pax_global_header00006660000000000000000000000064142310330360014505gustar00rootroot0000000000000052 comment=720fd8add4be6521877110c563d8aa167d1a36e1 django-simple-history-3.1.1/000077500000000000000000000000001423103303600157375ustar00rootroot00000000000000django-simple-history-3.1.1/.codeclimate.yml000066400000000000000000000016511423103303600210140ustar00rootroot00000000000000--- version: "2" # required to adjust maintainability checks checks: argument-count: config: threshold: 7 complex-logic: config: threshold: 7 file-lines: config: threshold: 500 method-complexity: config: threshold: 7 method-count: config: threshold: 20 method-lines: config: threshold: 25 nested-control-flow: config: threshold: 4 return-statements: config: threshold: 4 similar-code: config: threshold: 50 # language-specific defaults. an override will affect all languages. identical-code: config: threshold: # language-specific defaults. an override will affect all languages. plugins: bandit: enabled: true pep8: enabled: false radon: enabled: true threshold: "C" sonar-python: enabled: true exclude_patterns: - "simple_history/tests/" - "simple_history/registry_tests/" django-simple-history-3.1.1/.coveragerc000066400000000000000000000001121423103303600200520ustar00rootroot00000000000000[run] include = simple_history/* omit = simple_history/tests/* branch = 1 django-simple-history-3.1.1/.editorconfig000066400000000000000000000006641423103303600204220ustar00rootroot00000000000000; 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-3.1.1/.github/000077500000000000000000000000001423103303600172775ustar00rootroot00000000000000django-simple-history-3.1.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001423103303600214625ustar00rootroot00000000000000django-simple-history-3.1.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013761423103303600241630ustar00rootroot00000000000000--- 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-3.1.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000010031423103303600252010ustar00rootroot00000000000000--- 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-3.1.1/.github/dependabot.yml000066400000000000000000000001721423103303600221270ustar00rootroot00000000000000--- version: 2 updates: - package-ecosystem: "pip" directory: "/requirements" schedule: interval: "daily" django-simple-history-3.1.1/.github/workflows/000077500000000000000000000000001423103303600213345ustar00rootroot00000000000000django-simple-history-3.1.1/.github/workflows/release.yml000066400000000000000000000017741423103303600235100ustar00rootroot00000000000000--- name: Release on: push: tags: - '*' jobs: build: if: github.repository == 'jazzband/django-simple-history' runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.8 - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U setuptools twine wheel - name: Build package run: | python setup.py --version python setup.py sdist --format=gztar bdist_wheel twine check dist/* - name: Upload packages to Jazzband if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: user: jazzband password: ${{ secrets.JAZZBAND_RELEASE_KEY }} repository_url: https://jazzband.co/projects/django-simple-history/upload django-simple-history-3.1.1/.github/workflows/test.yml000066400000000000000000000047761423103303600230540ustar00rootroot00000000000000--- name: Test on: [push, pull_request] jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-18.04 strategy: fail-fast: false matrix: python-version: ['3.7', '3.8', '3.9', '3.10'] django-version: ['3.2', '4.0', 'main'] exclude: - python-version: '3.7' django-version: '4.0' - python-version: '3.7' django-version: 'main' - python-version: '3.10' django-version: '3.1' services: postgres: image: postgres:latest env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: github_actions ports: - 5432:5432 # needed because the postgres container does not provide a healthcheck options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 mysql: image: mysql:latest env: MYSQL_DATABASE: mysql MYSQL_ROOT_PASSWORD: mysql ports: - 3306:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 mariadb: image: mariadb:latest env: MYSQL_DATABASE: mariadb MYSQL_ROOT_PASSWORD: mariadb ports: - 3307:3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v2 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install -r requirements/tox.txt - name: Tox tests run: | tox -v env: DJANGO: ${{ matrix.django-version }} - name: Upload coverage uses: codecov/codecov-action@v1 with: name: Python ${{ matrix.python-version }} django-simple-history-3.1.1/.gitignore000066400000000000000000000002551423103303600177310ustar00rootroot00000000000000*.egg *.egg-info/ *.eggs *.pyc .coverage .idea .tox/ .venv/ /.project /.pydevproject /.ve build/ dist/ docs/_build htmlcov/ MANIFEST test_files/ venv/ .DS_Store env .vscode django-simple-history-3.1.1/.pre-commit-config.yaml000066400000000000000000000020271423103303600222210ustar00rootroot00000000000000--- repos: - repo: https://github.com/PyCQA/bandit rev: 1.7.4 hooks: - id: bandit args: - "-x *test*.py" - repo: https://github.com/psf/black rev: 22.3.0 hooks: - id: black language_version: python3.8 - repo: https://github.com/pycqa/flake8 rev: 4.0.1 hooks: - id: flake8 args: - "--config=tox.ini" - repo: https://github.com/PyCQA/isort rev: 5.10.1 hooks: - id: isort - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.2.0 hooks: - id: requirements-txt-fixer files: requirements/.*\.txt$ - id: trailing-whitespace - id: check-added-large-files - id: fix-byte-order-marker - id: check-docstring-first - id: check-executables-have-shebangs - id: check-merge-conflict - id: debug-statements - id: detect-private-key - repo: https://github.com/adrienverge/yamllint rev: v1.26.3 hooks: - id: yamllint args: - "--strict" django-simple-history-3.1.1/.yamllint000066400000000000000000000004431423103303600175720ustar00rootroot00000000000000# allow "on" until yamllint stops checking keys for truthy! # https://github.com/adrienverge/yamllint/issues/158 --- extends: default rules: comments-indentation: disable braces: disable line-length: max: 120 truthy: level: error allowed-values: ['true', 'false', 'on'] django-simple-history-3.1.1/AUTHORS.rst000066400000000000000000000122611423103303600176200ustar00rootroot00000000000000Maintainers =========== - Brian Armstrong (`barm `_) - David Grochowski (`ThePumpingLemma `_) - Kyle Seever (`kseever `_) - Micah Denbraver (`macro1 `_) - Ross Mechanic (`rossmechanic `_) - Trey Hunner (`treyhunner `_) Authors ======= - Adnan Umer (`uadnan `_) - Aleksey Kladov - Alexander Anikeev - Amanda Ng (`AmandaCLNg `_) - Amartis Gladius (`Amartis `_) - Ben Lawson (`blawson `_) - Benjamin Mampaey (`bmampaey `_) - `bradford281 `_ - Brian Armstrong (`barm `_) - Buddy Lindsey, Jr. - Brian Dixon - Carlos San Emeterio (`Carlos-San-Emeterio `_) - Christopher Broderick (`uhurusurfa `_) - Christopher Johns (`tyrantwave `_) - Corey Bertram - Craig Maloney (`craigmaloney `_) - Damien Nozay - Daniel Gilge - Daniel Levy - Daniel Roschka - Daniil Skrobov (`yetanotherape `_) - David Grochowski (`ThePumpingLemma `_) - David Hite - David Smith - Dmytro Shyshov (`xahgmah `_) - Edouard Richard (`vied12 ` _) - Eduardo Cuducos - Erik van Widenfelt (`erikvw `_) - Filipe Pina (@fopina) - Florian Eßer - François Martin (`martinfrancois `_) - Frank Sachsenheim - George Kettleborough (`georgek `_) - George Vilches - Gregory Bataille - Grzegorz Bialy - Guillermo Eijo (`guilleijo `_) - Hamish Downer - Hans de Jong (`sult `_) - Hanyin Zhang - Hernan Esteves (`sevetseh28 `_) - Hielke Walinga (`hwalinga `_) - Jack Cushman (`jcushman `_) - Jake Howard (`RealOrangeOne `_) - James Muranga (`jamesmura `_) - James Pulec - Jesse Shapiro - Jihoon Baek (`jihoon796 `_) - Jim Gomez - Jim King (`jeking3 `_) - Joao Junior (`joaojunior `_) - Joao Pedro Francese - `jofusa `_ - John Whitlock - Jonathan Leroy - Jonathan Loo (`alpha1d3d `_) - Jonathan Sanchez - Jonathan Zvesper (`zvesp `_) - Jordon Wing (`jordonwii `_) - Leticia Portella - Lucas Wiman - Maciej "RooTer" Urbański - Marcelo Canina (`marcanuy `_) - Mark Davidoff - Martin Bachwerk - Marty Alchin - Matheus Cansian (`mscansian `_) - Matthew Somerville (`dracos `_) - Mauricio de Abreu Antunes - Maxim Zemskov (`MaximZemskov `_) - Micah Denbraver - Michael England - Miguel Vargas - Mike Spainhower - Nathan Villagaray-Carski (`ncvc `_) - Nianpeng Li - Nick Träger - Phillip Marshall - Prakash Venkatraman (`dopatraman `_) - Rajesh Pappula - Ray Logel - Raynald de Lahondes - Renaud Perrin (`leminaw `_) - Roberto Aguilar - Rod Xavier Bondoc - Ross Lote - Ross Mechanic (`rossmechanic `_) - Ross Rogers - Sergey Ozeranskiy (`ozeranskiy `_) - Shane Engelman - Steeve Chailloux - Stefan Borer (`sbor23 `_) - Steven Buss (`sbuss `_) - Steven Klass - Tim Schilling (`tim-schilling `_) - Tommy Beadle (`tbeadle `_) - Trey Hunner (`treyhunner `_) - Ulysses Vilela - `vnagendra `_ - `yakimka `_ - `Paulo Peres `_ - `Alex Todorov `_ - David Smith (`smithdc1 `_) - Shi Han Ng (`shihanng `_) - `ddusi `_ - `DanialErfanian `_ - `Sridhar Marella `_ 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-3.1.1/CHANGES.rst000066400000000000000000000352311423103303600175450ustar00rootroot00000000000000Changes ======= Unreleased ---------- 3.1.1 (2022-04-23) ------------------ Full list of changes: - Fix py36 references in pyproject.toml (gh-960) - Fix local setup.py install versioning issue (gh-960) - Remove py2 universal wheel cfg - only py3 needed now (gh-960) 3.1.0 (2022-04-09) ------------------ Breaking Changes: - Dropped support for Django 2.2 (gh-968) - Dropped support for Django 3.1 (gh-952) - Dropped support for Python 3.6, which reached end-of-life on 2021-12-23 (gh-946) Upgrade Implications: - Run `makemigrations` after upgrading to realize the benefit of indexing changes. Full list of changes: - Added queryset-based filtering with ``as_of`` (gh-397) - Added index on `history_date` column; opt-out with setting `SIMPLE_HISTORY_DATE_INDEX` (gh-565) - RecordModels now support a ``no_db_index`` setting, to drop indices in historical models, default stays the same (gh-720) - Support ``included_fields`` for ``history.diff_against`` (gh-776) - Improve performance of ``history.diff_against`` by reducing number of queries to 0 in most cases (gh-776) - Fixed ``prev_record`` and ``next_record`` performance when using ``excluded_fields`` (gh-791) - Fixed `update_change_reason` in pk (gh-806) - Fixed bug where serializer of djangorestframework crashed if used with ``OrderingFilter`` (gh-821) - Fixed `make format` so it works by using tox (gh-859) - Fixed bug where latest() is not idempotent for identical ``history_date`` records (gh-861) - Added ``excluded_field_kwargs`` to support custom ``OneToOneField`` that have additional arguments that don't exist on ``ForeignKey``. (gh-870) - Added Czech translations (gh-885) - Added ability to break into debugger on unit test failure (gh-890) - Added pre-commit for better commit quality (gh-896) - Russian translations update (gh-897) - Added support for Django 4.0 (gh-898) - Added Python 3.10 to test matrix (gh-899) - Fix bug with ``history.diff_against`` with non-editable fields (gh-923) - Added HistoricForeignKey (gh-940) - Support change reason formula feature. Change reason formula can be defined by overriding ``get_change_reason_for_object`` method after subclassing ``HistoricalRecords`` (gh-962) 3.0.0 (2021-04-16) ------------------ Breaking changes: - Removed support for Django 3.0 - Removed `changeReason` in favor of `_change_reason` (see 2.10.0) Full list of changes: - Removed support for Django versions prior to 2.2 (gh-652) - Migrate from TravisCI to Github Actions (gh-739) - Add Python 3.9 support (gh-745) - Support ``ignore_conflicts`` in ``bulk_create_with_history`` (gh-733) - Use ``asgiref`` when available instead of thread locals (gh-747) - Sort imports with isort (gh-751) - Queryset ``history.as_of`` speed improvements by calculating in the DB (gh-758) - Increase `black` and `isort` python version to 3.6 (gh-817) - Remove Django 3.0 support (gh-817) - Add Django 3.2 support (gh-817) - Improve French translations (gh-811) - Remove support for changeReason (gh-819) 2.12.0 (2020-10-14) ------------------- - Add default date to ``bulk_create_with_history`` and ``bulk_update_with_history`` (gh-687) - Exclude ManyToManyFields when using ``bulk_create_with_history`` (gh-699) - Added ``--excluded_fields`` argument to ``clean_duplicate_history`` command (gh-674) - Exclude ManyToManyFields when fetching excluded fields (gh-707) - Use default model manager for ``bulk_create_with_history`` and ``bulk_update_with_history`` instead of ``objects`` (gh-703) - Add optional ``manager`` argument to ``bulk_update_with_history`` to use instead of the default manager (gh-703) - Add support for Django 3.1 (gh-713) - Fix a bug with ``clean_old_history`` command's `--days` argument (gh-722) \* NOTE: This will be the last minor release before 3.0.0. 2.11.0 (2020-06-20) ------------------- - Added ``clean_old_history`` management command (gh-675) - Added ``user_db_constraint`` param to history to avoid circular reference on delete (gh-676) - Leverages ``get_user`` from ``HistoricalRecords`` in order to set a fallback user on bulk update and bulk create (gh-677) 2.10.0 (2020-04-27) ------------------- - Added ``bulk_update_with_history`` utility function (gh-650) - Add default user and default change reason to ``bulk_create_with_history`` and ``bulk_update_with_history`` (gh-653) - Add french translation (gh-654) - Start using ``_change_reason`` instead of ``changeReason`` to add change reasons to historical objects. ``changeReason`` is deprecated and will be removed in version ``3.0.0`` (gh-655) 2.9.0 (2020-04-23) ------------------ - Add simple filtering if provided a minutes argument in ``clean_duplicate_history`` (gh-606) - Add setting to convert ``FileField`` to ``CharField`` instead of ``TextField`` (gh-625) - Added notes on BitBucket Pipelines (gh-627) - import model ``ContentType`` in ``SimpleHistoryAdmin`` using ``django_apps.get_model`` to avoid possible ``AppRegistryNotReady`` exception (gh-630) - Fix ``utils.update_change_reason`` when user specifies excluded_fields (gh-637) - Changed how ``now`` is imported from ``timezone`` (``timezone`` module is imported now) (gh-643) - ``settings.SIMPLE_HISTORY_REVERT_DISABLED`` if True removes the Revert button from the history form for all historical models (gh-632)) 2.8.0 (2019-12-02) ------------------ - Fixed ``bulk_create_with_history support`` for HistoryRecords with ``relation_name`` attribute (gh-591) - Added support for ``bulk_create_with_history`` for databases different from PostgreSQL (gh-577) - Fixed ``DoesNotExist`` error when trying to get instance if object is deleted (gh-571) - Fix ``model_to_dict`` to detect changes in a parent model when using ``inherit=True`` (backwards-incompatible for users who were directly using previous version) (gh-576) - Use an iterator for ``clean_duplicate_history`` (gh-604) - Add support for Python 3.8 and Django 3.0 (gh-610) 2.7.3 (2019-07-15) ------------------ - Fixed ``BigAutoField`` not mirrored as ``BigInt`` (gh-556) - Fixed ``most_recent()`` bug with ``excluded_fields`` (gh-561) - Added official Django 2.2 support (gh-555) 2.7.2 (2019-04-17) ------------------ - Fixed ModuleNotFound issue for ``six`` (gh-553) 2.7.1 (2019-04-16) ------------------ - Added the possibility to create a relation to the original model (gh-536) - Fix router backward-compatibility issue with 2.7.0 (gh-539, gh-547) - Fix hardcoded history manager (gh-542) - Replace deprecated ``django.utils.six`` with ``six`` (gh-526) - Allow ``custom_model_name`` parameter to be a callable (gh-489) 2.7.0 (2019-01-16) ------------------ - \* Add support for ``using`` chained manager method and save/delete keyword argument (gh-507) - Added management command ``clean_duplicate_history`` to remove duplicate history entries (gh-483) - Updated most_recent to work with excluded_fields (gh-477) - Fixed bug that prevented self-referential foreign key from using ``'self'`` (gh-513) - Added ability to track custom user with explicit custom ``history_user_id_field`` (gh-511) - Don't resolve relationships for history objects (gh-479) - Reorganization of docs (gh-510) \* NOTE: This change was not backward compatible for users using routers to write history tables to a separate database from their base tables. This issue is fixed in 2.7.1. 2.6.0 (2018-12-12) ------------------ - Add ``app`` parameter to the constructor of ``HistoricalRecords`` (gh-486) - Add ``custom_model_name`` parameter to the constructor of ``HistoricalRecords`` (gh-451) - Fix header on history pages when custom site_header is used (gh-448) - Modify ``pre_create_historical_record`` to pass ``history_instance`` for ease of customization (gh-421) - Raise warning if ``HistoricalRecords(inherit=False)`` is in an abstract model (gh-341) - Ensure custom arguments for fields are included in historical models' fields (gh-431) - Add german translations (gh-484) - Add ``extra_context`` parameter to history_form_view (gh-467) - Fixed bug that prevented ``next_record`` and ``prev_record`` to work with custom manager names (gh-501) 2.5.1 (2018-10-19) ------------------ - Add ``'+'`` as the ``history_type`` for each instance in ``bulk_history_create`` (gh-449) - Add support for ``history_change_reason`` for each instance in ``bulk_history_create`` (gh-449) - Add ``history_change_reason`` in the history list view under the ``Change reason`` display name (gh-458) - Fix bug that caused failures when using a custom user model (gh-459) 2.5.0 (2018-10-18) ------------------ - Add ability to cascade delete historical records when master record is deleted (gh-440) - Added Russian localization (gh-441) 2.4.0 (2018-09-20) ------------------ - Add pre and post create_historical_record signals (gh-426) - Remove support for ``django_mongodb_engine`` when converting AutoFields (gh-432) - Add support for Django 2.1 (gh-418) 2.3.0 (2018-07-19) ------------------ - Add ability to diff ``HistoricalRecords`` (gh-244) 2.2.0 (2018-07-02) ------------------ - Add ability to specify alternative user_model for tracking (gh-371) - Add util function ``bulk_create_with_history`` to allow bulk_create with history saved (gh-412) 2.1.1 (2018-06-15) ------------------ - Fixed out-of-memory exception when running populate_history management command (gh-408) - Fix TypeError on populate_history if excluded_fields are specified (gh-410) 2.1.0 (2018-06-04) ------------------ - Add ability to specify custom ``history_reason`` field (gh-379) - Add ability to specify custom ``history_id`` field (gh-368) - Add HistoricalRecord instance properties ``prev_record`` and ``next_record`` (gh-365) - Can set admin methods as attributes on object history change list template (gh-390) - Fixed compatibility of >= 2.0 versions with old-style middleware (gh-369) 2.0 (2018-04-05) ---------------- - Added Django 2.0 support (gh-330) - Dropped support for Django<=1.10 (gh-356) - Fix bug where ``history_view`` ignored user permissions (gh-361) - Fixed ``HistoryRequestMiddleware`` which hadn't been working for Django>1.9 (gh-364) 1.9.1 (2018-03-30) ------------------ - Use ``get_queryset`` rather ``than model.objects`` in ``history_view``. (gh-303) - Change ugettext calls in models.py to ugettext_lazy - Resolve issue where model references itself (gh-278) - Fix issue with tracking an inherited model (abstract class) (gh-269) - Fix history detail view on django-admin for abstract models (gh-308) - Dropped support for Django<=1.6 and Python 3.3 (gh-292) 1.9.0 (2017-06-11) ------------------ - Add ``--batchsize`` option to the ``populate_history`` management command. (gh-231) - Add ability to show specific attributes in admin history list view. (gh-256) - Add Brazilian Portuguese translation file. (gh-279) - Fix locale file packaging issue. (gh-280) - Add ability to specify reason for history change. (gh-275) - Test against Django 1.11 and Python 3.6. (gh-276) - Add ``excluded_fields`` option to exclude fields from history. (gh-274) 1.8.2 (2017-01-19) ------------------ - Add Polish locale. - Add Django 1.10 support. 1.8.1 (2016-03-19) ------------------ - Clear the threadlocal request object when processing the response to prevent test interactions. (gh-213) 1.8.0 (2016-02-02) ------------------ - History tracking can be inherited by passing ``inherit=True``. (gh-63) 1.7.0 (2015-12-02) ------------------ - Add ability to list history in admin when the object instance is deleted. (gh-72) - Add ability to change history through the admin. (Enabled with the ``SIMPLE_HISTORY_EDIT`` setting.) - Add Django 1.9 support. - Support for custom tables names. (gh-196) 1.6.3 (2015-07-30) ------------------ - Respect ``to_field`` and ``db_column`` parameters (gh-182) 1.6.2 (2015-07-04) ------------------ - Use app loading system and fix deprecation warnings on Django 1.8 (gh-172) - Update Landscape configuration 1.6.1 (2015-04-21) ------------------ - Fix OneToOneField transformation for historical models (gh-166) - Disable cascading deletes from related models to historical models - Fix restoring historical instances with missing one-to-one relations (gh-162) 1.6.0 (2015-04-16) ------------------ - Add support for Django 1.8+ - Deprecated use of ``CustomForeignKeyField`` (to be removed) - Remove default reverse accessor to ``auth.User`` for historical models (gh-121) 1.5.4 (2015-01-03) ------------------ - Fix a bug when models have a ``ForeignKey`` with ``primary_key=True`` - Do NOT delete the history elements when a user is deleted. - Add support for ``latest`` - Allow setting a reason for change. [using option changeReason] 1.5.3 (2014-11-18) ------------------ - Fix migrations while using ``order_with_respsect_to`` (gh-140) - Fix migrations using south - Allow history accessor class to be overridden in ``register()`` 1.5.2 (2014-10-15) ------------------ - Additional fix for migrations (gh-128) 1.5.1 (2014-10-13) ------------------ - Removed some incompatibilities with non-default admin sites (gh-92) - Fixed error caused by ``HistoryRequestMiddleware`` during anonymous requests (gh-115 fixes gh-114) - Added workaround for clashing related historical accessors on User (gh-121) - Added support for MongoDB AutoField (gh-125) - Fixed CustomForeignKeyField errors with 1.7 migrations (gh-126 fixes gh-124) 1.5.0 (2014-08-17) ------------------ - Extended availability of the ``as_of`` method to models as well as instances. - Allow ``history_user`` on historical objects to be set by middleware. - Fixed error that occurs when a foreign key is designated using just the name of the model. - Drop Django 1.3 support 1.4.0 (2014-06-29) ------------------ - Fixed error that occurs when models have a foreign key pointing to a one to one field. - Fix bug when model verbose_name uses unicode (gh-76) - Allow non-integer foreign keys - Allow foreign keys referencing the name of the model as a string - Added the ability to specify a custom ``history_date`` - Note that ``simple_history`` should be added to ``INSTALLED_APPS`` (gh-94 fixes gh-69) - Properly handle primary key escaping in admin URLs (gh-96 fixes gh-81) - Add support for new app loading (Django 1.7+) - Allow specifying custom base classes for historical models (gh-98) 1.3.0 (2013-05-17) ------------------ - Fixed bug when using ``django-simple-history`` on nested models package - Allow history table to be formatted correctly with ``django-admin-bootstrap`` - Disallow calling ``simple_history.register`` twice on the same model - Added Python 3 support - Added support for custom user model (Django 1.5+) 1.2.3 (2013-04-22) ------------------ - Fixed packaging bug: added admin template files to PyPI package 1.2.1 (2013-04-22) ------------------ - Added tests - Added history view/revert feature in admin interface - Various fixes and improvements Oct 22, 2010 ------------ - Merged setup.py from Klaas van Schelven - Thanks! Feb 21, 2010 ------------ - Initial project creation, with changes to support ForeignKey relations. django-simple-history-3.1.1/CODE_OF_CONDUCT.md000066400000000000000000000045071423103303600205440ustar00rootroot00000000000000# Code of Conduct As contributors and maintainers of the Jazzband projects, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. We are committed to making participation in the Jazzband a harassment-free experience for everyone, regardless of the level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. Examples of unacceptable behavior by participants include: - The use of sexualized language or imagery - Personal attacks - Trolling or insulting/derogatory comments - Public or private harassment - Publishing other's private information, such as physical or electronic addresses, without explicit permission - Other unethical or unprofessional conduct The Jazzband roadies have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. By adopting this Code of Conduct, the roadies commit themselves to fairly and consistently applying these principles to every aspect of managing the jazzband projects. Roadies who do not follow or enforce the Code of Conduct may be permanently removed from the Jazzband roadies. This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Roadies are obligated to maintain confidentiality with regard to the reporter of an incident. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] [homepage]: https://contributor-covenant.org [version]: https://contributor-covenant.org/version/1/3/0/ django-simple-history-3.1.1/CONTRIBUTING.rst000066400000000000000000000056331423103303600204070ustar00rootroot00000000000000Contributing to django-simple-history ===================================== .. image:: https://jazzband.co/static/img/jazzband.svg :target: https://jazzband.co/ :alt: Jazzband This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. Pull Requests ------------- Feel free to open pull requests before you've finished your code or tests. Opening your pull request soon will allow others to comment on it sooner. A checklist of things to remember when making a feature: - Write tests if applicable - Note important changes in the `CHANGES`_ file - Update the `README`_ file if needed - Update the documentation if needed - Add yourself to the `AUTHORS`_ file .. _AUTHORS: AUTHORS.rst .. _CHANGES: CHANGES.rst .. _README: README.rst Requirements ------------ The Makefile can be used for generating documentation and running tests. To install the requirements necessary for generating the documentation and running tests:: make init This will install: - `tox`_: used for running the tests against all supported versions of Django and Python as well as running tasks like lint, format, docs - `coverage`_: used for analyzing test coverage for tests If not using a virtualenv, the command should be prepended with ``sudo``. .. _tox: http://testrun.org/tox/latest// .. _coverage: http://nedbatchelder.com/code/coverage/ Documentation ------------- To regenerate the documentation run:: make docs Testing ------- Please add tests for your pull requests and make sure your changes don't break existing tests. To run tox and generate an HTML code coverage report (available in the ``htmlcov`` directory):: make test To quickly run the tests against a single version of Python and Django (note: you must ``pip install django`` beforehand):: python runtests.py Code Formatting --------------- We make use of `black`_ for code formatting. .. _black: https://black.readthedocs.io/en/stable/installation_and_usage.html You can install and run it along with other linters through pre-commit: pre-commit install pre-commit run Once you install pre-commit it will sanity check any commit you make. Additionally, the CI process runs this check as well. Translations ------------ In order to add translations, refer to Django's `translation docs`_ and follow these steps: 1. Ensure that Django is installed 2. Invoke ``django-admin makemessages -l `` in the repository's root directory. 3. Add translations to the created ``simple_history/locale//LC_MESSAGES/django.po`` file. 4. Compile these with ``django-admin compilemessages``. 5. Commit and publish your translations as described above. .. _translation docs: https://docs.djangoproject.com/en/dev/topics/i18n/translation/#localization-how-to-create-language-files django-simple-history-3.1.1/LICENSE.txt000066400000000000000000000030011423103303600175540ustar00rootroot00000000000000BSD 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-3.1.1/MANIFEST.in000066400000000000000000000002441423103303600174750ustar00rootroot00000000000000include MANIFEST.in include *.rst include *.txt recursive-include docs *.rst recursive-include simple_history/locale * recursive-include simple_history/templates * django-simple-history-3.1.1/Makefile000066400000000000000000000014021423103303600173740ustar00rootroot00000000000000all: 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 test: coverage erase tox coverage html docs: documentation documentation: tox -e docs 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: tox -e format django-simple-history-3.1.1/PULL_REQUEST_TEMPLATE.md000066400000000000000000000033271423103303600215450ustar00rootroot00000000000000 ## Description ## Related Issue ## Motivation and Context ## How Has This Been Tested? ## Screenshots (if appropriate): ## Types of changes - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) ## Checklist: - [ ] I have run the `pre-commit run` command to format and lint. - [ ] My change requires a change to the documentation. - [ ] I have updated the documentation accordingly. - [ ] I have read the **CONTRIBUTING** document. - [ ] I have added tests to cover my changes. - [ ] I have added my name and/or github handle to `AUTHORS.rst` - [ ] I have added my change to `CHANGES.rst` - [ ] All new and existing tests passed. django-simple-history-3.1.1/README.rst000066400000000000000000000041521423103303600174300ustar00rootroot00000000000000django-simple-history ===================== .. image:: https://github.com/jazzband/django-simple-history/workflows/build/badge.svg?branch=master :target: https://github.com/jazzband/django-simple-history/actions?workflow=build :alt: Build Status .. image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest :target: https://django-simple-history.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: https://img.shields.io/codecov/c/github/jazzband/django-simple-history/master.svg :target: http://codecov.io/github/jazzband/django-simple-history?branch=master :alt: Test Coverage .. image:: https://img.shields.io/pypi/v/django-simple-history.svg :target: https://pypi.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 .. image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ :alt: Jazzband django-simple-history stores Django model state on every create/update/delete. This app supports the following combinations of Django and Python: ========== ======================== Django Python ========== ======================== 3.2 3.7, 3.8, 3.9, 3.10 4.0 3.8, 3.9, 3.10 ========== ======================== 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/jazzband/django-simple-history/blob/master/CONTRIBUTING.rst License ------- This project is licensed under the `BSD 3-Clause license `_. django-simple-history-3.1.1/codecov.yml000066400000000000000000000002501423103303600201010ustar00rootroot00000000000000--- coverage: status: patch: default: informational: true project: default: informational: true ignore: - "requirements/*.txt" django-simple-history-3.1.1/doc-requirements.txt000066400000000000000000000000461423103303600217660ustar00rootroot00000000000000Sphinx==1.2.3 sphinx-autobuild==0.3.0 django-simple-history-3.1.1/docs/000077500000000000000000000000001423103303600166675ustar00rootroot00000000000000django-simple-history-3.1.1/docs/Makefile000066400000000000000000000152461423103303600203370ustar00rootroot00000000000000# 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-3.1.1/docs/_static/000077500000000000000000000000001423103303600203155ustar00rootroot00000000000000django-simple-history-3.1.1/docs/_static/.keep000066400000000000000000000000001423103303600212300ustar00rootroot00000000000000django-simple-history-3.1.1/docs/admin.rst000066400000000000000000000051721423103303600205160ustar00rootroot00000000000000Admin 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 Disabling the option to revert an object ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default, an object can be reverted to its previous version. To disable this option update your settings with the following: .. code-block:: python SIMPLE_HISTORY_REVERT_DISABLED=True When ``SIMPLE_HISTORY_REVERT_DISABLED`` is set to ``True``, the revert button is removed from the form. .. image:: screens/10_revert_disabled.png django-simple-history-3.1.1/docs/common_issues.rst000066400000000000000000000253471423103303600223170ustar00rootroot00000000000000Common Issues ============= Bulk Creating and Queryset Updating ----------------------------------- ``django-simple-history`` functions by saving history using a ``post_save`` signal every time that an object with history is saved. However, for certain bulk operations, such as bulk_create_, bulk_update_, and `queryset updates`_, signals are not sent, and the history is not saved automatically. However, ``django-simple-history`` provides utility functions to work around this. Bulk Creating a Model with History ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ As of ``django-simple-history`` 2.2.0, we can use the utility function ``bulk_create_with_history`` in order to bulk create objects while saving their history: .. _bulk_create: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#bulk-create .. _bulk_update: https://docs.djangoproject.com/en/3.0/ref/models/querysets/#bulk-update .. code-block:: pycon >>> from simple_history.utils import bulk_create_with_history >>> from simple_history.tests.models import Poll >>> from django.utils.timezone import now >>> >>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)] >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> Poll.objects.count() 1000 >>> Poll.history.count() 1000 If you want to specify a change reason or history user for each record in the bulk create, you can add `_change_reason`, `_history_user` or `_history_date` on each instance: .. code-block:: pycon >>> for poll in data: poll._change_reason = 'reason' poll._history_user = my_user poll._history_date = some_date >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> Poll.history.get(id=data[0].id).history_change_reason 'reason' You can also specify a default user or default change reason responsible for the change (`_change_reason`, `_history_user` and `_history_date` take precedence). .. code-block:: pycon >>> user = User.objects.create_user("tester", "tester@example.com") >>> objs = bulk_create_with_history(data, Poll, batch_size=500, default_user=user) >>> Poll.history.get(id=data[0].id).history_user == user True Bulk Updating a Model with History (New) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Bulk update was introduced with Django 2.2. We can use the utility function ``bulk_update_with_history`` in order to bulk update objects using Django's ``bulk_update`` function while saving the object history: .. code-block:: pycon >>> from simple_history.utils import bulk_update_with_history >>> from simple_history.tests.models import Poll >>> from django.utils.timezone import now >>> >>> data = [Poll(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)] >>> objs = bulk_create_with_history(data, Poll, batch_size=500) >>> for obj in objs: obj.question = 'Duplicate Questions' >>> bulk_update_with_history(objs, Poll, ['question'], batch_size=500) >>> Poll.objects.first().question 'Duplicate Question`` If your models require the use of an alternative model manager (usually because the default manager returns a filtered set), you can specify which manager to use with the ``manager`` argument: .. code-block:: pycon >>> from simple_history.utils import bulk_update_with_history >>> from simple_history.tests.models import PollWithAlternativeManager >>> >>> data = [PollWithAlternativeManager(id=x, question='Question ' + str(x), pub_date=now()) for x in range(1000)] >>> objs = bulk_create_with_history(data, PollWithAlternativeManager, batch_size=500, manager=PollWithAlternativeManager.all_polls) QuerySet Updates with History (Updated in Django 2.2) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Unlike with ``bulk_create``, `queryset updates`_ perform an SQL update query on the queryset, and never return the actual updated objects (which would be necessary for the inserts into the historical table). Thus, we tell you that queryset updates will not save history (since no ``post_save`` signal is sent). As the Django documentation says:: If you want to update a bunch of records for a model that has a custom ``save()`` method, loop over them and call ``save()``, like this: .. code-block:: python for e in Entry.objects.filter(pub_date__year=2010): e.comments_on = False e.save() .. _queryset updates: https://docs.djangoproject.com/en/2.2/ref/models/querysets/#update Note: Django 2.2 now allows ``bulk_update``. No ``pre_save`` or ``post_save`` signals are sent still. Tracking Custom Users --------------------- - ``fields.E300``:: ERRORS: custom_user.HistoricalCustomUser.history_user: (fields.E300) Field defines a relation with model 'custom_user.CustomUser', which is either not installed, or is abstract. Use ``register()`` to track changes to the custom user model instead of setting ``HistoricalRecords`` on the model directly. The reason for this, is that unfortunately ``HistoricalRecords`` cannot be set directly on a swapped user model because of the user foreign key to track the user making changes. Using 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.context, 'request'): del HistoricalRecords.context.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 Reserved Field Names -------------------- For each base model that has its history tracked using ``django-simple-history``, an associated historical model is created. Thus, if we have: .. code-block:: python class BaseModel(models.Model): history = HistoricalRecords() a Django model called ``HistoricalBaseModel`` is also created with all of the fields from ``BaseModel``, plus a few extra fields and methods that are on all historical models. Since these fields and methods are on all historical models, any field or method names on a base model that clash with those names will not be on the historical model (and, thus, won't be tracked). The reserved historical field and method names are below: - ``history_id`` - ``history_date`` - ``history_change_reason`` - ``history_type`` - ``history_object`` - ``history_user`` - ``history_user_id`` - ``instance`` - ``instance_type`` - ``next_record`` - ``prev_record`` - ``revert_url`` - ``__str__`` So if we have: .. code-block:: python class BaseModel(models.Model): instance = models.CharField(max_length=255) history = HistoricalRecords() the ``instance`` field will not actually be tracked on the history table because it's in the reserved set of terms. Multi-table Inheritance ----------------------- ``django-simple-history`` supports tracking history on models that use multi-table inheritance, such as: .. code-block:: python class ParentModel(models.Model): parent_field = models.CharField(max_length=255) history = HistoricalRecords() class ChildModel(ParentModel): child_field = models.CharField(max_length=255) history = HistoricalRecords() A few notes: - On the child model, the ``HistoricalRecords`` instance is not inherited from the parent model. This means that you can choose to track changes on just the parent model, just the child model, or both. - The child's history table contains all fields from the child model as well as all the fields from the parent model. - Updating a child instance only updates the child's history table, not the parent's history table. Usage with django-modeltranslation ---------------------------------- If you have ``django-modeltranslation`` installed, you will need to use the ``register()`` method to model translation, as described `here `__. Pointing to the model --------------------- Sometimes you have to point to the model of the historical records. Examples are Django's generic views or Django REST framework's serializers. You can get there through your HistoricalRecords manager you defined in your model. According to our example: .. code-block:: python class PollHistoryListView(ListView): # or PollHistorySerializer(ModelSerializer): class Meta: model = Poll.history.model # ... Working with BitBucket Pipelines -------------------------------- When using BitBucket Pipelines to test your Django project with the django-simple-history middleware, you will run into an error relating to missing migrations relating to the historic User model from the auth app. This is because the migration file is not held within either your project or django-simple-history. In order to pypass the error you need to add a ```python manage.py makemigrations auth``` step into your YML file prior to running the tests. Using custom OneToOneFields --------------------------- If you are using a custom OneToOneField that has additional arguments and receiving the the following ``TypeError``:: .. code=block:: python TypeError: __init__() got an unexpected keyword argument This is because Django Simple History coerces ``OneToOneField`` into ``ForeignKey`` on the historical model. You can work around this by excluded those additional arguments using ``excluded_field_kwargs`` as follows: .. code-block:: python class Poll(models.Model): organizer = CustomOneToOneField(Organizer, ..., custom_argument="some_value") history = HistoricalRecords( excluded_field_kwargs={"organizer": set(["custom_argument"])} ) django-simple-history-3.1.1/docs/conf.py000066400000000000000000000202331423103303600201660ustar00rootroot00000000000000# # django-simple-history documentation build configuration file, created by # sphinx-quickstart on Sun May 5 16:10:02 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys from pkg_resources import get_distribution # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath("..")) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ["sphinx.ext.autodoc"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "django-simple-history" copyright = "2013, Corey Bertram" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # release = get_distribution("django-simple-history").version # for example take major/minor version = ".".join(release.split(".")[:2]) # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = '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", "django-simple-history Documentation", "Corey Bertram", "manual", ), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( "index", "django-simple-history", "django-simple-history Documentation", ["Corey Bertram"], 1, ) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( "index", "django-simple-history", "django-simple-history Documentation", "Corey Bertram", "django-simple-history", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False django-simple-history-3.1.1/docs/historical_model.rst000066400000000000000000000355351423103303600227550ustar00rootroot00000000000000Historical Model Customizations =============================== Custom ``history_id`` --------------------- By default, the historical table of a model will use an ``AutoField`` for the table's ``history_id`` (the history table's primary key). However, you can specify a different type of field for ``history_id`` by passing a different field to ``history_id_field`` parameter. The example below uses a ``UUIDField`` instead of an ``AutoField``: .. code-block:: python import uuid from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords( history_id_field=models.UUIDField(default=uuid.uuid4) ) Since using a ``UUIDField`` for the ``history_id`` is a common use case, there is a ``SIMPLE_HISTORY_HISTORY_ID_USE_UUID`` setting that will set all instances of ``history_id`` to UUIDs. Set this with the following line in your ``settings.py`` file: .. code-block:: python SIMPLE_HISTORY_HISTORY_ID_USE_UUID = True This setting can still be overridden using the ``history_id_field`` parameter on a per model basis. You can use the ``history_id_field`` parameter with both ``HistoricalRecords()`` or ``register()`` to change this behavior. Note: regardless of what field type you specify as your history_id field, that field will automatically set ``primary_key=True`` and ``editable=False``. Custom ``history_date`` ----------------------- You're able to set a custom ``history_date`` attribute for the historical record, by defining the property ``_history_date`` in your model. That's helpful if you want to add versions to your model, which happened before the current model version, e.g. when batch importing historical data. The content of the property ``_history_date`` has to be a datetime-object, but setting the value of the property to a ``DateTimeField``, which is already defined in the model, will work too. .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey('auth.User') history = HistoricalRecords() __history_date = None @property def _history_date(self): return self.__history_date @_history_date.setter def _history_date(self, value): self.__history_date = value .. code-block:: python from datetime import datetime from models import Poll my_poll = Poll(question="what's up?") my_poll._history_date = datetime.now() my_poll.save() Indexed ``history_date`` ------------------------ Many queries use ``history_date`` as a filter. The as_of queries combine this with the original model's primary key to extract point-in-time snapshots of history. By default the ``history_date`` field is indexed. You can control this behavior using settings.py. .. code-block:: python # disable indexing on history_date SIMPLE_HISTORY_DATE_INDEX = False # enable indexing on history_date (default setting) SIMPLE_HISTORY_DATE_INDEX = True # enable composite indexing on history_date and model pk (to improve as_of queries) # the string is case-insensitive SIMPLE_HISTORY_DATE_INDEX = "Composite" Custom history table name ------------------------- By default, the table name for historical models follow the Django convention and just add ``historical`` before model name. For instance, if your application name is ``polls`` and your model name ``Question``, then the table name will be ``polls_historicalquestion``. You can use the ``table_name`` parameter with both ``HistoricalRecords()`` or ``register()`` to change this behavior. .. code-block:: python class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords(table_name='polls_question_history') .. code-block:: python class Question(models.Model): question_text = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') register(Question, table_name='polls_question_history') Custom model name ----------------- By default, historical model is named as 'Historical' + model name. For example, historical records for ``Choice`` is called ``HistoricalChoice``. Users can specify a custom model name via the constructor on ``HistoricalRecords``. The common use case for this is avoiding naming conflict if the user already defined a model named as 'Historical' + model name. This feature provides the ability to override the default model name used for the generated history model. To configure history models to use a different name for the history model class, use an option named ``custom_model_name``. The value for this option can be a `string` or a `callable`. A simple string replaces the default name of `'Historical' + model name` with the defined string. The most simple use case is illustrated below using a simple string: .. code-block:: python class ModelNameExample(models.Model): history = HistoricalRecords( custom_model_name='SimpleHistoricalModelNameExample' ) If you are using a base class for your models and want to apply a name change for the historical model for all models using the base class then a callable can be used. The callable is passed the name of the model for which the history model will be created. As an example using the callable mechanism, the below changes the default prefix `Historical` to `Audit`: .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(custom_model_name=lambda x:f'Audit{x}') class Opinion(models.Model): opinion = models.CharField(max_length=2000) register(Opinion, custom_model_name=lambda x:f'Audit{x}') The resulting history class names would be `AuditPoll` and `AuditOpinion`. If the app the models are defined in is `yoda` then the corresponding history table names would be `yoda_auditpoll` and `yoda_auditopinion` IMPORTANT: Setting `custom_model_name` to `lambda x:f'{x}'` is not permitted. An error will be generated and no history model created if they are the same. TextField as `history_change_reason` ------------------------------------ The ``HistoricalRecords`` object can be customized to accept a ``TextField`` model field for saving the `history_change_reason` either through settings or via the constructor on the model. The common use case for this is for supporting larger model change histories to support changelog-like features. .. code-block:: python SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD=True or .. code-block:: python class TextFieldExample(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords( history_change_reason_field=models.TextField(null=True) ) Change Base Class of HistoricalRecord Models -------------------------------------------- To change the auto-generated HistoricalRecord models base class from ``models.Model``, pass in the abstract class in a list to ``bases``. .. code-block:: python class RoutableModel(models.Model): class Meta: abstract = True class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') changed_by = models.ForeignKey('auth.User') history = HistoricalRecords(bases=[RoutableModel]) Excluded Fields -------------------------------- It is possible to use the parameter ``excluded_fields`` to choose which fields will be stored on every create/update/delete. For example, if you have the model: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') And you don't want to store the changes for the field ``pub_date``, it is necessary to update the model to: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords(excluded_fields=['pub_date']) By default, django-simple-history stores the changes for all fields in the model. Adding additional fields to historical models --------------------------------------------- Sometimes it is useful to be able to add additional fields to historical models that do not exist on the source model. This is possible by combining the ``bases`` functionality with the ``pre_create_historical_record`` signal. .. code-block:: python # in models.py class IPAddressHistoricalModel(models.Model): """ Abstract model for history models tracking the IP address. """ ip_address = models.GenericIPAddressField(_('IP address')) class Meta: abstract = True class PollWithExtraFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField('date published') history = HistoricalRecords(bases=[IPAddressHistoricalModel,]) .. code-block:: python # define your signal handler/callback anywhere outside of models.py def add_history_ip_address(sender, **kwargs): history_instance = kwargs['history_instance'] # context.request for use only when the simple_history middleware is on and enabled history_instance.ip_address = HistoricalRecords.context.request.META['REMOTE_ADDR'] .. code-block:: python # in apps.py class TestsConfig(AppConfig): def ready(self): from simple_history.tests.models \ import HistoricalPollWithExtraFields pre_create_historical_record.connect( add_history_ip_address, sender=HistoricalPollWithExtraFields ) More information on signals in ``django-simple-history`` is available in :doc:`/signals`. Change Reason ------------- Change reason is a message to explain why the change was made in the instance. It is stored in the field ``history_change_reason`` and its default value is ``None``. By default, the django-simple-history gets the change reason in the field ``_change_reason`` of the instance. Also, it is possible to pass the ``_change_reason`` explicitly. For this, after a save or delete in an instance, it is necessary to call the function ``utils.update_change_reason``. The first argument of this function is the instance and the second is the message that represents the change reason. For instance, for the model: .. code-block:: python from django.db import models from simple_history.models import HistoricalRecords class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords() You can create an instance with an implicit change reason. .. code-block:: python poll = Poll(question='Question 1') poll._change_reason = 'Add a question' poll.save() Or you can pass the change reason explicitly: .. code-block:: python from simple_history.utils import update_change_reason poll = Poll(question='Question 1') poll.save() update_change_reason(poll, 'Add a question') Deleting historical record -------------------------- In some circumstances you may want to delete all the historical records when the master record is deleted. This can be accomplished by setting ``cascade_delete_history=True``. .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(cascade_delete_history=True) Allow tracking to be inherited --------------------------------- By default history tracking is only added for the model that is passed to ``register()`` or has the ``HistoricalRecords`` descriptor. By passing ``inherit=True`` to either way of registering you can change that behavior so that any child model inheriting from it will have historical tracking as well. Be careful though, in cases where a model can be tracked more than once, ``MultipleRegistrationsError`` will be raised. .. code-block:: python from django.contrib.auth.models import User from django.db import models from simple_history import register from simple_history.models import HistoricalRecords # register() example register(User, inherit=True) # HistoricalRecords example class Poll(models.Model): history = HistoricalRecords(inherit=True) Both ``User`` and ``Poll`` in the example above will cause any model inheriting from them to have historical tracking as well. History Model In Different App ------------------------------ By default the app_label for the history model is the same as the base model. In some circumstances you may want to have the history models belong in a different app. This will support creating history models in a different database to the base model using database routing functionality based on app_label. To configure history models in a different app, add this to the HistoricalRecords instantiation or the record invocation: ``app="SomeAppName"``. .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords(app="SomeAppName") class Opinion(models.Model): opinion = models.CharField(max_length=2000) register(Opinion, app="SomeAppName") `FileField` as a `CharField` ---------------------------- By default a ``FileField`` in the base model becomes a ``TextField`` in the history model. This is a historical choice that django-simple-history preserves for backwards compatibility; it is more correct for a ``FileField`` to be converted to a ``CharField`` instead. To opt into the new behavior, set the following line in your ``settings.py`` file: .. code-block:: python SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD = True Drop Database Indices -------------------------------- It is possible to use the parameter ``no_db_index`` to choose which fields that will not create a database index. For example, if you have the model: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200, db_index=True) And you don't want to create database index for ``question``, it is necessary to update the model to: .. code-block:: python class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200, db_index=True) history = HistoricalRecords(no_db_index=['question']) By default, django-simple-history keeps all indices. and even forces them on unique fields and relations. WARNING: This will drop performance on historical lookups django-simple-history-3.1.1/docs/history_diffing.rst000066400000000000000000000020211423103303600226030ustar00rootroot00000000000000History 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)) ``diff_against`` also accepts 2 arguments ``excluded_fields`` and ``included_fields`` to either explicitly include or exclude fields from being diffed. django-simple-history-3.1.1/docs/index.rst000066400000000000000000000041051423103303600205300ustar00rootroot00000000000000django-simple-history ===================== .. image:: https://secure.travis-ci.org/jazzband/django-simple-history.svg?branch=master :target: http://travis-ci.org/jazzband/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/jazzband/django-simple-history/master.svg :target: http://codecov.io/github/jazzband/django-simple-history?branch=master :alt: Test Coverage .. image:: https://img.shields.io/pypi/v/django-simple-history.svg :target: https://pypi.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 .. image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ :alt: Jazzband django-simple-history stores Django model state on every create/update/delete. This app supports the following combinations of Django and Python: ========== ======================= Django Python ========== ======================= 3.2 3.7, 3.8, 3.9 4.0 3.8, 3.9, 3.10 ========== ======================= Contribute ---------- - Issue Tracker: https://github.com/jazzband/django-simple-history/issues - Source Code: https://github.com/jazzband/django-simple-history Pull requests are welcome. Documentation ------------- .. toctree:: :maxdepth: 2 quick_start querying_history admin historical_model user_tracking signals history_diffing multiple_dbs utils common_issues .. include:: ../CHANGES.rst django-simple-history-3.1.1/docs/make.bat000066400000000000000000000145311423103303600203000ustar00rootroot00000000000000@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-3.1.1/docs/multiple_dbs.rst000066400000000000000000000037701423103303600221130ustar00rootroot00000000000000Multiple databases ================== Interacting with Multiple Databases ----------------------------------- `django-simple-history` follows the Django conventions for interacting with multiple databases. .. code-block:: python >>> # This will create a new historical record on the 'other' database. >>> poll = Poll.objects.using('other').create(question='Question 1') >>> # This will also create a new historical record on the 'other' database. >>> poll.save(using='other') When interacting with ``QuerySets``, use ``using()``: .. code-block:: python >>> # This will return a QuerySet from the 'other' database. Poll.history.using('other').all() When interacting with manager methods, use ``db_manager()``: .. code-block:: python >>> # This will call a manager method on the 'other' database. >>> poll.history.db_manager('other').as_of(datetime(2010, 10, 25, 18, 4, 0)) See the Django documentation for more information on how to interact with multiple databases. Tracking User in a Separate Database ------------------------------------ When using ``django-simple-history`` in app with multiple database, you may run into an issue where you want to track the history on a table that lives in a separate database to your user model. Since Django does not support cross-database relations, you will have to manually track the ``history_user`` using an explicit ID. The full documentation on this feature is in :ref:`Manually Track User Model`. Tracking History Separate from the Base Model --------------------------------------------- You can choose whether or not to track models' history in the same database by setting the flag `use_base_model_db`. .. code-block:: python class MyModel(models.Model): ... history = HistoricalRecords(use_base_model_db=False) If set to `True`, migrations and audit events will be sent to the same database as the base model. If `False`, they will be sent to the place specified by the database router. The default value is `False`. django-simple-history-3.1.1/docs/querying_history.rst000066400000000000000000000174401423103303600230530ustar00rootroot00000000000000Querying History ================ Querying history on a model instance ------------------------------------ The ``HistoricalRecords`` object on a model instance can be used in the same way as a model manager: .. code-block:: pycon >>> from polls.models import Poll, Choice >>> from datetime import datetime >>> poll = Poll.objects.create(question="what's up?", pub_date=datetime.now()) >>> >>> poll.history.all() [] Whenever a model instance is saved a new historical record is created: .. code-block:: pycon >>> poll.pub_date = datetime(2007, 4, 1, 0, 0) >>> poll.save() >>> poll.history.all() [, ] Querying history on a model class --------------------------------- Historical records for all instances of a model can be queried by using the ``HistoricalRecords`` manager on the model class. For example historical records for all ``Choice`` instances can be queried by using the manager on the ``Choice`` model class: .. code-block:: pycon >>> choice1 = poll.choice_set.create(choice_text='Not Much', votes=0) >>> choice2 = poll.choice_set.create(choice_text='The sky', votes=0) >>> >>> Choice.history >>> Choice.history.all() [, ] Because the history is model, you can also filter it like regularly QuerySets, e.g. ``Choice.history.filter(choice_text='Not Much')`` will work! Getting previous and next historical record ------------------------------------------- If you have a historical record for an instance and would like to retrieve the previous historical record (older) or next historical record (newer), `prev_record` and `next_record` read-only attributes can be used, respectively. .. code-block:: pycon >>> from polls.models import Poll, Choice >>> from datetime import datetime >>> poll = Poll.objects.create(question="what's up?", pub_date=datetime.now()) >>> >>> record = poll.history.first() >>> record.prev_record None >>> record.next_record None >>> poll.question = "what is up?" >>> poll.save() >>> record.next_record If a historical record is the first record, `prev_record` will be `None`. Similarly, if it is the latest record, `next_record` will be `None` Reverting the Model ------------------- ``SimpleHistoryAdmin`` allows users to revert back to an old version of the model through the admin interface. You can also do this programmatically. To do so, you can take any historical object, and save the associated instance. For example, if we want to access the earliest ``HistoricalPoll``, for an instance of ``Poll``, we can do: .. code-block:: pycon >>> poll.history.earliest() And to revert to that ``HistoricalPoll`` instance, we can do: .. code-block:: pycon >>> earliest_poll = poll.history.earliest() >>> earliest_poll.instance.save() This will change the ``poll`` instance to have the data from the ``HistoricalPoll`` object and it will create a new row in the ``HistoricalPoll`` table indicating that a new change has been made. as_of ----- The HistoryManager allows you to query a point in time for the latest historical records or instances. When called on an instance's history manager, the ``as_of`` method will return the instance from the specified point in time, if the instance existed at that time, or raise DoesNotExist. When called on a model's history manager, the ``as_of`` method will return instances from a specific date and time that you specify, returning a queryset that you can use to further filter the result. .. code-block:: pycon >>> t0 = datetime.now() >>> document1 = RankedDocument.objects.create(rank=42) >>> document2 = RankedDocument.objects.create(rank=84) >>> t1 = datetime.now() >>> RankedDocument.history.as_of(t1) , ]> >>> RankedDocument.history.as_of(t1).filter(rank__lte=50) ]> ``as_of`` is a convenience: the following two queries are identical. .. code-block:: pycon RankedDocument.history.as_of(t1) RankedDocument.history.filter(history_date__lte=t1).latest_of_each().as_instances() If you filter by `pk` the behavior depends on whether the queryset is returning instances or historical records. When the queryset is returning instances, `pk` is mapped to the original model's primary key field. When the queryset is returning historical records, `pk` refers to the `history_id` primary key. is_historic and to_historic --------------------------- If you use `as_of` to query history, the resulting instance will have an attribute named `_history` added to it. This property will contain the historical model record that the instance was derived from. Calling is_historic is an easy way to check if an instance was derived from a historic timepoint (even if it is the most recent version). You can use `to_historic` to return the historical model that was used to furnish the instance at hand, if it is actually historic. HistoricForeignKey ------------------ If you have two historic tables linked by foreign key, you can change it to use a HistoricForeignKey so that chasing relations from an `as_of` acquired instance (at a specific timepoint) will honor that timepoint when accessing the related object(s). This works for both forward and reverse relationships. See the `HistoricForeignKeyTest` code and models for an example. most_recent ----------- This method will return the most recent copy of the model available in the model history. .. code-block:: pycon >>> from datetime import datetime >>> poll.history.most_recent() Save without a historical record -------------------------------- If you want to save a model without a historical record, you can use the following: .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) history = HistoricalRecords() def save_without_historical_record(self, *args, **kwargs): self.skip_history_when_saving = True try: ret = self.save(*args, **kwargs) finally: del self.skip_history_when_saving return ret poll = Poll(question='something') poll.save_without_historical_record() Or disable history records for all models by putting following lines in your ``settings.py`` file: .. code-block:: python SIMPLE_HISTORY_ENABLED = False Filtering data using a relationship to the model ------------------------------------------------ To filter changes to the data, a relationship to the history can be established. For example, all data records in which a particular user was involved. .. code-block:: python class Poll(models.Model): question = models.CharField(max_length=200) log = HistoricalRecords(related_name='history') Poll.objects.filter(history__history_user=4) You can also prefetch the objects with this relationship using somthing like this for example to prefetch order by history_date descending: .. code-block:: python Poll.objects.filter(something).prefetch_related(Prefetch('history', queryset=Poll.history.order_by('-history_date'), to_attr='ordered_histories') django-simple-history-3.1.1/docs/quick_start.rst000066400000000000000000000113711423103303600217550ustar00rootroot00000000000000Quick 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 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-3.1.1/docs/screens/000077500000000000000000000000001423103303600203315ustar00rootroot00000000000000django-simple-history-3.1.1/docs/screens/10_revert_disabled.png000066400000000000000000002544711423103303600245120ustar00rootroot00000000000000PNG  IHDR˻ MiCCPICC ProfileHWXS[RIhH "B UJ AŎ,ZPuEWE] kE]bAee]\ņʛWwo9s?%sW×P}4@ o(dheUMH!8SCX @dC Cl$B,Sl .QL R$s!˳mzV ހ]*H#C(GB<,? C;O?839AE-PBǟK~rl4<2^3ۍ)Q*L[ !o%B=(ULأ 0!vC 68\gfIyNsa Z%rsr_ enGC, *'@LJc օHlܘ2^l4"DÏg|@XX‹qb>v_ č")'iG=PkIbd!ڹ=8=NE6) spAjhYA\&N<#?&N^P–  inꆿ4# V30#E="PHBԣ"PRg(+ճޒ#|]c̓M55Zr7`I ##Dg h g~'<&:7'K_2t@pmƙ_f;@N/<gf p jڸUM|QsŝRP)N_udQUhb*wpk/,}זBl/v ;bM!\Ckh[:\#_SUI{{(MW폀;E6C.8pxRa,wwT6> ̳u ."{gc'pz@)/pՃw=FK` `FkAbA"H`p=40 ,ZlN4UpN נABG)b#F0$GR $"Jd)G*F9C M>҅G1:e4 MD'T-AUh-mDЫh 11k cc\,Kò096+*ZkeDp7#$\O6?_='`Np%xl4B)pMD"It$1CI\L\GE$H$S+)K H5#KN[يA'br%y;0 OQb)B RfJ G5:R|jzzRGGFWgDgNn:u i.4.--mݤ`z^G?NGխmԽ\gћWWW^>EAן_@~`AAbg   % >d` [!`,`lfdtxF9FF;ڌz G'O76>dĘL3y~ѐEC\dIȤdU,0\Mwp3qf֛4j4`h=Co.377ﵰY8nmɴ ̱\iyزˊah%Ziu1cUNzͭ#۬lmlmvܵڲmlWڶYٍeWow˞b϶ۯ?e!{&&<"z;Nt NNWl\u]P/KWUε}a0ahnBzÙão|݈G=}푆#nj,2oGOg\f\GFuË5{V>r.; l#v{1/7wAw~~~{wthͣ6t3 = o ~qpvpCqgsbemaaIakۄgׇDxĚ8I\ygx=c|s"6AK]*+uLjj~_mˈˤˮ-Z ኱+WV|j3*7Vj^cfٚkkVT1YTfpݥ6Xl(Gɏ76FlluDTͧ~bTlK[[;o;QSW|z^Yߵ#}ŝ;6b* v+wsDi۰~_~FqFcO9--փZzzp#GeGe{:ǯwdӿzԑ;s,l9smm|.4_>K.^ ʹ1Wۯ%]q=z ፧7nUx;;ewV3W:;Aƒ=R|Q S`3u枧Ds7U#.o`:s6yzb0@==VY.Gyߺa78:UsT  ~ T&y+edeXIfMM*>F(iNxASCIIScreenshotfG pHYs%%IR$iTXtXML:com.adobe.xmp 1722 Screenshot 670 4viDOTO(OO|Q@IDATx/]!C\ B!QąIww]vwOll˞}̴TWWUݳ{o^w;pHFX:BҸfIYi[l/>5LQv'ڕFb{^Haft'-Ϟ2w6ٶ+-jC?pPFMOm۵7Պʢrۻa^d)c?Z/#%y)3&(wΥlD9v5xlܶVXڲpUfgT3s$oȟO~JteҼ%;9Ezo{ccP{E1J/*-{cƖߥVkH%t +d$ٽ7x^G񢅥uZ/C=ZA6m)3[WerK]rzאު ٴ-cf5ٻzܤvU)X L,tY/Y_ӷ;.>|J6nM?*)) jTReO>-2qٷ?- ޛڎ{MA˶}$iVrKW%lNā:kV*+%j]~S4XV75+aT72Cz]wjJA׸k^Ym5'iVYt@X kwpaL)R&lϗXizn{83 (_4U59._1E&ꏂ>j;Vo/% +Qt[VHS$Q-ۘ 5M[QH=$Wu~-.5S*Ɇ]R ȑLMko_VuX\tV&uIT&ٶ ,Z S5 fUeą.(_N,V!]ƚmeR@.&K7VXۡm(c(OCfZ@i8kZ~9>fXv>, Hymg~[vɼO)av)`5U[ a2X/?߉wI{\;!ӧO֌3~\6Y7VnkC,`O}<3hw϶M Oo}?X>kkW71qҫKKW>Ui)WBy+-<6;#^vyzoTd=ޘ'~WVz2_ޘtrzr5!\Cx\?g@ФsY.}5S]hZdzG? ^`>y㍐{|؍UU z [}o>ӏn7훏~- O#ex+tYNһg#e,GO! goCk.NI<_6 '?\O`™m~/{9QPB=o 3%їʥjԹҰeךּo+eDnvM>A.Xn<[5ͮ_^7Y!M|͖!47ٺswg58jW<|EA\}ӉrSt{F2i_˔eϷx>3KW1BWNluR$᠙xJAWa -!!v#FIp1ESWNfvJr.jvIN..ߨ vڦ@FrF/K2! , rf#m~ T@pi'He[Vx&F8C'--,?L-&'t|fi_g0vEZ Nˌb!Vd#UzFLs<={3z,E;چqc@EHEExT]{$8]Gν'+n[BqM4[恟2c*#팅ڛ&')IHHHH 7~a+KF7収ԗ?fұiy*d ]mjUGs7SoKd5s~mHE!STY כ͗*x2)%j/~&>B:e>JK/~R q vn閹23KOr?.oGy+Ոe/{vRJڼIB*4!$3BW*][K)2ˤNrS盛ֈBOc,c ^P|Cw9:^H?zP{u{c{qvm/2ٱk|&ES;4S6 x}㵃cB.,\r52oy_b`v`Ysr*rOfzڲ|{N K=[7QuٮzV'̔Wx}e}M}RRՈ m<4|7uk˲i/X˲=6J kTV煏 |5*n^c6< 7{#V-'o2.VpyQ9rWsm߱L+JЉu7hLQOkф.{mk{8x4+hǞfҾzT*6 H?gZ&{RgO!#(r@R…]W@C %x4"3+j۝o ?z[mz+ڛW*/ͫƮ*vf#;~D._&*ceS_IB:W ,I)yִYgZŘ`Q=<d OӃ&dXv>| /,+ *:t(h`vzBUѱT>UZ5s)?zy{߶zkfŘH;5gɊ H#    FNX?3D C4T^^^D'lA׹/VsПx}sw٬oz~NJ?'ʈ]3U=*] HW>>J'5ӇӻG-S`bҠ6fv' ka W~=W. ZTci~Z=Q BsaU䭤~fD¡GT]KϐC!aFۯ4-wNnGSPvgJ3фQ:ex̽zo$K'n_rF{$pg&rE[ߏ<C.]FyT3LwPܻ_׶gӰ/{sJ~x\kU<<ʓ`v7< <oOkŲ ! B<̬ ] nx潀hyFTyi=b{|H B'oXöV4}䍠C97Ax}Mh:Oi7{<=⾹἞r|;rr9i?? W? B y).XqϻYp^r]ej~zo"t AoNB _22N.*#W;o16 ]Qz6;6[$C5\i1ф.x[ QͿK &]LCL:0?hX}v߿ '*tlWb}z%S3츿BـkF'8F$@$@$@$@F QWs|/\Ze[7ZѺ|"·*3v?Tj?Π.fФӷ]ZKЇȉg ]kdROsRmӳ=Ψ0yɬժ#לp&xxܨ|>ZGɃWkNp|=8^yYvX_v_.>O1v     ޻L;[L8Ƀ};<)/W<6JCcy/BG+]戲K 2^#^ 6w2so#> r}lz0$73 #<`p[^N 9`Oi(J1^nm䙏2pAvEC2?뒓;I5ݼPADбrx!'!"6<]׫ZUCvu~z?+}z@6*ڼu9{ MnȔˌ9uxnݙog-֍-$* ]w3YI u ]; Y<wǠ_}؆IǦ{_ Q!GSs`GFM+bW)tEW6񀼠Q ?h-ЅR C=Bô:7޽V10Cs=SzQĝwŒES)wX|2a޵n "B,TjVmy_p3DŶQ6&șxMG5KH|YL>0Ԅ'H ʸ33b=gB3ReɊ1륑 @n$0wV*a+Nf2|7jګWнІL\^QgPY+ICr^ |iB`s,BxXO!ϿU溧.eyq܍Tu[G9^.DHCL$=%Z]}]ў`Z͍~:Ѕ>{9VlN`}`o תHίƹ5C{l.3nȍ iQAћoA>UVK[#X$Ijqtֻ! 0U,]BhN/|^xH/Zߠ ݸuG: ^U06ثW< Ԃ'oU S't]B*tښ͵i/ֱcVTPUAJ&]qB'.X1L= Zc,_V4T֞^jODt[fZʙ_7?~f=Ҟ1u)KV !-}v8ڦx :C=Wi{^mӮhFĆS3rn ]~$F+ r*t! ,+J_#5R,BO*lnxBC(<{MW(m ]>yfЅfe gv|Ӱ{j=&}>>-,OLo7#L'O\1&t]^L!,aM$+_iՠz637;>]~Z_,BWgPh￲z6ޜ#YLؼ'4B`v_,}bk_i=޷:Axsͱ" A,g5<zGTfAD}Y,ca./YsA;vp< TzgеʹSu!tbcW:k+)^p@$2sT.[J޸*=[msn=w2~'Zk4&Vg^!_Ohӥy}3zmp7p\t˷9]W}Psv>5#Ԟȹ)t3L+ ;䧺RBH] r}RZC!'S1 IkxoMdժUr+t􋆻XEHB5ݶ }=gDyQ=n2c&Y-tuon622yi8>(]o%HB o Ķu*NՐV)L#ʻ˙S8>vF]Wl\/HHHHr# AzP!L^MKUA9أZI%#`U_RqPGteI;f~ٹ+_l9ҢUݢfexNYC2BADŶ00L* Aldi/X$n5hV%.G%c"N9n>qwes=#B[?,0*Pˌes͟&1QyTihA ď3 9jZ˥Q {נF%-ӯWwyH Z \!θP~Zov ]?߻df}?z]RrMnzox1]zox/ ,|z !x˟j<0=01&cLBt)}O+=E- l˄~?<(n@e f|K߄ ]y}k- Ibρox!_ ϼÉ >A ԰h瓇•k.;p:ЅЋ0~󖭱2QFM/|#~c*XPc#`dA PVȲr5m\@.}5W|e􁷾ұ=qPVB޼j}\s-d7[KN_ߴ.p8MWnȹ׫9-/O~# Zxvi 32s,] ]Q:^а|ф.}vO$-RsTU*lmᖼ/O6ΓB9₰{,uz괨ʲ׹hZ =i8x,+vo[uĿXPJHBMZ)o ܎o[{ry^ЍW{1 H9Y9X_v_7HHHH<\Xz}cG FIea 3хvܮt~#,,3B*,شo$_O 4h\-Fl&l@:LÐ^1!ݗҶדU0 yp>7uVm8BwtIx mH&]b!]۲q@M@(%G4aBhGx:֏ w叼!vn:$&x!֕{^L%/{2<Ԟ"#27fVN9ݮLM~py3roN|#w\iS\؍p菰wh4PsAvM`DNU2hϱpK)tEM[k*A\,d%_.w ]HBMZK%ԛYd#TAcv`&y,V){&+k;#ˮ+^6F" G{J2w3Q&df6Ǒ+.:7`Bm 3heF'jMفJ5dw:#_0(s n;vym 3BFrzdՐjzYJK0! ^r-6tK(0RہPm:o- !ͅ00짳"rzԜ߽!#M>7T]{ޜ,˓C1sW,7{U:j޵zt-6i 4p|&w]k\xp(B&Һ+PV*G6WĮVd0 H9Y=p3캿ak 4    'PFsswOWhskrF'ct0gC\ĈIA,7uC^LCްz?/4sXjq܆K)Q'At k6T?-F?/7"5\vamxL3v\TٱjCz Uqvu$[66n+ >ikxs'mJ 5,rtCw>I,*niηFfYե\۫YF_jk<s#M (Q4A>6W _6뤨NHKŊ%!'k*7_p1 ԛbCs̔\_\OF ylwzo,#~Ta=/=8JY8b:> 1g:O{q6weyưg֕ڧF4m,LJ[.9B!xm@n'F /L#!}Ky?nh,./@w(Br R!/ w*F xlg2ڡI]+n޵'Y~3M5>]7Ez#4:c zaBAxsb; ^Zܴ}\:VQ鋖;:gFhfzZk~8_.:B]BדD~DN9n\ٛ\ET g  4leʉip؇]TX^@;nȟmD_[/55%O>=ɽ<.z@IDAT^v|݂|ZTG>?Ɏ1k;# ]츿a6P &.ﶪNZ:y>  i(t9=V.EcmyrIjP1E?Njaw_yzK~[m|S7D.X J]ʉ Vт?N+&+Nl*ao>ys 'tPg$ Ax@}jC.h=+_PbQ 7KZ^OB^Ǟ;4Ox5U!$Yo0`ִj}M7Bێ7[6{&;T&te0,. -rϡ_4kZ?F31,|йVSu3eF4ԮW24\1Ê4ﬞ>KfmB2iia#WoFy:5? OnSv񼴰tCZ^ݴZVy4_e_PWe_z(RN)k ]tar 0]לvk~s8Xx}CC(\];敗(-Wu*JzײcbU!K igNH9="Ѕ##v*S,K,&M-t-Yמa/@dl R F+5UCb͵}ƈ c5g\+jpxS3 דWl(th$@$@$@$@$@$P:<}FpYr ]G(t9Y~f{ʵ|['Ȳuw>٤:\GH+ Z2z4 Gl&t_JYҶ&,˦ //ߗ5_ mϯHXC0 i s^s۸;9,P@>Ӑg{DLկAgL댢ݞ붡 v!U]`j&xc FZ 1߭.[=\VOXVf*Z$hbkW$?f-{R2F\|4'΍r;"?<)m]s4j\^'1K;ce/X'h#HHHHHHHHHcޣ+;A"t#SE?xH-Ѱl;U49 `)kN8h\^B>$EJ9 55da~y٩lYm7T_ YA:۬׆dv,RjsHy2kY9,KO$@$@$@$@$@$@$@$@$@EBW&vW_322yYLvt[{r )n0[G$@$@$@$@$@$@$@$@$@$@$pЕ.F?=SY 9\&N-֭bώze={N|mZ)["Y) 1KBW&vۧLLTwXEG E4 oyɀrVPk[y2           #@ˏJmsZrIm$]^%9p0y:vCo-bb2mlѴt_z):ׯXؖ7oܠtWY0beɤ[Zպ4Q޴gйoW]gw0f OZhi[_T+k&_yJ{|1 -&цjoK8sө_.o;un;fjdmƟ߶p83GӚҪV#ܧ$߳W wѴ \؏r( \+B׉*tl]2}ٺh|S$@$@$@$@$@$@$@1Х協S- kkVf?3N ۠bBrU,YlB:HxF5* IjeMʚ-; ݺ?DULhZ0YZ,`6eV*Qe1V̤/[9vbU.d ȅY57>#&vK+dB!!tEj/΁AB_ClXYFpc݊NLs&caƟ6#[.+~E4oLaޏWt;l3b J5; t X[=q[\=Yy^q $sʖ(xlToҗxs 9|fx1iD&3V2Qv4 ]fEb-9x.y057)04,.g~[6ZgL5*Q'1l:}>u? m ޹7=Y0i}.}7rAي85c<6B= v˸\k[J .]^#.IHHHHHH!@KiEV 90y&s HpGs#`(eA):d^ sLȺ/Kyr0 !ȵkح%Qݗex@-{Ѕo Utx NV$ոjY k7w&rQVb5L}dgY-t jg oj?XOž12ľH\UrPYG`^}Bo l ,7 eG+Fc)k]0/>9gyP>;?^p"ڃbV`^ p{!(Atצ`f76=8/ Mj]23#Ѕݚk^޼BCig8\%      HGB" 't5QaS qx9|j ]|ӗurZʥ &P1Qe' `7կTFJޮ^.yk`G OhN2,&ht^D̷';WЖdG[Dm gLpJ+yax~psWr^/<7@T&F~;Tn ٟ 牾7Lз~:z,Yghڛ t;)/#\k_N :^>^2Pt<ϐO̮T^adDsMqb9ux-BVͅz1a\>xO=p+Xenωo:9 -gDH_g&GW{+hew:^sa2zC'{B;:T55/U!rŋJ4w'yLlUR9:>F'/UBP+'l>틓6g5B>bNhZS*tAmUzz/xλ,s.s1\%      HG ]P?ٷb纞->*|[ 3[וFUʘ ]A)7ݭmTGkX8w / B06v Q΍M LQ~PZ B)+df"!o CauM[!x{OƁڟ~y0z\"-\`2@Ψ)QX'{hx/x~SIݝl0:i7xpinjX?QH fg;wǚuBB};q`i'kn%_kY.GיdlyS^ߧzy㵁kȟL//pn/[ׯUf/Ziy93g&|>l_.Ľ aöeqo_^&;ԯjBdys׻5nd=k2XvZ/xx9~q;[_D@S+΅n\ۗs1T]*ԚFFȈЕvx1̪>o+twuz cޫb#4Yz\rfݿff%##`Զ' ;N=6iWx8ނʖ0ZU<0\l͛Ё!FxT1 e][_뇍_*ҎI肀\3=;L.<.njy稨3 2N:* @:FJwa6X 09'4x"D+t! o.[IāA x`Ju){&o &0=oАsխ$'5 x߄@y hB E@OB='9D# :N# e>qcln :=# 2X. ,sP^ NpX[qŶ9ڷX]i!5 6_ $t|Zzp:%(Kb/zu}1Z'-GN(}L6LHkvXCxYx |67 Y"^F8ޏ߶xҶF̖֩E24.[+"+t{!7 BaB˱>FBL.NBݢB,Td6D*34";<hEXhleEn/nXYxg/m5JEQꭖF'Cmmcw#<^]x36ϡ:gUnL;Ws=vlwTC&"l}mk g]eu bxl[ĵ{dVc\~dt|W:R.^7uvMB!'5:W] 't/#ڞ-vsirw1䂋g7a!I=&W ?VF;CmO_a$=j΢pV+[S+/M=1 / t]&<Yk]HgnN+}oc{+(#.Co ,6z4crtAȱT8y+Xy oXlF`0\; 7o7m~י ]{S=*Q/>*oB(""l[\iYv?ګ"NǹR2  ]JOF)lRa`6a$x.{~ԏV 5_9)5'QZĜPp~f}s!k.3B2BT٭a10;ɣ+SZԖ6 fD?HFBESP`ᄮ:!G'aBcޠ;1 lo9=n=wkzDxYp爥/c?m\- 6WrN_Z$^wO4ϠUANy G5X=0auӆBpZAo ]nX44ehn0!3ژȱ\k&q`(w7_0LB3fg>$.,Q$ JQ ((Q>Ek^ LP H J%`=3ݳyvzOUSSXl9Һ z3sѽDp\v}XHY. ]7?VgHʴv˰Ϲ,nw5a}USV=,|&ɐ5aӶzXY{IΥ1m1|vo=msnµ u^AWLl+2 _}0 xQ|.>|_we_oе|y2c n]Gw9hMSa^FEW @ @@/AW}Flq5唋5Wxn7p%JkoI<ފ'm~inqٵf}}b'n]p ݋zzOhMvVKܦ0͜Wjf?6Cr&|GgtngTT̾wT^}+þ-7,Rrh3^ZaqX/c Wt= &lNޡ3ZNt]X*޲pRzι_5u$Oȕky1֦O9_k}/%d3]icv5}H{.muV/o~eж ]Q~f&P~a, eo@]y%:` ՚ϫ|nqwЕ;FZb"r}q]=ɨs4]9mm~7bsxG3 s2geyjS]5W|>a5gaº%5t]sm;ٺ#~ \0×]L3x, g8TC*2(h @ @hFWЕ05M0 f>^lҩ"Ȱ'5C!7'#ۉsY7M)RiC]<_-ޮUzBY3`TO1FYg60C6v߰w&i x G~i5}%Cw859|UkifhzZz :젫;P5kfn qVe 㘖ɍ܄ 2\*f8}Z_Yq;fޙ.f [fةng$vZ(7nnB6woY@\ tFپCe=yxr/՜ÍcT^GuB3y%ztSv_Saf\7iy \Y?zzm5\;x?,H?NmF>g4nvys|^:^AEtM\OvЕlD{t= V^0!Ysd(~uQ=:|g|O^|ͼ2R=7X C3 ]r5(Om4ӵV}zЄ[NX]s{u`AWva3nڝpș+ݟfv7HЕ7]G'_ ! ?JHr~'@ @ L頫*"ï=ի=y F?\Qneur,-7SQKJ%M0*n|zvX uخՔ3X*jڶ_g7S*4>wX݂LDJȑm_^_Tf浵]>>jhuQ3Xn"WWB!mPuNdHg;$^~f77r-g_>װ{5rM֭9ʦv:&ɼB_WKUKuns]YW8Ruu\VlBζeӛ!8S6ZkjiSi~j36qMU͵tWe?/z#v_oBThu6_oZ(1>Cmu5z5y>9gql\vQkY6H;RЙf.+y_-5u 5{>\c3뚫0Wy3ti>sOؼ]>u1s9jSQ0-'4y;lMn/](h 8{k2Q| ozn}Y tc"g4UnwMEbS8OKemuMY2jB;iFr}3WwЄWڥos)[lT?oS限l,mGn\_bnkK7mk_j=(w~#Uh+ @ @S_`J]yrffr&Zh7GL nshNs6#Xѫ&\nb%[ح;N395N>'ѶinLUh.,_=0kn\9-j+._Ƣz hr}z @`^f _&HNekwK@ Kc\kYa.Yzm)A`v \vT}X% @ @% ,IYE /H` H T4wW;DPD Հm9$@ @A@5/ sSO C=0^4!̦#]Q3_՚7s3NA8yH @kPV?AW="@@? ~*/[!,zʅ.  @ @ O`)[mvnX$- 0^]ڸ嚛V.}S{ǻ  @ @O @ @ @tv @ @ @]}~t @ @ @ @ @ @}. { @ @ @]],%@ @ @sAW #@ @ @- b) @ @ @@  @ @ @@oAWoK  @ @ @\@'H @ @ @z zXJ @ @ >?AG @ @ [@R @ @ @>t = @ @ @K 5S @ @ @_AW=#@ @ @E@5  @ @ @W@տF @ @ @Ft) @ @ @t3 @ @ @Q]x @ @ @]{n @ @ @`A(8"@ @ @_AW=#@ @ @E@5  @ @ @W@տF @ @ @Ft) @ @ @t3 @ @ @Q]x @ @ @]{n @ @ @`A(8"@ @ @_AW=#@ @ @E@5  @ @ @W@տF @ @ @Ft) @ @ @t3 @ @ @Q]x @ @ @]{n @ @ @`A(8"@ @ @_AW=#@ @ @E@5  @ @ @ }e/K[^,e/J6mZYwu_uV`.A\~!@ @ @*0k֬2srUWuM@`7.ӧO/mڛut @ @ @`Ru I߹( ۮVyk ; @ @,iV.مmP`-,3fXL]S<:  @ @ Pnr'oqM`*;sYmƶ]K5EO"@ @ @%pO,wyd~&A`V*kI赤6Aגz7 @ @@*;8!* s%Kе8:I @ @D? \k}^.  @ @ @8%\2DZ$[nYf̘$5_)@IDAT\(蚋c_=,28/~{Yy啧T-)x]O @ @={v9ށM#}?pCAG]fΜY]wWz#e|q[C-.ly{S\sQ0xO]E袋ʭZRK-UVYef]v٥l&w`x r-3,g}vc=o?bvE @ @ '?)_r=sHe?)r}W/7|sʴ^ޏ?Qz{9ӦM+/|_Zn]gVSؓW\q\OK:&;]={oVﳷ7x\lO{s)^{mxB?{BFS6J~e9WZ:묲r˕O|㵯}mfmƼ~_/{\o^}aK^ZA_vg<1Ïg\sO9J7ap>Ӟ痝vک> @ @KYJ.h‘s ӞjSO'z]uQﮏn= Qr)/ vyy^ׂo~;On[uo馒蹷c^>6kr 2YwK{)qh6жє 7sϲ^{B}[Z/'< vW\|o嗿eeg<a{XyCRḞ'\`Yio|cT;o>ϡ 5pUWs}󁜃ts- @ @ x%ad]TJRx?+?LؕR!G>V[<w7_'#o<` 6%JM[*Ri:.-1S'oH=ky.Dme]w] S68CJRTsk,-u*7 p, :|z.<~ ?I9Ծ[ JGᱏ}lɇ׾!,3 @ @&G T'< 40q, /S1cFr-kuG}.?Sd-Ks4ӟjG*RLNsnزV[mUl]}9yNYuUkX!. }/XlIhS:C}LsL}s+_|qYc5ʇ?zN !{Zt;A`Zkb|*te?5z;CPk*]Ȏ @ @ an+ X.LV¢Lc=W,sdu] ao{\I`Hǖ/y] 6tӲ.eE-O|ˣ}ߝߝ ݘ!6aԧ=y\ e}鬟 ;P y2FH-C&1ɤx)mdM 2]J&zZJ$/Z]jٽn^w]]x,)e]V>Om^<)ݻ8I>\,cڶqM0:RKfJC3h–x#ǖ 5=|;i9m??8h9w6m@Y~7g?_WfY/.hLenXiY.蚈m @ @L]N;ǝ#) j=֠+V/}K=ܫo~Sr|~m~AW;| oyߛ96ұZ%#V?YϪyo: b^f9lϜJ%M+'#|Q:餓JRӴT8%Xh[@Pi_Wŝ3f^o\TIpӏR ?]<O`*@逸G]'K}S3|po~sӟ{׿|9_gLı>J7j8..nvoy[}c~L8k6|_ _!?s d [s,ˁ ;%qf÷wAh:#@ @ **sNMFkà%$(2_Zzi!3bZK㎝XK/E%u/z3Q 2[o Ru=ζJa [MQDK˽ sr=$ZŔ+$$T}#)f*)!|6j 9@*r'r.LPVfW^k >O >}d ԝ4d~O~&o3_*ʲvȾ^ _vqǕc=V%jd]qLg8LKb | ^{ϮdRq[lE]/rw )J!gv 3LG5f7߼:%iFsΜ9yNuK?mЕy~Z|X&KK^*& &C> @ @LT- &rm4mte [o]Ξ=_-&]{gyW%><ϭSSRĐ,#vuW$Ch[;adLdC>-L+aӻ6<-|+{Zf}TetThP̲\?[=ZΚ`66Aׂ ڞ @ @}T`MFkàL?R?kn2#}Tmx_mAD>7|_شǖ_2ZZ7{y,E&'˺2$+^ESrra5IS9J O| sḆ(b&wkSݹ6n~i#C{*hn5$O,;V\q жT%I/aXZl?d-0-AKL[РkAa GP;saVi 2Wc>F Soj`\v]/yKʓR6+,^w]c @ @%CCmà:\p txFJQM7O¥d^hmP{6lS IYmw:::,q r~xk=°OWUt["ʨm[BUG *F 0*G?ўsh*eN׼I)CnYֶvxLZjl;("f& 7|k_[ùC~{w[X8/ω],ǟs__uĹ^뗌ӚP.-7S=9_bzO5chu!Բڤq%PSǜ϶AH`?:>S]>џَ @ @U5#uk]R t2lImZ/Hl_\\s̩3 .[f̘QH̾~mm5,CRPl"2G\SN;|_R\m˅_㙪uF =R]Ϥ0Cfz0_/kyd׿.7ͤt|;%cf_mKUUg=kAS/NOX[mUIY骫ţ]0`'{`x-s2+{AW/] @ @XP^UMd]yLyږ )*=lx_Еs9ۑr>X2Xn#[߿f )(u]/;e/C%y[RD5-[֒aF *F Re/}K;|K_c>//{ Ga +o7׻uW}e_=hKS>~uTo &GSO{{Ld5Vv8D/zыkt`z^* @ @,Fjj2Z–.#sw,ӧOԗLr*{o+)p|\) iV|as}xk\ JFjY/מ{yL?gю-3\^#jwg<޳,v^ l]9ay4?a-LڶAd8}[VgΜY?ڊ 7 桲 @ @ (T dڢvD=| r$P_ZP0qF $IP&Ru 2]¤rgH)\d{ykʶnۮ6K4sZun[yګN׾ر[;]->sk_k/L9k2Wwh馛mݻqb{^ l @ @-2*6xoq-OKT?yYc5{QS8fVQq+~_۽ocnQh,AWv.3f1M6-\j}p/̜9sju{Vu%OUYZ*ͺyLeY*bO}j}}SNkASQ!R{nD|uڠ+iLFJ?k?iI[Z26m] @ @,nYf{鋻^)hƿn])!MZ*{Rvە6Og#c}+j𓪮 :e2o|~vYfeZ//';}#>/Yo`-DگڧlR8_~fnT=IOgwnE S" cIjKd] [8<~rdT4%lɐi Zk'XZ|f$H;r9ԧ2#^^q:\o, PeH³:z¢UR;eXLs mK)s-!P°^AW֙c}cn/.. zm2lm:?# CO ]կ1ӹr OKq'U&D!@ @ @2L Ȕ; vu:RumuGеȨ2^6xws @ @ @Rٕc c8&R+y睗JXJ9&΁>d.1 @ @,N;\r%KAneƌ}֫Aq @ @ @``fϞ]=e`a;ۮgABaS @ @ 0f͚UfΜY22Fm^d@, @ @ @_*Txr-o/YvKK/tY~AӦM+JVi]],%@ @ @sAW #@ @ @- b) @ @ @@  @ @ @@oAWoK  @ @ @\@'H @ @ @z zXJ @ @ >?AG @ @ [@R @ @ @>t = @ @ @. @ @ @O @ @ @tv @ @ @]}~t @ @ @ @ @ @}. { @ @ @]],%@ @ @sAW #@ @ @- b) @ @ @@  @ @ @@oAWoK  @ @ @\@'H @ @ @z zXJ @ @ >?AG @ @ [@R @ @ @>t = @ @ @. @ @ @O @ @ @tv @ @ @]}~t @ @ @ @ @ @}. { @ @ @]],%@ @ @sAW #@ @ @- b) @ @ @@  @ @ @@oAWoK  @ @ @\@'H @ @ @z zXJ @ @ >?AG @ @ [@R @ @ @>t = @ @ @. @ @ @O @ @ @tv @ @ @]}~t @ @ @ @ @ @}. { @ @ @]],%@ @ @sAW #@ @ @- b) @ @ @@  @ @ @@oAWoK  @ @ @\@'H @ @ @z zXJ @ @ >?AG @ @ [@R @ @ @>t = @ @ @. @ @ @O @ @ @tv @ @ @]}~t @ @ @ @ @ @}. {,zrM7n̙3w}^^`e)+bYmʚkYZj?&@ @M@ogDX{o+jX;  @`J $tM˲.; @ @- Zg 79sf;iӦ +MuG஻*rK/HJ+ӧS @ 08IHW_][n[^zѼW!@)-pK/s=e 7,k֔>^G @kQj{-Z 7!3'&lRV_} @` z+sv)4 @ @`r]h/Ls=dmV58$?[vS @ kO 0LnT3. @ 07GT =qM=3 'J7  @ @`]ut)Եo= @ @/ |S{$@`@܀3rt @J@5PKg Xn@.L]&@3 @ 07GT =qM=3 'J7  @ @`]ut) 7 /̚5呏|dyWV]uՅIgQ׽u o,w{]6` &/# @ ?9zBb ȃ>|3 N<IJ;\pg.<-mY+~|g|p}N8q/׿u1cƢ"@BB錝 @ @)" "'a /~C.ly\'n|[ߪ!׺[~UEҫ^rap oxCN] jEc-x+J*[cY`"3|N @k ʗ_~y9Su]W[nvۭcغ?Vkʻr7T 3묳>q%/~_K_۟r->%g~g?r1ʴS2|`aڵGv׼dXļQGU~_>ٳf~nm ],#@` =3 @ @` FPi+rHlu@9yN ҇K.ބ뮻0k[`7 w}ӟP{;o{gk*!U._/O|b .jo[ ^zv-R}/gxlP*AjVf͚U~}{s\9CKf Zkd~U >O7Mu ˾o[ne>ա,V=Oǔ:SH}eh 8&~ q' @XML? +Sk*+l ?o<'?y豇ų&ӷ:cNs=Cz\/mCM`R9眡79CХ^:&MxT 'Po¡yyPk_ס 6ؠ.k;j겇u^&X˚9 : X3,˃<.oϿ<фiC~ӧO{;S]քlCMU5UmCLҷrnׄx̺[~ @#]#Č7M}2* 2 _’aӲn*RMu'e/x 38Coe7߼Vi\xeڴie-UWVm_}9Ksq*6E/zQyW%CtMuTtdքucKO0я~TW_35׾qs3~䉑UVUrk7cmY~:a[ѕφuSEc=:+xоvf̘c  0X#[ @ ]#?O/MS};N{~d>V2_Mn o}qiˍViMD ={? 2tbj*_U:s=C%l|ZSQRΝf}nz_vemvugZL [~3Z{}e73ԣFs2nJ}f^+!RO=92`SIVOέ[*Lоn ]|Hς {}w;KSV){׻Uڠ330fޱ5&| 1' @Xg/Kɼ:i{gݫpJTt7ܜ~_~Z9#9Hˍ}ݷV?RE`)Zn7QR*f8Zy+z^W:묺Te/ dG@{1_V9eyʜ^rJ0{= Oa HϔةL=RK2,St>Do&%UG}t'kЕd,ɐ+Uƃ>ܗ Utkx@O&M4 YOֹ~6A-2Ϝ9V76×֊fºm3\7s 5aVgPU͙ u m(*&j'>k_!ҽP =Yjr7Yo>W[tN=Ժִ{|?я5Cw]+ @`&=3Ǧ @ @`q z f8򲗽ViTQd^fZQFlXzVHKZjfú|m-~xiB҄^*#O47ko\in Jѓ~47P%dȼYͅS+8˴i 'P˲kYne]V#$HKaǺ|5(o}..a+i^]MhVDRyg?SVw}*i {Z⊥R]qJfZ)ϒ&̚BlvMV>7pY4v/,akM^?/FX񽚵  @ @! <_~啯|e^-aV3NYk~W^emЕ_2Ynϙ3mYWR)k*ܫ:+C vկ~uYgu A3k8jk 2Yn'kx;.sN\װ@L ȄM%TɰGydy _/ &{f1$@ @}' )I%Ui Sݫ/RqZG?Zn{i>UC IXJ3X],c#*.;˧?-UeijKЕyyږUW]U%Juv܌oFUcAyCjg>yӟ^O ږ8o_Rg.y[ڮ'X7 :֌3uY4=* @ @]c<7 R:rqu|SRCvA̺ e/O Fjkȕۀ\sMYiꐆ,U` *ßw}w'lD` @3KIw @ ]$>!RЇ>TӚlF@''B7 0E|L @ @` s-zЃ[o{)"d @=35ϫ"@ @+ Z^>pN @` d$IDAT'! @ @bt-Sr&SS< @k{uH Ñ mݶοG]p￿e*m݀ @ @tϹ^Z̙S6d/xyJzk+ˊ+Xbth @,VAb7Xr՛K/t?uO_ @`@R͕?{ʆnXZk=&@ @' sG,& [8srwe]L6 +,yYdr-{キJeuA>.}'@ @$ ꧳/,v܈+3:@SF Cn)A9 @ >8 @@ 릛n*v[  @@,2uNV[*$ @ kΘ @ @ @TA  @ @ @` ]yt @ @ @@ @ @ @HA@6&@ @ @t @ @ @R@5M  @ @ @] @ @ @t ii @ @ @Ak @ @ @` ]yt @ @ @@ @ @ @HA@6&@ @ @t @ @ @R@5M  @ @ @] @ @ @t ii @ @ @Ak @ @ @` lu뭷 i @ @ @ ^5I'  @ @ @ctʚ @ @ @Z^ @ @ @` )#@ @ @,AdI @ @ @"t-Rn/F @ @ 0Yɒ @ @ @E* Z^ @ @ @`]%i? @ @ @T@еH @ @ @d &K~ @ @ @kr{1 @ @ @tM @ @ @,RA"b @ @ @% ,I!@ @ @XE @ @ @&K@5YC @ @ H]ۋ @ @ @Lk$ @ @ @` )#@ @ @,AdI @ @ @"t-Rn/F @ @ 0Yɒ @ @ @E* Z^ @ @ @`]%i? @ @ @TX@IDATxTU.Mҋ`Aņ*7\߲XKQ삽!)?3 3;IH2<̝[;}r["h eR@l2ʲ5k؆ ʤ']<*Uի[:uaÆV\GE@@@H[r]i{8 P ̙3]U1 ]vVrB@@@@(+]eJs @@@\ӦMl,ԯ_ZjxXvedd ^kԨa:tkw@@@@T t .]jsε*UXNB 6d-Mlʔ)cZFmK@@@@(@U*!PAsrmի|̴Yf9@@@@#@FulŶh"'ѴiSkҤIB=T/h[`-[yְaÂ2Wvsi5kycnرcMٓj4v?~W^49/@@@@B J{YVVVrʹЦu֥KG_|ѝgm]tQSw;p^!CX׮]w%Iw}n_n~zv̘1ve/B!P?A޽'/    @8Nw͙3'P曭 Ź j߭RJqUR4ڼy֯_NG+tbEB (bb",bp   1YХMWn=u=/s=mÆ nO?֭|bA?p@裏ܰڵk>+T@(PLTܗE @@@@R @Е"kvuWmС6n8np|;s$^\.i,]4h`˗O瓒ti.Cb&@Pl8e>B@@@+@,ti>߭5?~<>Ӱ|駟~3f Dsz|v>|6eʔ۷oo͚5|-[j[?؄ ~֩S'Kyb_lܸ4֖-[iӦnhaÆܹsmwC=4V?}n7zva/қ;J`EGa6vXw+0 騖h+Җ-[=ŋ3DFDo9jaeͱ"{VF+@@@HC-QХ@)RX}D˦Og 7-H-5Չ'h]wu0<ˣ!_mW]u[gϞ. ]ZjO^7YjC qaN8?ryk:z_B{ףa,8j0]I&ۭ[ 7k,_ӄ%X@_25.}g:nj+قe+3nb&Z`5oXjרjS,ag'6    P\dAʕ+_~-(2US)ۇBc9ƌBg=@O:[M,S (ki>nV ԂA׫j?:mm۶o'ٳ]%.:꨸4̧SǏok֬qs"Tb5RUjvN:餸j[U?2߶-[nf öT}rP,e:/3/q3ڝsmtuMgFO;v٩wkvs@@@@ \dA6{]^k;=_օ` b4̡,T0aTśKCF *dC9 ӧm5a%6C6mڸU! 4YgK}W_W_^WT 'K {x]pԨQ. \ht7Щ6I+G ;:_ۗ#<^.-{|xJ A|ӐOatnLyih T*Oc[߾}]8S̙c ߦ8 Xa9cez)d]aSN1 ZJ(Ty;`Q_>˲Tt9qlUdαoq |uޱشAC^~[ IkTֱՊ yx.q= @@@HGW-YХKAȪz m4[nR09vuW:^ХLADO?U ĶؠKsNi?j=X馛r-KKCo_惁#FT٥W RU*dä`k*m޼y:R{]n!?J+V=?/ ]4[~SvtA.4Ͼ M0G*V\y7L_^/k٠fMk׺ow7m |2Vf(t *|~+O@@@H )Yе`@o :u*3f+zw.vB*U-YХ AM|S*Qpؠ+X~ ]ѥ>k@yMTIG6_j~>TUC:U]h45.P&$ UG>NIՆM 6ow@wHC jQUaumf:uVj=קj8lԨە }F>;P:tUIa`:tr<{7R!cD-zY,}9}᩷<-6nMz湓 gz5faKm&Mٹ0#.~h6+hХ>~֬~m@@@@ t%Bɂ.Upi&50O Cl}Ԯ];ZcFsE! >s~0] zGɂ^{-Zm=Ni~߇Gɂ.gjvs'yv>#7LW^I)JQ[MT#r eG0:ݼR .t(US5.]999. 7a*ASժUK`]Ujru@KUREs4ӥPJNjTNͱl7|)F8PZ}h_yt,STByZN QEL*}K}_NaeYm㢧fz57Zcr]D*hK뀞;҈ˤ߷YsfkT}1~6tmC@@@H*Q5vX7'ժUܖ?(Ci>) ](҃Wf ĂZ={60^&r~Q߱Aֹ֭Kܐz!sZ3?09^\w̙nm D-LХy=9]q.@МY] Ut⨇jRMDfjCk6?[F ti.; Jtj]5?Uѹl2L=}_5U6)4RX*C ݷW_}=گ7 Sm/vi/M5ֹkgy3+'|.BWqڎ;hOKU塰MAUc֬Y3SPm4(_J=U?2gF^vG!&NoK 7d 4>$Ҏ0Zi2qyp4'UP.ZXCe ?&5 @J&j߾GC5inW*4̙$o]> 8j3oc)P AY/~_lۭ]x_nt ̈kV[>G|7Z6v<;c6j].o@@@H7WLb}_E;z{g46m2c Uu=/{UnJ' CUh=US0e5r`;jժZM 80\NMv{1?bz\TiSO94Tyjذ{ 7DAoE6~x۩bJf p:t]y+8 #S̄ NUi8Jj>\g=RUE+]ɂ.}to!6~L- ]ꫣ!Te9lH#T7z(ﲾi Q4D[A$\y ]ۗ_~7sG$Ktp@7W bMt땑sƑ6ݯ}hf.>l/ݻa` Wu>=>W{Q`|z;,)O4#al;ޑm/A:~ =R9uG檨gB%   &@ЕWk*7ׯ;@4o܅QP]E0[aR^-6pz@Ep]]pam4iv멯_K!WԟhaU!ᬒrVUMu x]*hRz@ߺpF˺TףGA{n](ս3 4V)ȏ ]_{.TjiXP xI'ِ!Co?7DcaÆmw@AtvvQC*|^SLqCޠA\Hid p :v}/]/| =xAH%o?F~dg9W 4W#K\nk͛lw~tK߾?y پ2.էEkߓ~te+@@@@ TJ.24/,3g.{y`Rt}駦jjT(踚3Os驽;nB&CT5wWJKUj 57o><@HUu7~+8:u 4hiHWQHk/+K/]7=󎛗M*'R5|Mn{}۵Ck)fؠK۾/0k-ʶM2A׺Hu֠ln¯~aFB @Ka+ԮTut'Ұj ^vuW7}SeZKsE[)v}g.ԩ^VK(hۖ.]y'js<3tjH޽{9f͚e[v5,p|G+P/s1͇7߸N>t,}1QKowc%C,Oy% <+:ww8kjZ=q)nnKj+Uo<*UW`ЕnX=v.Ҋ~(D_ѥecg̳;#N-۬?Yz6Z*AW>]{GwLZNܜa~&Z7i쌹lu @@@@ _?V >c3>F?A*T!PI@K.qi'_A9zz)Q*{@yMUJs͟?mRBW94׼yM6vUWTM_4jK5PP%!b^z' /ЭG!GUsPӐ ^jʽK/srr\ 6{l6m4=zUZվ{޽{އ6Js(8/>R,)nV۽,m;_٧?Ozd/(oeZcלtեVEªWF~-Xi<<ĪDg=ݪWy#w+ՐeeuOܿoW:x*,C@@@ ۈ5@ xJ/N|ذan^xeWKUi( l< >SefKAWڵMb>x ;v(4h zD|M< yti˖-sTL]I'*>#F-TePm ;v yڶmkR}Pp̙n.0hi۫oK6q~,s_fvSԉ;4|Qݔ]xUrE7¯ں|r{d~XFsuOfjWjhooz gDw뜅vZ<^\t|ty]wlf{uZu}ulT<{{%ofnkd].JF@@@t J+F@D (RP!CÄ.z@MQUT)9)0g&n ʗ/8 !wj[`{ޗJpI*kTJIU-\i "^+QˉTziXƑ%jJl}{$Z-'JÇ    @B4|^(^5l{ rvoO\~rtD]ts3!{*etو?LԺi(Ʊ3n֥+@@@M+ݮE A4 .J@i//l[u3cG흼KSCVV-Ye?4@@@ʨAW6m(Xξܗw<4]v*EU@@@JAWi B !NUH2Ҝ`g z3ֲ_[jרjuW`u5˲֬,vۥ+V:5WEw @@@@ tB@-@~/V{㫱 zmN+Xf*v^zXU@@@@'@U =Bv'    e\>Mcږ-[gϞVBY(M6\r֫E%@@@@ tC@%0e[fmի~-ifͲի[NBoNJ    e[l_ʨҥKmܹVJ*PUForڪRcZF @@@@.@UүC-6megg[ʕ~_jն%֮]kF֡C7|a-X    @nCʌ3g! Is%V@Ck%t @@@@ tKB@Peײe,++^6l(s2/PR%7'W:uaÆTr;@@@@ u@@@@@@t@@@@@@@R J݌-@@@@@@JAW t@@@@@@ u@@@@@@t@@$e[leeeٚ5klÆ %{*UdիW:uXÆ \r#    P@̙3]oF@Wvr|@@@@B(9 6megg~ժUKŢXvedd FkԨa:t.A@@@%@Е7 @Xt͝;תTb:u *] ĿojՊ.駟/ScnvG?b˖-_ܹsOϵs=gžX]~屋K79@@@@b *K@@|WdɒP)W߆ b?5iĮ:QFKJsqĉsN _8 6؈#^>~ԨQַo\%zsw}l3gδmۺ~m͹?SLΞ=^|E<>\k׮͚5+ײ7UVk..5SoJ͉'8H.jYklq*FzNAC@@@]]"@z 4("xfzqs9v}*i:㏷Ç[>}p!߃>股VZ9 *8{+R .{SO^zw^wyVvm4iutlCɓ'*/_nO=idM|ku]~dW_}Յj'x :4*bYfmˉ5VJA mmz^UT1QVqFVZǜh~}3ylȵ}*ov]lǦ r}@@@@ t̏@ tyn= _$a a[T XFi+ڵ.;crUXGC0U[)|J%Ҿ4L/<蠃~袋.Ov!YVfM7z0^ЕhrҥM2ž;c=7 FOiuzm{?ٰ/Î٧6Ui(]vʗ/ꐫؓrf jVKNÜ/    [k{ H+VM;;`^xnܸ z`N:I+uիWϵDsyi&7ԟ֯_oj޼yC- XhgP]>\r*ϟu={_~%nOc=q1ǸCc :v]BUu?Wk}~n:73uYnE:25U)S{MH%zwFSZouK=s.t MkaFH[<+׹TZٚ7z/X֬[f 기iڹ@@@@ uʈȑ#]ȡmӦp ۜnn^j-[4|[y4ӴiLAKÆ ^ݻwW^y-WTtah̘16vX>} gׯ;裭RJn5+27 gԏڹkgy/x'|҅gu=s]2Pl/lOSf'qe ~U6n~*U`/p[{m]S,Ͼ6W|?uUNi)9:n?7    @]):\|M)`QkР Tꥷz-Wiذaѹڷo%=PWRԩ]quع ~ ,yQ]:&Nh2VSp8.;wvb~^2׹R#}.[3vCjCRk|f.Xj'NS|?0UQ=vɑl]efgѐ3/qo?֭ms\>ka"b't8WG8RM{˓.>~쳱SݲO<(A@@@/@ފ5@ FХ9N>d'gKo|^OZ7ih7,G@@@H"@Е@l guM7tT+WDA*6;7X-lܹa};Sm +<;ՠ[nqR^z6s*'|?|;C>z6}tС)Zr_A 7*N9{oiY!}t5^{kXvvv>=퇩߯}d]`wuD.a_lC>ѥ  f/ʰɳu'=-lդw|&e|fQC#&jɂ[_x&Z6íGvr@@@@$]Ipʶ@q]?OW@a7 fϞm->}D/PNN-[̚7o]ꋰi&/?ypNݻ? ZnHt_~]w*T6[~PCj.3ѣG[߾}|] sαnݺ7|Һg}>SoӦM5j8g,;Lеj*We?{pu&^еy|Z3X%A׼vìR gZȜ\k:~=v9`ԲYl-sfn\_~_ >7{.ѥr1@@@ȷAWJ@q]?=Q .bk֬qAƍ]B%K؝w¥эCXݩ /taG"edd4WW,:mnbv!ا~jUT~Y7ߴk׺MB6ͿNp)R(VjU] u'M1U)c_uKMK6  O7;*t?l˲VG"s Mx#C_qKgGgh ξ][t1Sn?~ ]8?ou6]Y7 ]+{@@@@ ]sc+(tWAxMCp vژ1c_ti? x%[6gŋb xg*V'^ƍ§+ 4Ak~yt,Uc_-Y]y啮Maoπ/ 6N:wݯfS~Ϙ1UE?ŻkGu|~޷j͛go}q) 7.hB G.3yi >[훉uvr]MZ;fZ?]񷿲Nu<8̦tmpy֭6{U~~]aW<[jҍgY CmJ    r{ h'U kήgW_mWvo5GS6mlС矻e Nzesgti}j'tP*Ri>|6{wmgWxP*.    @@+K@ hBj UJ)SR)s X_/Euߨkcdnkl7аAה9l~($)2X[ꗂ_US(p    ? P*(upeJ}6%WB+**j=u62#o~a9{pli%Z    !B  PJS`QWI9th̜W9o5vSoٲCF ovnSf0A[S.~ g!5SիU=C9    @$8|%XVZYv#m\zʦ4wؚus}ެAiڹ@@@@ u(e9(N7ؓѓf߻k;}f*g%@@@@]}(3e=(3Of+EmIڜ Ud}b;6m3    L`~lRs)Dorsk58sIZVF]    @A * @);vm<ٳUP=6mdǏ8W^P@@@@]qYX=)Sؚ5km۶V^,ifͲի[NRޞ @@@@(AWA(%K.sZ*U\hAUW)4Tͥp4''Zje5NGb    $ Jl' @аӦMl\կ_VZrd ]222ܿ[5Cn[)    /@UH[3gtCI"Аڵsh!     `@L]˖-,xmذ*UrsrթS6lH%WT    !@U@@@@@@]&d      !@U@@@@@@]&d      !@U@@@@@@]&d      !@U@@@@@@]&d      !@U@@@@@@]&d      !@U@@@@@@]&d      !@U@@@@@@]&d P֯_o6mJ*YŊ97چ ~E`ڵH+W *lsTY}gZ͛7[NNUV-)O<ٴn.]|I-yߖ-[,++˪W^hVP}ƅG   AWqs\([}ǎ\r9|[x5kva\FBhWmҤIn 6֭[o_կ_ڴim%Klٲen:SHеkWRJ7n z7(La #222wAWN E$q @%p'g*8m{}@@@Hwt U]iͭiӦ_K˜9sA;oA T u233m֬Y.-i+ҹXuM\ Zl4*-JaGeggٳnݺ;SDqJ>&Nh, ø$;}B@@@#@Е5AR+ u٪V=߼.Ub$.LХxCF;Ŕ)Sl͚5KU"7vO.9ve[/UbR1Yt͝;U)L]va⾏ V3Q BWJ89u-w ! Ԃ,sHn^t%ڗ_ha{ao@@@@ t+B@XA:RfMСCO.=d^`\ J۵jY6aZhB)m*$RL1BJ%-WZʦO>R@j&OgD-ZȚ4ic@d:?'Q>P{W^t$GO^yDw pT5N;:>XQ4Ĥ/ 鯃֕MuMu^5rA>W kc(\ؠK#,zpZG!Ȍ3lչaUp.9zPBa U!תPnUi}GzP aM~/RK}Lt!} u!Iq\d^>]C< |XAU5j~X`Cmc󁅮B"_eWWǎݱ}"=ze?y1?6W%sp56J' = L-QGWУ{N!R&>Jt ܂ABNQmڴqTy=+YR~+ u^r}{߾OF@@@JAWI: \ t!*? r项+?HVj.=t0dA^ٳr=d[_:o n|>Lڇݯ߿~L 2TE#S EΫ$ZI?.N~tݝBS Ma|` \c U|BVjj4HG]Q OKlNGyL辛0aۯt\}w35/ս']8':+arwߟ>8`x   Tze@0R87 3ɪ:+\PC|%.UZ{ ;zzU0l]WE-#]@#&+XY #X^ V)tAgp~OWCKIAWu i4Cx|⫓_lnw~>B߯vu)4ӽ{ *ұVYG8ſ/HCl݃W|0oo@@@@$ tC@b.u@a U(lPx.?4VdA^CjȻئ :D\>珙W tbFsWK)Q[^A_&>\ :oCQXא-6UzoRBsob~$ | ko.>PJ5aQ:xǍ9<}vk,T@/I# K!& ?ZVAW~'#@@@@$@U}Abt)`PQ'}׏z?@&^E^]a# |N4tD҃t5 <<těZN@f&8*99+6&RJ"Uo|Z몚PO}PwZG?gZXxuo.Tb=WQ('r]Jᦪ9 /ZVAW~'#@@@@$@U}Ab!Pl 9ItyTԩS' \c+XU֨}\} ;wv 425mr >ÇQ񂮰}Jb)4Qk 0 FnO?^?~l Vk[6^?bUX)q7onM6u E% YA`YgDttu=qo%O:y%:0>:_ ={_O4T(h]5_ױtAxcǎVF Jq>5ta~J4@@@@ t+C@X|(/ę?QsZQ)=O'OvC\TPϹH`(R( VV R^x~I)P"Wckti;V굮S|)S k(UhڗBC)Xσ͟\2Z]`/8źĆ0:v?{̷q24k{ϵ '/|Xk.VƉ+ o?Wz @@@(jx PTmڴ1|8;K"E" <(CuիW­D]@ᆪ\[λI~~(UYҹ)8y (\xi4ڟ|ve1SՈ+˜cqOUS`2v}AA:gUkS{EDuCaBfj:; 5U \(L!eq]c.v:G9{L0*}>ZOKSMvwC6ɮ֗^᷏skN]l0jJ I-L!/eFC@@@JAWI* R@+DM!+@ Y⃁ߏQ`T-LBI14:'elJ %6g:/O1Q+ߧ9&;_7[ǖw?u bQa8a}=-|l#    *"     AWs`@@@@@@tDm@@@@@@M90      @A Ƕ      &@Ul@@@@@@ ]c[@@@@@@b *6z      P-      @ t=F@@@@@(AWA@@@@@@#     D zl      Pl]Fρ@@@@@@ "@U=E@@@@@(6b       *"     AWs`@@@@@@tDm@@@@@@M90      @A Ƕ      &@Ul@@@@@@ ]c[@@@@@@b *6z      P-      @ t=F@@@@@(AWA@@@@@@#     D zl      Pl]Fρ@@@@@@ "@U=E@@@@@(6b       *"     @ 233 #     )P^<|Ȩ9      P4]EQ@@@@@@ȗ@ʗ!     AW\*:       j@@@@@@ mRQ@@@@@@AWP      i#@Е6"     F@@@@@HTt@@@@@@ (@5      @tͥ      A@@@@@@F+m.E@@@@@ t5x      6]is(      @P+k@@@@@@ JKEG@@@@@@]A ^#     AW\*:       j@@@@@@ mRQ@@@@@@AWP      i#@Е6"     F@@@@@HTt@@@@@@ (@5      @tͥ      A@@@@@@F+m.E@@@@@ t5x      6]is(      @P+k@@@@@@ JKEG@@@@@@]A ^#     AW\*:       j@@@@@@ mRQ@@@@@@AWP      i#@Е6"     F@@@@@HTt@@@@@@ (@wQT_$l D BwQ*VAl~O;L >'}&@ @ @(42P @ @ @vAW @ @ @tUJ @ @ .PÇӧٳg @ @ PZ488/^.]ZyׁW:Pkrr2 + @ @ @ H.G'OF-Z%ujn]͓ @ @U Ďv4, Geخpzz: -[MWM @ @@\Y5<<\m +tݿ?K0c?X @ @ @TY VvBEVXQͭAD6ɡ! @ @ PUX533Moll|n^by @ @ PxSwKUF> @ @ бcb(*v @ @辀}(  @ @ @}ttuSS- @ @ @ 2t1 :@r  @ @ P)AWE)H#M @ @X@1Uot?FG @ @t_@}ӾTtC  @ @ @> G  @ @ PAW]  @ @"tUA @ @t, ꘪ7 #@ @ @/ i_*  @ @ @@]}]T @ @&p4>>R٬ 5F/_֮]6lӱ zʻp] gI @ @ Ptܹ499YAWh###iǎihh'tu   @ @ @'OVqaڿO&( t-' @ @ @@nܸ^ZAdt[lI7nl]]'OAAW= @ @+p)[=}>AWISPwO%@ @ @ =z45F#k4])(ꏻ @ @ @@q#O>kAWISPwO%@ @ @ AW~4Dk]8T @ @ @j OAW~@ @ @J! *V]!o @ @(Xmt+  @ @ @bIЕ* @ @ @R&AW~@ @ @J! *V]!o @ @(Xmt+  @ @ @bIЕ* @ @ @R5ZdIj4ѣGl²tWt7T @ @B`!Ѵ|t4==&''ӳgϞ… ]/ywsX710 @ @ ]"ڵkWZbK޽{sז-[>~tڵkU aAW~C @ @ @@)ztLCCCsO>MքN ]-;}211axxc1 @ @ P^]s+SlE833ڶm͛3xױcR`eٷj{0 @ @TAכovޝ?HgΜIf9={7x#;wutUV'OdzvV\,߿"\{,w]bQ? +ۺ0  @ @ @ iӦĉY"իӾ}W^M.]Ǡkƍi֭)Dl?"駟^ "ߞ={ϝfK.}۷wŊ^* @ @ @R2m l:zjݺuٵ;MMM2j_!_x]_oVa}СZŪFƍXY>w;w_~ٛ* @ @ @R2:rH6_~c +Vo6fŊaڵk޽{ξ[o۷gc[. "?NboCCCيx~^Ǐ¸'7 ^l]8E @ @(Ы+VM}m+ة|AWS|AV}VgEHT_|EhU[+xVfׯ~ʕԾal0ⳳ]OЕSЕP @ @ P ޽Ξ=۱|AΝ;S?+VUEj?6oޜ{TЊZu{K)VwE~ xWnzzl}غvt…v+?+  @ @ @ b}YZhQ߱G{@utڵG+VGZu={B+VUj/Wm ^_ʕ+?:sQ5kt&  @ @ @@Yzt[8f/N[n[jD꫗۷)a~\h1zu @ @ @J!ˠ+ރnݺ)B믿f vڕG+ ]xnڲeKv*ôdɒ_ls8==]mc m۶eǎKwNE/2{Wv'.o @ @(@/իW}eά1=yd%T%aal)8_rҥKsub{O>$[WkNaliw9_6mJNo߾ӫ]e] U @ @ @hBXV~rr2]r% beUj]~tE(5_a֡CZQ,oYcǎ422=#zU+ZZjUھ}{vίX9q\~=ݺu+=VzŻhmu҃]Q] U @ @ @u5<<һqb'Nd!T/al1+;ٳg\q=«]:wf=^;w|eXvl[W{^ЕW0%AW~C @ @ @@)ztBl+֬Yb%WxisN^XY:"'WP\2Vp_/_N>#Vhxŋ.*͛sCkWkՏMLLsνn܅_]] U @ @ @X"B,jsgm#䈕bqDoώ`lvv]GЕQЕP @ @ P JA @ @ @J! *V]!o @ @(Xmt+  @ @ @bIЕ* @ @ @R&AW~@ @ @J! *V]!o @ @(Xmt+  @ @ @bIЕ* @ @ @R&AW~@ @ @J! *V]!o @ @(Xmt+  @ @ @GMfc F:|pק9==z"t+D @ @z pԩ499كJHڷo~_t+Q9n:<<\% @ @7իW{;زeKڸqc'=*͂ @ @ @'OLSSS'˗DBօ/*ZxsO$@ @ @ ̤s°O- wؑz2AWOXk= @ @#p4>>j6x Gh4U\k׮M6l ]=]₮$ @ @ @b!(]  @ @ @J& *Y^5\A׫d'@ @ @* Hg]ii @ @ @ :*b @ @ @ oڗ{( @ @ GAWhAW75"@ @ @ *C:$ @ @ @tU44 @ @ @]SFAWct @ @ @@]7KEAW_= @ @裀|j @ @ @etKQ[ @ @ @J *NAWEi @ @ @@}c+JF؃5: @ @ @@Nffff*ccc9MY(߿>}.]_&@ @ @L rfȊ+J66]X-[fU- @ @ @ivv6l!P { Bɓ'YȵhѢ,ȍ @ @ @Ϟ=v<$BCFGG0Chd @ @ @@UN###N~*tbǏgW_ @ @ @@"Ԋkŵٮo'3 @ @ @@5]Y @ @ @j' ]M @ @ P AW5h @ @ @ jr&@ @ @TC@U> @ @ @vڵ܄  @ @ @tUfA @ @v-7a @ @ @@5]Y @ @ @j' ]M @ @ P AW5h @ @ @ jr&@ @ @TC@U> @ @ @vڵ܄  @ @ @tUfA @ @v-7a @ @ @@5]Y @ @ @j' ]M @ @ P AW5h @ @ @ jr&@ @ @TC@U> @ @ @vڵ܄  @ @ @tUfA @ @v-7a @ @ @@5]Y @ @ @j' ]M @ @ P AW5h @ @ @ jr&@ @ @TC@U> @ @ @vڵ܄  @ @ @tUfA @ @v-7a @ @ @@5]Y @ @ @j' ]M @ @ P AW5h @ @ @ jr&@ @ @9kIDATTC@U> @ @ @vڵ܄  @ @ @tUfA @ @v-7a @ @ @@5]Y @ @ @j' ]M @ @ P AW5h @ @ @ jr&@ @ @TC@U> @ @ @vڵ܄  @ @ @tUfA @ @ \-GIENDB`django-simple-history-3.1.1/docs/screens/1_poll_history.png000066400000000000000000003056441423103303600240220ustar00rootroot00000000000000PNG  IHDR<@bL GiCCPICC ProfileHWXS[RIhH RK E*I ĐDʲ ]D@]Uwudkc塈.lI]{}{9?%sofЩI.yY|DkJj !˥츸hewy} B=P8C A x _*+ֳ J< bLbgqgq&1^4Ov3Գ YGĮX@'81yy/xƙ1e`u-*!\ޜ[r1ࠉdʚan̊Rb}X!~+!F"Ed59g )ܘh>#S΅H\M.45Y8Sak|x2U\)EN[C$*%sƨ!fs6M3l#S+O(Qc32e{Y|^lH̍Qbg/f4#OE S׎u%Iz.iAH47NcSJĦ/X%3yE p@(``>M=x@h4) &b'DB  Q A!Ѫ. S5[!Q ~W$#ђ#: r:6Dk4a^ΰ%1J$q<ǣ5w=!ppE9SDU=,0 tᚚ3x!7M >FbA0'r4+o5|uŕRFQ)_{j;i{({eԹf32u|{ז2v;cMÚ6U4-^O#OSIkk\H~Y92qņo~!+rwu@?~MdϺAegC`~+dj'kq^00ĂD f.z`X JA9X 6*l`?h :Up `ABG1b"Έ;"aH4"H"A].G@Zu=^^ =z%Ow30532v0N3z \lr &V1bbL;&\ϼ|?l{pQ .zc4(HhTf{cqq&;&d&[LN6??lNsMEI643gg7?jk[8febrYS~KSHK6vA+{$%VXS}3[ZXLgSo-Vd;{oso;j8}s7;v8NN"jΨys11c\...2F]2iq6ƭwf'WO\&-qkq{wvAX|x-ox2<'y~Km]}'gY_oBþ  ;&tXt  ~vdgBqsbeaaIaUawí³#<#F$DFE5uO<EJ-nN8iݤ11Xˍ]{'>.?q'?w&03aOĐUI:ӒߤM2n)RMRũi䴝iSæn3sZkM?7dF#3uffH'I25|# X^+ > \8+ k]V(HT!sUّ[MݗGK;$їHN2U4S,-voEvtysܰ)(V<@^mӜs0˟:ryo[,XкzažEv/.Y%kZĬdQI7ԗjJe27-T&(;_Z^QaܾnheU^&&hzkvq=k}Wfn8W1bFFƮM6VoP%ZRƴfy͛͂͗oij|֮b;q{;w燺&;w~%յ;~:ﺺ={Vգ޽vcsKö}}??=9k9/59MCu쯻[>bxdQђCNJ ;uuf뭓SN^95Tg cg>wyM.4y^/6wvtN?Sţv<`_zbK㗻^:7pu7eo~g }SԧCyCCR `p=:NUTϦ*VU` ([అʭzb0@=_*;*Q{|-~ m pHYs%%IR$iTXtXML:com.adobe.xmp 2108 496 g>iDOT(w2G!@IDATxC_{/ (*VXޱ_"`EvlRR3NdKve_~sI2}3 ^1=Kh        @(F]-@@@@@@@<        P<K#       @@@@@@@(r%@@@@@@@@       9Eq        @{@@@@@@@"w8`@@@@@@@ =       ENCd0       x@@@@@@@@"'@]2@@@@@@@9kОlQrPt[Q7vϽG>`/&fsI򣦅FZ9Rٱ3EwoѿuM[@@@@@@p<mz[_\=: l;i/$y'ml˶g@m8>h)Svrh`ifFߺ9FE@@@@@@ ^xx9Rx.S(QL6e)Rkq$} i"=C25      !W\'jn kՕk$\ȅWyZ"lpػRaĥZAXCZ\^BP_0KRK;ioShvo]ӽsD@@@@@(>~K վlR"N4@~R!?Y}̹RRw&Q$Nw @@@@@@H>pb^Jew)ZЦvm,J˜:iA r+V8˿qY!}5Kq>RT;      {8}qaV뤸fԔSz xȭX,_TE 窲@@@@@@rxߦfy")]24Ť[/8B PyEQ %b@@@@@@<<ةȅWo K/?'M˚RBIL_Q6Yc;䧹6I |R6r`tٴDm?kAeꔈ7PK]hWwLZZNVl,XV*iSc-R Vʔֵ2dsfهKClJgs/>Ún;E~INtͪomO-S:7]k˸ɼ"   .}oKd]zpϱIrodͱc:@NJz5eʼ3c)ȝ7UUzV^'# wU(եԩZI~4K/\V(,uv@kٱc|TYf}زܺlfvh@L3O Y-ɋE6 ﹋ɳ|v|H;Yj:%ٛV$/1WWm;WyhRmʺ#I [.}[\7L5?_~ 5 qi-_7[ ߏϦVI8C&V~,r΁:g }*ˠk    @xmJY2j҈s.R|){hJ7CLo=HU盧^-CKzv/ Pj<}RRϳCyaiX1bv;.X]gה˩~OSVN #ؿUusL}>s]e~j^{3#"զ{Fm6c{o[{}NҨfg筗_+bJ}ilo>\Q;]DaZ&sm˟ÛpǚFkxKns^?yJ=yorԭVIeyↈnwO4*bRtZ#w-+S*zF@?|n[GX[|3qjN2fL?;/sҎ,G-C!{Ϝpce:F; ktRߵ1C|J~3vXSK1g3{o5vVv_>sבo|_4SRמ?{`˘XGu~'Titw;hSqU o>qq9yE_Eb4 .4AΓa ^5d_7ŮԒg-exol+.}v<{}M{a =z҂Ω(|_[/菏*WU/_3WK4͏QIۤs/?﶐Et4U`qq$XힻHڸ@j菅S>/4Pjw6a ҧzwOٿ6gK45~mѮ{ 2y\;6Iz~tuZ8}ؓ_GmT'?\o~=> O~caueeŞ>|2_3:t:+ ׸tn{ \gc~P(~{f§:C]wGG:~WwmcumR~ҴNuI۰I<[\޹" 7UxĮ[׵ws>țlO?3EϮc|ErC-3xсLV('W}=I~1_ 0z6x9=-iݦ 79j@Rx1 . :t]nPȩ_ECj2D>D,F>xhhKWʹj_zzlٽ`o\BFKJɹXЭr~whSK5G,@byKWD]z|V Z9oTkZϻ>  hr)eUZuIt}hW5٬C/zްO yp1^w[;?V꓏X?O-_>jttVj[U, sY{.gQ7 e^aw :˺C5)R^g`5ĵqV[^]=]t(`OaS$v2;a{R5x T滧m8u%euzIo{6BǞxfu:3 YM-orZ 2km' N}zWT/^u (Jv>\]{x*Ekp:ڮkeV :q~}@ Vm›굲{ľZ6O{+v1Q縿߆"pԶkU>U|*tjՅXC$s"B8X+,>C |g(8c) vx3\V^;צ-/+O2bU kyl~2Ckjz9T+0+s&=YKޏK34Gx11/^?]s]-_&    @QppYW\;_ ˚wi&کP;RASSUaD3iKIUe<=4E5ιF2ez̗h*gibr"K[EW$N7Zv\^;BgB!|jNVEõZr>>eukPE Z ĘY Up׼+g'pxry'z>м.!DJ1-cძO>R@>vϓƵ{4p˃^>u$+#>!T`NUun7vعDq%mwӖn^n^;4,O]^orUmTXi8xVx3{޽b*Uؘ%'-Њ_PᎳ˱Z9x=2$aO?wAg<;EMvx%ߛxvOԯ^ SiV%?Qۯw,iT-'ɡ:DDPpĪXPysxA!!ʠ.򪨸!^^lb>3;]JPvȰUn:}K_ T*w<7[)}grLߏg!ЃV齑PШM # & ͯL/hf6ߞ{ol25ջknR}%~v^q a Q=/LĶ샿>[jۈnu$OR?C7)jVufPa,:U С%nQx[WVkp5V*`kv7 kmxֵ2~ݿ u᚝%ݎ㽾!'ԐlYq!yn9SJfolp5@ JںLkb_irA-Q縿߭o!jzuZlN|+n](`uض\AŖ-jV1jJyS_jAma E~ OkzQr_E Sc 8^/.`3c <}o7^ެ, @@@Sl %:bM:A-8мny}2o [jAZfmX YLw{(tqaC^D{m#G!o\ TXF>5^r^ebT([R #ЫK=?Y85|r>Um/oP!:$ڧ*56xc|Ux?p7CP')`S9 ;(p;.Ѧam dF\COPjNwK*Z]rکߎ}<f^GɔK5Qx5. ;P=ik؁Oo UUlU@ 3b/k޸@ bCD7~p\҇ٱu[HxwܿM׆EW)&F^,N J8]5@t=2꾣]/cNpFpfx hhdhlV 3J޼%\K+wp!+1j>\c.`$` C-5*^r WUrZ4P yP/ M*ɳW/LO{*S;.i$E a XjJԒ* HfU(F k+кaEi׮}yL_!ې6EσMe7]o^$?8]iwϲ?+q]Y EuVHM ? }`F%zlh^v6ywj۵S:'O: z_>zu>blȄ~ 4Z<_[Usvsn[UG.cb_!-bZrVcH4},tbᓠf6*kƅ5ٲ|_W̮ho65 zK˽5KSnn     :'[Ҳ>d¬r[Ă+muZXE{"&Zv<=]YCRtH ȟ2_xl 4mk>kFj.Stxc]lZ[AP(aU"[V^.NXYSB#R['V[+5|Byy%/׮(tH kpj6`c7wy7e rez}zDG.U+K1.}^+LXj ֗F|=ĥ0+ q-^B9e̤:$bOsAz̖[?]gR%KxӴxun9 < Wq_L$}xoP2AFnJ7l=?u@׬\A1xJ `!%6mOܹ3ۧ )MnjU 9>nVAih@^סB> *$^縭$_!P>WwտX(iۣZl&w.( m[ygqKZM~L[LVo*%YƛEa0kAQCPeM6JYgpӠŘ   4`NxEר>yF>ZJ=V;uxL^g:US3,k`7:x~( o_ϕ{ /~{o6f5=qz!0VEC)ޤ ~ e&jۮՆRƮk6?a7<ӡUDĿ~{ ݭ٘ 7*(xH)]*b(~ԍnY+^㑋NέȱǮph@F0ЉI)-ǟ:L(Q|W(MP<8hN~%O`}?T?FXrֲfm_=g`mrĝR,asܖw0*oFmu~D4 -cˇVwkny9Tm/7mĻ߄X7~_oMPu c6^g     ,`JJyېJ']T* 8AGpTCZXGW&}ΦZjG\ ?NY--匇MV)xtD ֨0bDox4+7sgʕ'4CC.[%\m6/`Wh/ zCfXXņDC-~װKƕxUԹ= Ԕ/[2oy <5WIu +(.jH"?ؐV=/|A*6jڡAI~a?OWF Up 4`C8DB6V._JEfjwWH )aʧc:sҬ*<*%5޵ߥWl\*xM̮ Ё[ԛC^,hȂ fDMyJ|~>A[}/N%VM'YumӦQ!]+u_D7 7߅tII>R?xKA7ٚEC^kXxnDu?C{*oktx7<ѼoW;F6:-vJBqP^:aX߉VB)%3Jx4`ú&8NtY}۱xEYDZ !ڃu:n?ChT1??YN> )+ClO_=W!uwgu:< G4z$koa-V+vMBOn߱Ė<nkWaVp+zk5Z\_XcÃɶ퉳o w_o;8ƘI3uk^7`*ߩ /3V/})|;p_+4*oI,@v9HC^,}}9Fat ZR|}zX,3\u:}V#e7ʠѡ!-R>NT;9u4Air˥r +`C.XǼt6 <\uO/kȵ7ͩˀsgR5WtҺVw f:@ <8/}lkbԷY}ny <$q:m6E#}rYQxpWW@@@|V-C{rmGi^i \^MFB(rvoO61-o4 h'DuHvM;lO('jT.Od¨`ChرZY6(+Y0'{]B_\CcZ-x2X+YTU'#`;$7plVrYݽV|/#ƅ{HNu2 " eaŴ% +gg{o 된t읈Vކuvuc[=#7RR<6a\byyϼ'+pUiӨ<3)ެxж@P!~UY>B4>b(X OCZ7 $#s[DG aFؐ W<7](utȀdi ns4=P,ށ#VϽͲN:@^/wϾ{c#ߢ'U+!}Eh ;Ckj\}<=4T3^a1F;ejhbKoڡE?ňqK#C<a-\ZGgP-='[wW|y˗,kv+9{:ڎ-hP?m\љWd넷?664@P~_ `Vy`tgrSJ̷m\כhUdUXᶝpEܘa ԯ^6[*bBCttBT=S>7i\}T' Ԕk7DTm "hX[uLُ2d`=9XGvi߳{)thF1=_Viv&ҰfU=́޸ sWL:魺]) |#^CA"}`.ej, +^) ~(7=k{#MF~C5JSޟy?Բ^N^%xi\Tl̖O,-גn[{eC`no cu8vV-7WpAId^2CJ* ZFN{<0b 3j[B%\+;տ -QUEb¬_=|Vx"T=biQt oF7>DJ ]ktݎ5P}6GhHŵ;c;>F6F3z^v6*>\Ʋ[Dw[hvE̶J5*_",n[NjDX6,GeVY`*:mW_W>{KU~:~<7lm"w8p窓Rvy!JB]/qUoƇqg¬r[e댎|ņ_7 _U&>pyI|C,fyoym营kETpՆ&xƾp M^:ȭuw$J~ܼ !_g{{c6kV9:gyZ aUv{k%FO./)Qե\srs?cZւBYX5;܆ybW n =\Шpo[s{ʡ6vJo |* PX۞~㽪7p񋥓Ea-V Qᔶ:Ė]aQpkcڶ͜^3GjW uPRE%Bnnfm7/! w`m/ȟʹ$|™RTh5yiYG!!    { WaQ/п;:֒fukebYH`͆P=C:t[54=㨲himڈUkN Eٰ*ЀɤFQ;/.h]L+6,Hؐ,[557i倩^y2ܡYo3/TaUVf{jUq bam[ȸsrr:^Cy.7 Mri]4Z4Ԡ:e?'F\]WRxpA:uCdF}Kĺr;-E3}fZ*7zB(c eգ7c `mr;O ½ZP @!s?i(<7-E&/Mu_<}4<{UpGĻMCE2_maԔ*/٫ 8wTIj60]1zSҺV]~Ptiv-\ * m}޲7܉XAl.UwGv"uʟ 13n^Xz_vm{oZ^ }% OѮ'6`+Hxe^3Wok]j,륹~?kW b5GR%x3      @~CnU߳?hI;~Q;z[ aAk.O_*zݨj ;: <-`wt["7=2w3k2sey?ktP"/rzv.9 `|D$` I?bY>b^~vL~]߿xlG]lk.pO /T[6N /ֽ%Y9j =1.pnyM4"    oߢC/RI9^WR";N"wɾ{+g9 <-!7(|uoҎYRd䔬n=ᵴzi'h[ѰfsI;wxV 5`ͪNNrofVJ󷩝u.ې#'AA7UNС@*e_ռ0Hi1Iv_rCjȶdL04?0)m]#"GϨ ;Z<[ur]uQp!      P HܞvXGE5/=홛rVlx]izH=@9*@@@@@@@")@ࡀ. G.Z7vu^nwkՇVjl@@@@@@+<e̹RR_4(=y>M*|`\K>V%7       WC~?:Rlw!Cdњtg%mG1yڲ9x_ehNJ_\~e       @^@@@@@@@@ @@@@@@@@0<2@@@@@@@@ xH*'C@@@@@@@ P@@@@@@@*@!l @@@@@@@ CCa(@@@@@@@Hr1@@@@@@@( >@@@@@@@@ @@@@@@@@0<2@@@@@@@@ xH*'C@@@@@@@ P@@@@@@@*@!l @@@@@@@ CCa(@@@@@@@Hr1@@@@@@@( >@@@@@@@@ @@@@@@@@0<2@@@@@@@@ xH*'C@@@@@@@ P@@@@@@@*@!l @@@@@@@ CCa(@@@@@@@H@ە-1@@@@@@@@<00G@@@@@@@ xH)[D@@@@@@@ Pl@@@@@@@/@!l@@@@@@@ XCy@@@@@@@H䛲E@@@@@@@(` @@@@@@@@ o@@@@@@@@<00G@@@@@@@ xH)[D@@@@@@@ Pl@@@@@@@/@!l@@@@@@@ XCy@@@@@@@H䛲E@@@@@@@(` @@@@@@@@ o@@@@@@@@<00G@@@@@@@ xH)[D@@@@@@@ Pl@@@@@@@/@!l@@@@@@@ XCy@@@@@@@H䛲|\xh)Y,L(_O]m[Ҩz sdͦ-F zf}-pͥrjِ)>+|AMזe2?\*3 /_TߜH+)SXYF=FRIJR|Yo[;v&el@@@@@'WWMZԪ,vt9I-W7:7msٽa-J ,*ܸY^柜z-X_Nuvr7NidܸZEظԪ*[mvd_VWw^L)9n߆9s>oa({Nاv$6o <4 Zz(֥y]/b+x{3      t*зKkik?Z*_NYzQNPqe֊u-oQ*-to%u9_'눌nYEs(r'nW_5y yit <\/kV!D4ZZM1=RK#[56caq-ҪNUosJ??BB4@@@@@@<xDIn+^WMS }O oy֬Z;#VUCZIr)%k&{G~8FW -?',V     [6'Z:@K,[+~঵dZt&=ma;4m:]-ީI-I)UB ᛝԷ{tʵܨ'|sꕗC]~F5zZF}\NKCIz@ñmBe߷l!Yvh:&il{2Yǥ G.H }{њt!ztէ֨2寅q+ٽ֤zEPBiW;c|ZUb=qy9YWLZŔ%in4zUIZz־+tXduvV'(V$V+/_qdņY߆бv9Ǚoܲ->v)APNgaoАKw(Z]~ Xw&^c}}czռjs9{w~SjULL~6.߯Wv{kw=x([7ө4!#Sr>7kݘl}zZhuzFx9Y!UZwama͖ۢ&-oofm= ~/my;&۷glߪRR9;8.^mV%txi      x&7!'DhgP) “ml}N ?u9y^0­lllIޫuȶ ngs멑! 7??9 <;3c}9.se+6pi}QՊ vҵxM,~4zsS-՛٤һN$ֱn@f\/~潷?NhX݆VIr&N>}Z83X˸t=2v͛=yDϯQ=no*Dg݉I}$+b6u9VpֵpUG۶PT3s9r= $fPOu9{+t#lr 후f,o-v2辏>6ٶV*&f y}]]N?(+pq•/6o){᛿a5C4U%5T}b^MnrSR]C$Q^`}aA};0svl~m 7@BC hU7K XX/'޻?jq[׾2"wRUw,0 "ؓ`51F Ě536b/DM~(j4D`7*Q(El(Elo7w{gڽo'3srF@@@@X$<\B >]r0I%17FAmز_A4 0]+ЛܖO~S}3>nrw9 q&WT}J7#[ws[7qBI ƛپU0|iGn j',RX̒{y6atq/Ifd]Ry mnM=UEcq0^tO|=e{L }poË+W]m&cd_KxM_ς΋Y׷Cоprݟd^5=z9Ģ8q"J铝C]m:pO,9+Gp5TmBDJd r>Q=swJ"g͂i Щ҆O~]&{ϦR [uj{7 *eXK>zͩO^hYq|]m'h[SF[ƒmOP%/+U"҆K7      Okn}~|)Wiy3|Wp[Ai;/qƒ3Ѭ!T]=5O069F[&>͗WLm&Am/g~iM98O &QXW EY*A%mQ85*ҎٳoH>e 4S\= |o%h(' `[0Xd‚-Oj*~ͨ{mݫ_q`^=軡V3\>ⲚD%\e8/>l切s\1 ,I "Q5tߤP@+T<~-}KxP}3 ٢ Y׷܄Uаjzv^kz*:5 qJX)/668j*[!J4φ9kH '"{%]*44DӚJx* 0=w}U.=%.TZV9={j$Tn{t%$އJWOd7ޟKhdl|^>)BOMIUO$5v 8Ͽp=8u⠣:^%PF-Wŷܛ׫5 ԕ ^ (T-^֚ J>iC+ 4}Z9 %PJ4Q%kq8 ͮ74%!Yul=i$k{VJβ$;g8!B`CH` JԺyG۞lb qēok]ڬ:;8y7?6?>5|Bt<>.c;     ^v1wՔۗחW{y~(i֋VfMI+;~W?>ğڇE'[*b[6zꃼ*$q^qr]Όƅ:Q[vƒҵڊ%c|wINfRo%nm\R3ݽ^Vnt_}IC*YrK%<)')XBC<-ާU=4b ߴjJXLǾI @@@@@@ 4ڄԗOkiCZ C6hB'K` qb ||,V+ :\ܻ;^.{%vd۷ y>*m6^Zׂ$w86<2/Lg}$+5*D(`~ѿƵyBC|q[ڴO?xnWI.=J ϳl 6Oix]H;w[ '-# iW?dZ9T-mP/񶔀J2[%m]t9O UdPR kJ#_3UQsai=     4LRKZAQn]a4ĀLx˳w"_Kk*k| |:ϙGPhxi[$<ؾw߸CZO\P҂5 =} BmpK~}U?҄vfo <%;}m]s$چ>҄s 0$v>cV UMƒV[n)ljvn|%* a+l(Y{ 9JMRvZ\TƒO.ZWu|'/m5ݧ:      Wk~M& .5 C :LK(hN9I[wjz0k*/^Y𱒄x˩v{ozZQw 6ry}9*LKP,'Fk씐T}Yʝ`~HlfaTf*j=nn-Xz1J, g=B؏w<P-'A=޺[ǖGtc׮8NrHG9-"-zM4lp\-g}/+%fWAZ|4=Y@rٴ_qAvj#!AX_w}~oթmp;(/״Sǧ7Bf+Xo 'i *a&~ZpW~Io%k[蟧mXV qBz~1Q(AB$zljk$ ZZ*.Y'Ð NxK+~3o ~X1}| Fh(&+,=(TwmJNZ4m`KOW_WXc=tsw%JkS?~C[՟!DݵzZ-6ZDԠ} a> j$!šr}~*r5[TU> d}67/J4{khq;zhgn8NC k_iƒY7Nx1j O_(ed}k(klUb4W ڧKVLAtg-{|rϺ&Oy1-`E6_q՘֞}%U@j ޿#AǼug?Rv F9UB擋: X! {')Aw5Ϙ9Kһϲ4̆ _xv      SQ%<ԯvYs]3U Wt6]=Urkzku,Vz`{WsS5}oq j 2Agڢ|QIƒv^-CпА5nD*/l^VnݏCz^rD n-!BґSڶ>+T 4-?뺎~|U[ [:c8onJ 6]ǡPA~>Jaiհڵ;#smM[]}W g=M*ӚNV+K!o. 1w019;'k*;6h 9,gϙ։sk7W6Pk]P 3'ge8B^U*a6*m[OUP+}\~mӰ,a^M~(ҍ9XRۖz$K7k^CKU^>UqPH[N =}PC>'hh  ו7>ԺYHn(7IEy%Ҩ+5]*ث!|DFsw444J?kOU4omG"6[opOU>jvÐh85UPVVs Oyj/trFL5߅~򪦡`{}eC(AQ|Tf  ] ea4-¢JMWҐ<}2K7zU"J|@@@@@Xz$<,={ԋ~Fpbj3!Шv^n&N/Ѩ8y@ns8+IDAT@@@@  p8 иZƆ1gzkG c&+,6i5[e%jP'Ͻxw@@@@@@HxT6V`V~k }z|W6O@S残"?㦹 $>@@@@@@ /K`;;q?=7g6.*tnmfo,'y$U]      Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@ Ւf?        Q!@@@@@@@Z;c?        @$=n5t~z<~,W`޼yO?M݉ڶmKPNu' ;4hzv~'s=zc=6oއz6W^yewUWbW_}o ?=4@@@@@@ 4|ROּyùsiC`TzHCUXSP-_$@êy$<lCHx9r6lSC)YE=z왜o@@@@@@A 4ڄٳg/8RI?>۾dtY@V @jH~,2+{^[4NxHw-|~;F, Kx?~|J8ڵ; *}O>@@@@@@'(kwI'9KhP 裏v+bR@ovuW7p%CI~dPV^= 0lN ɦd7x#TO7n\nV[m͗(Pl$<Y}Ge˖ .(xGuTd%=@@@@@@(jokq纵Z$O?u~u҂sJPm۶SNn-t-\nB_43<{='VZɵku=Wh/½ iӦ^}Y8?}7a;;wUT[h?~3fL8b-r%еMkfmVts|AĉrJ8ڵkffe/!Mn{sʇz><8#g]^z 6`Fь{7p &l/n=Ä^44;wW2Kx3{mۄUp W]uU޼I&IzO6|5ADuȄ u_~nUW-ھMx;w{饗qYGMC4z:+dac{q֢wnvJ>öN>t       I%<6KXm˜^2裏:5Fi]J۲?ܨQiMA?<pXVXaw׆ Ч5jӦM0M3V8ٔq衇_2d4C:_}ff+I[n :tӵԘJ`HZAXlvG Ou_Mk&7C|xݚkMz=P_\Z*JW}c_Z /\ձ_ TBǢUPTMc% UP+24ײjZuG_y啂 rSے&_{C Ͷ:'+|5.!5#?USAXە&?-59@MTBW)oC_U<,D fAnV *Dǯ s9aU-ŚڷQ)o[UWۄ#G*e/qM4۶zX,D֪ȠJb +zx衇rTPszI'ٺWℐ,qYOxPe%:ѽ TOS.iETQEϑWӺvXHuԳ򅚞?:W>uNK%<(AII3^{w1ֽs,iҿuډJ(Dm=       LQ%<(dۯV=_/k"5nFeTW*~饗ZWAR ۂ#FpbqK +išE2%on6;iAsM}ݞ{/ -ar'e,o~=#aFmN8ᄼ~$Ϸ뢒zj^_4Z*e5&Ԕ Ym)ȩwkl;Hl= KA 5%ۜwyav߾}!TPUU,a}q;SJиr3|9She%쨢ӞxGzF)j7߳:e9AI'($5UI&(QHiprWج>o6sυe|)9%޾ .V% 5]w b-~']]*A JPӐ3݇OUv뮻w%ޚVO>yUϰ$xB/; dBgg"c\VQU)p ;찃O=CzTD*w}-i%zJ\QcTrUZ֞%<}u5[S➒$UIkJ.9\ءaHi=6lXXT:Jڡ!      UQ%<4!{2ܶ*~\xxwߕġOU(`V%2r-NU,qYLxYl.=бccj&X UBJ;ycl^SILw}S߆I.SwwuZƒy&u|:dIsӎŪ{X7n\x(J4dˠ!      Y:^J{^l5dnݺuɽ/Z}>\. cƌqVeBs޷fy. %<.ݻw_>m{I^:mxMKxP |% Z۶m;$i ZaҤIC&?m8p`n%<]S U-Pz+$Ĕ;_Nx1bSw5TӳC_=mO*X2BFh(8`Sɽ`ا%% GΝzGl};AχJQQ+|+Ю{ZM~J*&N>;r [Jqƒ*.'5 ydRCbO> ZFUUIkJP,4Hi`      4UÌ3BUAGk$!fΜ.7eW_^ew]ԛ RpM4vJūS߂qo就2$}Rp՚zL+(C[&B J+9 |'ΕO:$7o޼%-<ꨣ믿;S&<8TBmww{gn쪜RNcP}}/l6x0ĆՒ0DÈ8!LCi-1MF$QN0Yuet{o߾CwΡYfe9{Ra,'&`     ,eF c=6$ ׯ ;v{g]-Iet+܄ 6Sva {a88 K *},@[jO%S=MxCTtl pYga/lYb ZCfs9ZO־7l3wG䖭4AP=Y@îL>5i$s;&<CgNA}4Mٽ/KUȺs1a-z衵|8Aσ4Ĝ9s1~gYĈԕS&I-D0 @@@@@F`%oA. "]CLh|V ڧeӎPØ1c_װƆ/T mN^4!y|ܡ酪iiͽ.Uj%XD]ù6AYf-O;3AQQj=o&kvHrŴjY㩧fϞ< }྆mнlvO0YɺiX>,}gN&O%tܹY@@@@@X.aСW^ Ů33-:U\BP~=#a5pV2F;z'c)TA*aZ@WNm}q;sj^%[^;.z@n:WQ `of/YJH&X PF/'A䥗^r7tSX^najP* =PJ:Zmd##dvU1̛7ϝ|n6׿*[ou?|L_~ɓHư fy6tk:ϹsÞbLxvӻNMǮPŇpaZe]<M&mSÓ{!IjY}y;GG2ҐqYrx#     E%V{Wmݜ*X"&QԩS"޾q"@=5T@3f߬|9B/6= {mXƒ%*v0U}ynQ@ MMWB%5((fَO 6z!)@P7oe҄3@׽ >ĚJo dk]/5kӣ]UnCѧOwa?Yx5WrM2i? F ڦi)?iҤ0)DmGuȢvᇻ7<|O'-+wu ֞ywwj;8NvqG[O@@@@@@ 4]aWyRU^\^6*ֶC[_@wOfzB[XUx;vkfz^_z?sn n;wƫ}ױk*ozom+<(@*Zh)\@k(r y䑩eZW6,uaÆfn=[ wu{ꩧ±+@V"E+aYhxsO>{ܢEd%et-^$|9rd药Ū6xt~JPPm%zW }V?WL\ː̡ZCIxб'/ Yk"ϵZ+,5 *2Ybꫯ*dUq(F~]vu'xbEVb Q7j%ytI!)I÷lf}~W;&      4ʄWn%1҃zi&R'zZrnm2Y AݻV մ*9b ڞn;Cr4( e4]cϜ9S_CUiލ74z+*좟omQ} kb믿I2 @&ylAr7g$ r*KX5%׹RV]uP &<@xeR'ђcl^b Jlx_ݽas: m[tcQE5 Ã>k{.@C+Զ=radu [*(IU(5?8,Gɐ!CBRE9!     ˊ@LxQXcO2%wL`D d[YA/[־dtWBz[S0M)fSJOXJ%<(B,`Mo{\6RWz {aMAhsK0 s-G4%U J<ӵu4oS%=TIS%>X\빋d2]]NƒN?t7{p rctnZo߾6)S2t-Դh      "hÓlm>(ԩ;#BOx}W T ֫j~^eM{s fǡંrz껵J%N1wzD>\`Ux4qY F@+m[ yBۉWrĉCƻ}Wpg^͔*mHӦMmW^y?~|rײӦM[^PR]֬FmҖ)4MUM+\-YA$^GLu &<9V\  bI%B5UsQuʳh]vYH!B01N4)$ĉ0ڗ=zâDŽ \mKxPeU%ը|_qUWų򾫂(`䟸iUXIF陫mC*}װij5\6PU%(MI,ǔ'T)1MƟy;ZֻCoޱO4̈U_f%sm ? ;}*q*Җ1SA7߼hMy)QHϜ>,y+Rͺ[!VYs5CK$r\X@@@@@XHxXR>F&ą? H9RJPO8?KҨ+B4DF޽'      @Hxhtp3R{-7pjC qmڴYvO Gn>s¡ @@@@@@ >iy*f͚.!*49SpְfΜ*t͓߰O>pT߿;B@@@@@@ ?k\3TOoҤIHzh۶kٲ={6mZ^qx+-=w}M:)yeԨQNI.J^\ӦMށ5=뙘7oK/h޼sX      Hxa_Zݣ>x+8ۺ8 ʋ.'pW &׀{7N8 @ @ @ @#3 xEQ!@ @ @ @|c9\߾}s\@ @ @`"0] Ə}]]={曯}GܑGkFihZ {7m͐۠A܉'X!CB~Ad g1`N"zzvb~CKH矻_ߝ|M~?ܚd5H[ougqF(s=ڴiS6tC=zoO)jڑZ񩧞N7쳻G}*:Dž @ @!ӥoq~{W]?Ow}IUӡJUIisk8~rHU^;!xru1TM; C>#R@`jĂoݺdǾ%=gz<4u{_M;č&@ @ @`$0 Fvi0R\W\ -P@,\{u~#x0*-l*ÕW^.R73cKs8&nu]9ӢyʳƫVXaw$Ǯ5D&}Y(i۶mRJVx {]S|1T++^s"ιjxU c_)% w}{ݭUޗz4?S2K^P1:dM2e*M%xK /PUYv馛g=Ooy睝eY]veIqkMl*W^{d믿-¹9Vc4JP/bJt*FU>!Y@5}nG贋fSOԣE/# @ @ P+C<2OSZl6.} 5ϴ<@du~wݺu iZj(/?}MbyME'M:t(V4bnn̘1N#UX`0N?6lCsbIFduY E* 'N f-"a~AwF?U6ܕo1P4("ofDpa>r*$Qƍ S]>(0[eUa>ڵk{JYXѾ}{}2aK\F{|_|qwYgs9d [o\p^T&]77={t:W]rB17޺kӦMU)Oqp衇hy=Ю];7,I ӆ*{gU`.]Be/GPN=a|fmkkW_39N#^tԳճ%AL3drVu@P/7:ֽ{Sa5<(nGt~jOYHV]꺪 ym^~1~c%zbeXڽuJԖ{N휸{hVy @ @jt%x8m?d׭dSNq뭷^A^cǎM %sN2mP'wqPGidNuuQg-$ԡuk5\7M:X ?2hgaw ]itxbavJtl$K!Zk;llA}6dub{ Z<Ȱ馛b?Ss1N"8kDM2*Hg<+jDh`*2IRqPk'q"^}7\o]JPn=!Cʧ`+=34hPa,=A$se_K{#ikO<3f׵5V]v%Br,,|qNii`^ZveuVoJ)g$* &vz+K_.=wJXr7k$lGAjrڼ$+2Qv=\N"!XPㅞJJzwnz/g @ @/ӕAtg]t0إC\sFFZqU&al0Ԅ ڮquH[g[leFwn&@P'lexcvڮ2Z ;C _5*NAz(wJSO>IK^41o 28).CYb[mUr~buaf ep6VիW (x\eD2~y#]iˈC9ņӽ B[%KOˀ` [(?*-HaU]x [n%mD#T"Q_\vFzkmiCMBςi3d.<ΡzBBO]t?d|saaykD;V Q}жmؿ}o-DV3^Rg_cn-UNkJ=[q9lƕwzYI7~.k$Qd 0辒 )H{;7묳NO@9A"9Q uu ޫDR?>zTWˏbM[2y#u 3c>IE]ĿK!^eF$^PI!n%ދEU mϫAAgMϏP EعUZO1g앿zY UK Q{or ]^@r 8ypU27%V]lGJݼr*R¤VJS:!֯J\]#Q3Oד\#v *9V(lӳiϪ &{R^裏W?t9a{|ު׭^k%^g}

sX%zlۓO>6u9'bA xLd5[e@'/WbítP &IvX_Om~rUz*/۞,O5CV~ ~ qUOVz t\W2>޳I%xi5홏w*-=GJ=7j O|^qy9TZ~3Bǂjꈸ\;N\ 2 [kK{%KbtNqVe&gȮǮj YJ6Oev\_6 @ @ PfP9E^\Ϛc܏<+{e 8ؼqrشXZ^7&nz8&L.[eױWQ'7!4߷\ ݳor;Epv1\(ܑ˕u?W0W(sŭr,7q۷oOioE&+xFp۝v]lÇ1Ѫ$(?0C!cEa\,[]8]8Ӌ|uOkJM{fgl =:ÂgV/I&sJj t]U.ͩDNup΋|mV03kY^QKan*='u)Jҧ:6l[\:pX5z~g{T9܊YbT{?d#+~Rυ7:M {sJRMѢ`Zh:!.&"kJj Թ(QՋc 70%UVY%L"Gm>o m=jWWTZ)-#e턞qMQ,TslU/v{ѶRKYh^yɀy7's4 d|Js;S? T6 w?YY)L(fh~i_| He'p<2P06 y䧲p޻ܹsȈdX2<($<[q~?Ht!K, jjBph .Oֺ=YVm=!Dv?Mb ?Dy,as/Ye+!ȪVq8]%D]R}\u@3L2oSP[!C/۞,OC1Wi[YB 1I䧰^{g1NAh:8U?Jdt}%ӫW/'@`( .q2$2$* ̈%a%x JߣG` oݺuQ bXIVڵk@ yfZdEu]g%xaRZ<ؽ>묳C\roT씟YFݿSpN@dto$/Ir+gFӻvmڴK?ܪ'x+&nc&<3H>@$/3"z}-T|gPu=Y+uWUǂOqDuBnr bl{ֲ>YǨ~ʧA$zS[WOVz T&'L`m~kYj`K~Z(m޽{'qVJX|gؘ('G([~{oP:H:"\j%vzFԖX\~ Ik6qCG&xyWO<7}52$it\:$N/~ʆC O|n\t(8xw9{a+yqs̽ge`=c~m]ZNovr{y UR}\Xq-UN˷/۞,ص~M\ȟ6)^; t޳mJq;ijVu!cTS󍷞 yuJ doeni5LMԠs3 ^zig@KBY{Ď_4JYsSi*`uD^PͱO^ۊ^sejQ׿6XY-rz~5!t7&秸p~8s5O>9k[:V[m1?m|(~k~yi48ic s=H\;0㏇Yꪫ:?ܒ7Zwf7ֹsgge^ (M'Kth{x#aN5˻O;nwN~:{ps1n<2:u V77{Fs{Gw6sKArؓN:yqF(!CxxCLrbϏ~E ~d2ݻyy G#g΋\kKq30C&꫇kv9/#\C㫬JϺڠkk;NUW eIx?٭a{?=z= nM7MvW@'I/NZ*xfo6׳gOWܪ'Tyj(:?)V4o=ڵ+^G{EQ W]W7ڴiS,_WJ!Jt+.+}l]jjۖԯon-%/p)JT58_?:=a=8oOױOϣt*1H /p؞/>.VVTZ~?uL麧:"\VyO JbʬX{_@Y푽*Am!/,'Zj VצLwd #8o Qۏ% @ @ P;.:Geܑ:NZe #O~ wa63Nf򣳃x@u},~Y

KKCe0Bt16VZO ?G,ADqQQn(v*-qٜdm'ybT꾷Lj裏6l$>8]n9~~к=ϩʂ íʭ'˽I~}ǂov*M5Le=j%䒑7mW]6P8~خ_~qtuVw*ޱ'J(Jo4SIW.k'va^ʫX{wb!h6#AmKPy'+TĮq=obvĎ:tHULyOHᘪ-Q9LgQ'MU#ضıF佇dҸJۼbWDzݻwx؎cdl-+y'S=¶m :Q;>K@ @ @~LT P҂.-Zg+yKKOex @ @Aôq9 @N~ (j#< 80oW ӫda) @ @ PW<墰 %v %E@SN馛B -T@ @ @@P 5 @+}nwk@`#?M8n@p @ @@@,X @ @ @ $97 @ @ @ @BC`%S@ @ @ @K @ @ @ 4 ͂L!@ @ @ @hN.yC @ @ @ ,<4 V2 @ @ @ @9 xhN @ @ @ @@@,X @ @ @ $97 @ @ @ @BC`%S@ @ @ @K @ @ @ 4 ͂L!@ @ @ @hN.yC @ @ @ ,<4 V2 @ @ @ @9 xhN @ @ @ @@@,X @ @ @ $97 @ @ @ @BC`%S@ @ @ @t#x7n\sr$o@ @ @ @@]ԩS]C]]. @ @ @ @hT?Nud@ @ @ @V 2,uujӍuu_RX@ @ @ @haZxCP.)A @ @ @HC^u5za( @ @ @ 5qCc&@ @ @ @#QcK5vA( @ @ @ 5u9A @ @ @7 3IJ?\sۗLF@`"Pi}Rt&N.]:b) PGb~n3slY'C@E xh̕08p{^,>A`q7t{gXCFi]tQ꫻y晧`{믿v'xbRuqǹaÆK.$Nho[zC|ாZBar;S9O?d.w}w?v6@@m8 *NuoFn,q 'l?o>_^P[Wf)OjS}Gym ˸Qڶm5Xm6GsOA_YfP4L!m|Ω;Q]$Oc?e]ܬΚs\N+S? sgqFHƎN>$ cKv`b!6ՋY!MuV[-5Bo|G}{!JgoNz!q_RK~8kC,&J^W4AC^Ʀ.:M]w-y@5B c,.Z>}ܾGo=I>ɥ0x` vmWve=N[l 0Z^PχvRib ,Lr$1C~! 9'#I uY[],  TOo m;A ~gU &zZyuYA8Qt]w]QE\ʨw ^{<e9>#\mJ~]!H znO?t",kA}gD sdK'~zꕑC4+9FhPWYM4PK$@Kz1J޽{vʫW(}SO=5{-Oé?.qۺ<֣0,WZi%CBL?(4B$ږ'xPgI 3 @IDAT>)cFUVYŭZN#]d2цT} c=}-LWRw @>Am{y(zꩧ7ш8}„ I~u) . d:N+yuP0L7sPd\)}'JĐN}O>ͦuw/>S)F I%yS> :UߎaYA_{%@Kzq;H[E=&A2Uy$T~VX S_zu]I DtScRS3 i+)2K( I yz[6 z˹} iDW\qV`6 2qMZ^:i0N$4ɦHo䮿>55,+# =K xi/O*b.\ !16dnl=6:eMiD>˳⋻&'r.}\X3&Y: P}ܜZәg:[o=FjJNR:{G}6/N#F+x;%V!< ;%x(V[mV]uՂB0@O|lj7*KDZyC)%xh,F[KѪUqǸ[]·%'U1Ïl: J;.Vjij9sݻgR~P~)7Yux: 3m6NuBϞ=(4}⠺y5t+bg5, BhMsΡ0~۽,C5z=R?>g:rHw98ĿǒTc#xHk&a7wkF:iy*є_/;-sxA`HzԂ)^WrhIbcV/62Q[YA6k\fC)ȕNUwӖ]Q(igAmw,NK{ bC5,*ћϞx 7f̘;nB iS+x_:a%\>K q"<2y))A ++YyƤ} $PJLJۇ:؞;5g-YJ GUmc @bDzI'FQ+N?jedpTPġv9%T*B93҂yMA&¥>U B$7=%<^K$E6 D,JW?~io/u6ͅ[nC-2"MI/aJ\ӂyꪫn\ZkCuf <\^۞EXjYBKzxEشMηv@qh^-U*m~*‚ .$i-vi'*iA/M kb^m`Npov(ޡlYj wkWU YjNaԨQ g'gQpq#fsqDocPATPu]' #a9rmrN-/:꓀}=H|сldl$m2t}⎯Rmb6\U։WmPJ fż29 @|'aTr|2AҠa-xCG ;3!.<ᄀcW-7 (Q.\iG;<7|P# TZo)q "yi뚊VpYJ4CKn<%2:WMga#|[l1{ĻsD^#wuWhbKd6#@-CzqmKcg_|q2a+U{yu &:0hTSAS,]p@5)lY?ukm  x95jZO=4C/_WaqI/ cI.zy! E>"B\Oi=ܓMϕgL: 6k҂؛M!E:BzTNS,c)*GN2zXG< ?F}€\ǎOuJ`3^{{WBOڮQj7:6b*}O_z= 3}}Ũڔ9Y+y-<\}^ :7 ^Ѷ坃_,iD mWt(v.J'r׮AWq8fVq@eP/NAWkq(Ր(בQtyH\-7t{OYȩev/;{% 0m@P׵)!E~٢2*G.q&+RPDZBe}JmYcmm<4XKC^ۦ}fmHNo9FL*W`yj$vy?p¬Fs5B'x*SvB8@w}<s$ǹX1馐7RN")B!6"hEW93p+ʯ20< @Q'q^lӂ#G: :w9KWU #VA+a~<Kz&x#FPq:Ҿ}{E%_%"I   *d3رcNnOs^ J i5;yIwꩧymT<< .n5WUBOq1AO:D4  @h>(6!`Sqz')yj7Bb#o鸝K/nslAg#7qt@ "&Pn}R;rO ԧ'уBP8-{qX-+?1tVbC`%FmS hT%!NSͺ$wZ\M~Ga$~+n͏ @MAwr !: @:AH  @%I_إ:ny yk\_ԋsKYHۥKD[ ˖UxCOC^q67 3zä&[}z*:e d{7i$׶m[7묳6Yd@*}7~s;vtZj Nڶi:6KrOk @qr.ވ0̭[΃30n\v[1d:a|W3!D `fYߺ>:0Dݻwwwyg G8p+ #2vin,EH9(o;\0a?}$}vءsS&_rhEƦ.r-Qs=zp'p[lBQ1L3cV @`&3ϸN:ɍ;Zk?`]ŎQK5]wPD|([һ"x H!{]x^~K/ B :9W2DK>,͝sXf {}he_{q. toM$񁛬6p@W +(K.T49v{ ]Ji$.luN:o r~{n!i>}Ljwy'}:t~k<]|%Rv[sPe-~n-tGue]Te]1hfy m^iW_}M 0<E@]0I:sx5J8@CP=m6!^";k$NƘ7ɓ[LyJ@=#L< ><՚Zi饗 ֜*pAkצu+br/}0}7q[WW>1S ǣW柫[h(7usu-Ew%OLonG93~F=l7hcwKk+ $On?򷝏Aopev_}3]XIV Jsos9}y:dlD i1BS=,m۶9%+o  F.B+qTP'tڵKR lYg06N2,4]W^yr 'ly'[oݖ;j Mj>v}w۔$yh <4<; r :IhTsLZ(b̘1 ^'t]mp@hQ{챇{WQ~G/bhwT{k-R_'SN;)5>𞤶/ jj*1={L5ڢ#GEY$c+D]vmX#FA/y z'zW??tLiE8L&PNF7G|V}oKOiܘ߻E}S>Ľ᧮kWs_~tw.8YmOׇͮH7#f[{@%FM3 y -Շ iFzjjߟ飲r F2O𠏒ξ!LMjK^.gpy!6HpD6-5e&'_kK2v:&7yÌ 2xK@XilN0[gT%{eȶmkw?Nr+-o-^f 0wCCN#, LDD]ɪ![#?wG_;Iq'4$),NK}OQ7]c3K欬i®FmHwzQa=Q/v^k$߬}sg,:ArP]qaA|A2ebb3N=T]Vz~_|+ yPfҤIngnԉa Q h^*4<`;C,]+D8hJ;.\EB].(, OW_};ü'6p8u@hA+Bծ1>:7`wqGd*~gɴKO^Tߧrk_~y'Q='x; 42HvȂ JԨwKXRym-z&8p`D>lSz*LGq\qo3f3z#Lmft?N7GwՁۆM;f'! q_ƽl-ܝϽiY,dQLIۯW-yXvl7l6 s~@47RMXQL {ť^=D,^ovռ?G@< y9e&wһw?7wÿF"zv}NJq} :fD.1D +yohWڗA~n]\;@byKPh<6тgNP.&dsoB۴vKyA?LAa߅݁LvN9fۃAyyx`lP+2 vu< B۞y#iY, Pa{컟tC^}?lr%ܞsy*=P$+lܾ燬A"`$Z@.£>F!*;m<3"(^22,nc^ZOA\Ã\-%x衇XM6n%plI(9A W۷oX^pGr)r2H8yWJpI'KڳD.v~&xӧHT6lqCyl}4U̡&0QPG̲.yi4'aCG#|u`G dX24״)FF`@<5M7Tԡwy$AE%T?SNw%팵ߖJ+AOW!$$dLi ޫ);MYoN୚??hS(y2Gs1'eJ$Jd4#4$SeLA*A4I俿Z>s9uu?}{_k=Y# .to)& xP=T2'. =vii2RӺK):*m @XFT||УHǦeJ zOJAQcU<>ļ| pk4 w#}aç~B}eCͻEc;~nZY BSV*o*dCaO:E ˷E 8( nET `=(NqӳclOr/uigeKf7!.qOGj ˅R ޹>ER%ldvn؀Y!]}*W5u۷iɼzչ\ #-z~ R<[x@Tk:u" ʔ)J5OzU*}}APciAu>A* ncI^|BgySǬ5ϪWpsgBS :իzpeCZ$? #FDSEeNR gHwߟh H(kR6A7 ZW^z zچ]| vU4y.SNqkPCey'm /8tnnb~Vu6ȩ_|eCJWvÜjXB6. BF-Uj и*oPgx>A@ Ph Y;􎾨AU;7c=f5͛7zˉd}'+rK$/r1]ǯPQR)6m #_7^%Π, Ɋx ~\s-Ƨ>}Uc%\zhDA[pIݫ{ 1 zt~g2bzfLr>RP`F𐑑]#<5s=v /t_K83^w,_ƺa+<:g`̡oøz17)^/7ʖ\PÂPCu}/@F>3R@=c{{vW0:3}gguQQ,:.]Y^H=_K*-@{ڋ.(WG56߀7fe@smS0EyFZl a^g A;e8V/cg̲g'>*Ã2=S: ==&3uf۸Z>8&:~͏}Y+^sVͣAf6]@tJ}}x}qn֬ tä/eGqY ԘTk`„ nok"ʢl *Vfzs,^_Y6|:k7#xـ'իmƍ)tMtm/LJ,$BIil߀HV<+c>쳨MLAaD( y+xؑ 3A|?TgI {9(s ֭.fƌL khjժBC Z<BvCAzr6L_ |Vs] N+6tYר3cA?$'uTZ @XFT#' 0PAmWt I4pY|2 hs}}w-Nbh ^δcwx\w\z  kM4kP'kCxe!AÊhxp~ (/E)/OoGq/^e> @x(mU@tAI&NzxIu mh8 e27b%- .֭[GgDn# @C6<(J\ @? @>HToа\w6Q Փī ߇hHMsnU>@rIl`}A'4&SA[a-2V+|1[s3ǭ֌%יru N'fƼߟ ԘF}_{쾻~~{Xn75o @bPLˍi4t| @O,L>`G;J{7Z^>c$C϶gDem傥 &uMt=>}=eP ?Nffӈ-  \77j4A=S=1wD$hYEBO> o۶@;w0z, ~};v\Ԡ[;g5vi.P%0~D_m5WncǎZ%hHlK{ݎ1e멷x"#>wGg 5U idN5AA{dvL~x~;SIgaV6ʻ˭0b:uTnG   !o2Qò_[ϗߴE+ׅ&@IDATb{%S$y"]VeJn8f/^$˕ȼ䆬L>Le# }0{;-QDhY?.[OtGiwT{w|w TzB7[N1YeO2qX5D#9Aa0ܗ_b3j s8ws\Їp΢ez]Ӥ]ذ.K6oHV({J' n o.ޤx6膖aG4I޵AJhh _k9% @\c7SO]#@5*WI~ 7\+%T4F2"_zYʋoPHmea4h]qFkѢ\ׯoC q@ jr(kO`d3 h;M!eH4E uV޽KeSO]8?]7]?er8~ O.Xڴizzr͛M5*V IhuN81a„>6mrA.ZNқ+~ u W _" 9/ .;3ȨtC+Zp*^%tgw>8Okh!)t>AFZtz! En2zg\S0 2*j߲u[-~w l}(2 hA4/ڿ_ 2) `PQzG^tsE\tZ'7*Q#*C#lJǾ[0&Bp+Ƽ(/euZ͑kղavǺsmgXd.It|FOɟϱp1|pmڲH1VWRIh[ʖ߷\MuJGyA`;b\ٳ'j> i${p݌(ԃ* (R͛75"(~ͯ -Zf)PgvC>(s?' x3gƠpW=k,+}vR1qL>䦢JI/564k/^W#.np3B/=.]juֵB 4jQ"7Ocq*8A%#Gt dddrSc%TA45k pZ2B'*j`R0Fޕ\%x^ Vɩx&n 䚀2U.)MYt?UR xHw~To,Pի ^ g~awomz꩑I&wYVʀ/BnA^("ml :36Yydx@vFxG)4#A6 :իM):*^@ ˆ7y哯YƪQ_XJAʟtJA Ⓩ6'geI J֧tӱ/}{A0O 2<<<ZťkT4xwAA#s߿<{$ئʔWo3V/wonA CLFePp'hXpQMO~"VV.&~#GYެi 5RT?{bnun<\ / ~yM۰i_TXhp!!4쬀R!O:՚4ibw_SC}Ϟ=wߍZVr;w PCh2MWdc…֥KHiz(ҋR6l' 23 7xeP͊;)#ߨ^zjTW>![cؾ} \DQE'=zp~YSY"TYjxiӦ7T5.o߾ָqȮ$A&@rE@,L;V-P/^%^2BwE>1e=F:&*S(pAx?z;UY,4| }QczU*pCEV@e K񎀥U#k7/jq!ܔ:Ŕ6sRCZīOSGAVp}>S:7@  Ϙ1gurWC5oGR) xko[~YAJ"!(m3 [[-(2?Yѐ6nCkVL|^}(f/Y7yD(\VCtc̲zF;2> /3Imp ˔{ߙM. ZdYkUZjQ ɍ7zh_jP'4dN^*UhVXe8wnI^_WEgӾ%b$7##侀~t S%/~g5kP>;5\T= fY0{7ݏQ@ tb.I)*@ Z@8PɒnUNm!Ov-O.tOua?@<P& evPs9'O;_^@rK`ڵa7zLTQF{   @ a h Ae8x%VX]9,@v-W_}冫.cŮuͿRS`)@vM  GU|y裏"Cs(søqPsT   vkgst7{{L]h9,@@@~|lٿx֧OQ;   ;fZcf|kE Ƈ`8yl@@@etomŊ{ZZN:    @ 𐃘l @@@@@@@Fqf/        < &B@@@@@@@ !o        9ɦ@@@@@@@@ oxg       9(@Cb)@@@@@@@ƙ        @ 𐃘l @@@@@@@Fqf/        < &B@@@@@@@(0k֬Q       @>(_|:j`@@@@@@@rǕ"       !rL        @ !_BN@@@@@@@'@C1       ^| 9@@@@@@@ s       {%@@@@@@@(x<k#       x@@@@@@@@ P9g       @ !_BN@@@@@@@'@C1       ^| 9@@@@@@@ s       {%@@@@@@@(x<k9um߶lŊXE#@@ V`ƍe+V(Q"k># n-ZJ, {` o[~uC YRSY%e~[j)ǂ   )@C nb_-\f˕ڻ-6[:Z϶g2bqZw%+[.s\ ӿdev… '[$w_͘1?|ѣGsj֭[mժU.VZnVVP!\-[6u-}v[l/VFlF[ҥZj&d%۴ivŊjժ6w_ +##5YOyfWv0m= S~s{޶;O4Y{6~x?{,, Y޳ UX 62G@X_o{+;ضmR} @]]3?-[EPFJOmkن xXn~am}mc{ⓏKN9* @陕e.UM6-qfs=*d5qE3ׯ_owq /Tzu3fLu4ڠAFo?ӧQ(EݣUQЩS'k߾}Բ߆ ;?0msֶm۴MI";M驧W^y%4׬fֳ͚gOWdkڴi2>)R$-<@-X^8S[n~!ᇻ&MDÿrJKce?.Gq|w%^ `:  )\_}ʖ(}A{۝iG׮v?v:SsV~\n7mZ+?2ovY`xB @ [de~|Mx6K䩬]t{J{}a_m ŏdA6l#ageY pmҤw}9FA`ȑ#Y2;曮WíRJT̈́hӦ[J穆}  M+sC42G>Q(O߿}wS6 oqǩLR M#c{,ݗ<=PLɐ1P|D``^h^eKٱk]14ꌠn 3N9|r!-ubn?> {ʡ#٦|1Vo-o,u\K2a +VpM[&{m VzeL@ _oSG=g !@BCC-^3naʕnXRݗVZӹ_Z Z$?W~ltXܥ/Utor-2~E;.ZaA ARmd׋#Xnw l~ c]e?<1򮇠Ow-=۞a ԰ Z϶gFKt b|`F[A`{GZSRz'!%&B TTQt=xK۶mn<=ĪYI\?oG߳ >}kL4\qI'E^۷oo;wvߩ^JofSA믿vA={ 妳;ƍ;F|0D=H=~ vMB~-[XѢETbw} { !~ /J.RGYq#]z?>`2d- {>,@Ps~_b*:,7{.Wiݺ߫|. {m߷ݻۡK5h} Tv?0j; j;@:7_[57_i;6=VDj{Y+/ϲyV/LpʶH=lӖAG26V+_'VF SPsژrNC^q~dͱSz^?a#TZ2&ʕ,n]<v7gϣ6W z#b;V$ xPGm&J;}1c=ԩBQHu t1 I.]exA)ëzmiAZ2Uҡ}.A…Z{߶wٍe=BAU`-Y4 cjK~^g:eh(Q]ݤ˼/Az`Cql}}x_4O\Aw2cm.DЃٰ в tH.\S xPC jPxw,##k׮W>FMAVŋw jVzl@(ÃR->n YL2+.\<8wO(@,U>hF^4رcM -ٝ_^z pCJ|z*H!Y3g]|qf26"^ٰaZ9D&*9ꨣ>0 /}z޶~` PÒzr!oXװ)ᆨfDEY}*/rTFd ߫h9eRV =K?:L}۽C N*zh@ O4dTtR6&;%Jsϣ o^C T hh-b30)p;"Q>K.n$uLCUQ~T4~L#>JBYK_YW:JW۹{Ȱ.:صόq ub죀 1J84. Bd[ *U,k/RXvgquiz[V/oS)U_IAF({$=/_<;^}&ī4>x㍮B1PBQN$z&v0ޘZ:˲ C$90I2+$~ {ޤ{X vӣ]t ^ʖL_6/PZ; 1n?U+S\_H}C47 q!OlXH(\-$8]T*ո̙3/wA޾!0W/HT/|K=ՀmWp5ho`p3vE}YݐCv~Y5("\4HVa1菠1DQzf|:?(cݰbuq}u>/^Xۺ)6cn*^Fm>7GgN}q6l.d68Nw̠~]zif{=\쮡+e̘NzvVk~ɗp@2s֫W/Y_Խptע6@r3ú#GnI7!Tis dx.Z\l˂L Jq.ᇬ2>fYf.A5OiUԸ@߈/AzNm4Kj0aB$+6y:?8^, ʦ/kEn_e!QF&$^Pv<+c|rϤtF ဇ{EloƖ.]>؏sJ{Uޫ$ xq,[->SN֡Ckݺ͟?eQBCXivLm軟Yƪv&Tu폨>jl љ xP]ThN_O2:(SH7as6$!$|Vs] <<ꫯ@-.-4w<1|n{'_G7 H$bBCMSvrȷًx->" i T4DCOL&~]Tg"L#@n {U:E٧44XlCnLٷ=X{駳fI%!,;g @ !ɟƟRcmN<<b~YoT(k:t<<={yn!jۇn_ԍ7Ŷ!n ۵g5Z>هuA[_γ4| XPUyO{fn:Ek[c  AH5=;250JF#`ڴi6w8Q[AjPƍjժvzݺuAk׶Ç[…04F? P0@bKv2nE W T/v o;*A4TEZ_#-[f'Gx2;P.hQ F#F Ksm-3~_TDUҽ+믿vݻw7Cٗ.BT7on-T|6vH@iBn#tON&|]Tͩ>F퓀(> @>HTorz衮n#D3TO>&|o<{9t@\ !5ARŊ3Dz U0íP*-?:qVelx㞎nmƊH(pЄ_> O@tp1֨n]MY?.g:ڐ?jA o(BAUʗ7v x:#@xG5jd Y*}TO̝)8Q=k*ZV}ygL T>H>ٻRWkʹsGҰo߾Ki A2]]^]v_%EPioų|LkƎCeP"4VʖTJ8È#\ޣ>x"#>wGg 5aU zϣ@CZTkcǿCBp뭷lMB/(Bi S .гmC_/, xu\E&&uyԎUZ޳ /  + $7$>SIY(.M0HT^kZ @"1?9&2'xB͘zmN)&tPUVֵkWw_W_YNo5iҤo*Uɯ{?!4?w&;t;v;[YtՓTҹ_s;@!DjSgw_Vx1Sp gxAPQti]0A0A߭l[fR%(\v L7J7mk_!N*>@^XQ{ ]x[no[Wǡ \PdwWv}&A @`H{jVR(P@cTϛ75"(~ͯ YEA (VfϞ|@Ɯ9s\6=k:Yfl-B>F}&7AAUVu*KFAcP*EA hU(EE^mnRvq͉x&nǼ 䚀2U.*)MQ*ҹܹsEѻJ^CA>bvCO@6m[G =ӽT|-4nUB~9#A  sZZSCKE ֻ̪ {/Z-ȴR @~ȮHu<;pfg(K7ڂ xచU]4yvپOd"W ;$*lp T(c+h V*,t2!<ػb P%KzP}Ŀ=:kUZjQ y-FŋF Z J5͜.r]Jۼ|WX2giGyq~NH?бyڗD_U5cd}@avzϣl АX ˮ^|…,߳5kX Qi&oSx,a/{W6ڑSﻠnnP/Wxw \@PRN߳z/'@ ! d*+;(  (2;(9Pr!     x^TN @Əo_}"~~p     y+@Cz7@@@@@@@rM        @  @@@@@@@r@@d        <7{C@@@@@@@ !       y@@@@@@@@ xD6       y+@Cz7@@@@@@@rM        @  @@@@@@@r@@d        <7{C@@@@@@@(0k֬.6       S|xWE@@@@@@@ wxW       D L3       ^| 9@@@@@@@ s       {%@@@@@@@(x<k#       x@@@@@@@@ P9g      w8IDAT @ !_BN@@@@@@@'@C1       ^| 9@@@@@@@ s       {%@@@@@@@(x<k#       x@@@@@@@@ P9g       @ !_BN@@@@@@@'@C1       ^| 9@@@@@@@ s       {%@@@@@@@(x<k#       x@@@@@@@@ P9g       @ !_BN@@@@@@@'@C1       ^| 9@@@@@@@ s       {%@@@@@@@(x<k#       x@@@@@@@@ P9g       @ !_BN@@@@@@@'@C1       ^> L68IENDB`django-simple-history-3.1.1/docs/screens/2_revert.png000066400000000000000000003312751423103303600226020ustar00rootroot00000000000000PNG  IHDRDQV GiCCPICC ProfileHWXS[RIhH RK E*I ĐDʲ ]D@]Uwudkc塈.lI]{}{9?%sofЩI.yY|DkJj !˥츸hewy} B=P8C A x _*+ֳ J< bLbgqgq&1^4Ov3Գ YGĮX@'81yy/xƙ1e`u-*!\ޜ[r1ࠉdʚan̊Rb}X!~+!F"Ed59g )ܘh>#S΅H\M.45Y8Sak|x2U\)EN[C$*%sƨ!fs6M3l#S+O(Qc32e{Y|^lH̍Qbg/f4#OE S׎u%Iz.iAH47NcSJĦ/X%3yE p@(``>M=x@h4) &b'DB  Q A!Ѫ. S5[!Q ~W$#ђ#: r:6Dk4a^ΰ%1J$q<ǣ5w=!ppE9SDU=,0 tᚚ3x!7M >FbA0'r4+o5|uŕRFQ)_{j;i{({eԹf32u|{ז2v;cMÚ6U4-^O#OSIkk\H~Y92qņo~!+rwu@?~MdϺAegC`~+dj'kq^00ĂD f.z`X JA9X 6*l`?h :Up `ABG1b"Έ;"aH4"H"A].G@Zu=^^ =z%Ow30532v0N3z \lr &V1bbL;&\ϼ|?l{pQ .zc4(HhTf{cqq&;&d&[LN6??lNsMEI643gg7?jk[8febrYS~KSHK6vA+{$%VXS}3[ZXLgSo-Vd;{oso;j8}s7;v8NN"jΨys11c\...2F]2iq6ƭwf'WO\&-qkq{wvAX|x-ox2<'y~Km]}'gY_oBþ  ;&tXt  ~vdgBqsbeaaIaUawí³#<#F$DFE5uO<EJ-nN8iݤ11Xˍ]{'>.?q'?w&03aOĐUI:ӒߤM2n)RMRũi䴝iSæn3sZkM?7dF#3uffH'I25|# X^+ > \8+ k]V(HT!sUّ[MݗGK;$їHN2U4S,-voEvtysܰ)(V<@^mӜs0˟:ryo[,XкzažEv/.Y%kZĬdQI7ԗjJe27-T&(;_Z^QaܾnheU^&&hzkvq=k}Wfn8W1bFFƮM6VoP%ZRƴfy͛͂͗oij|֮b;q{;w燺&;w~%յ;~:ﺺ={Vգ޽vcsKö}}??=9k9/59MCu쯻[>bxdQђCNJ ;uuf뭓SN^95Tg cg>wyM.4y^/6wvtN?Sţv<`_zbK㗻^:7pu7eo~g }SԧCyCCR `p=:NUTϦ*VU` ([అʭzb0@=_*;*Q{|-~ m pHYs%%IR$iTXtXML:com.adobe.xmp 2116 750 1iDOTw(ww (¡@IDATxT؅ED EEQQ̿k_[0 A,,PQTD nXXXsfW;-4@@@@@@@@(D        x"@@@@@@@@ q]RN@@@@@@@Dp        '@ ※       @@@@@@@@N@wI9!@@@@@@@ =       rB        @ {@@@@@@@8%@@@@@@@@@       p"K !       @@@@@@@8DpB@@@@@@@        p 8.)'       "@@@@@@@@ q]RN@@@@@@@Dp        '@ ※       @@@@@@@@N@wI9!@@@@@@@ =       rB        @ {@@@@@@@8%@@@@@@@@@Dsۮmx{7'U^j7[[*yT߸}R-IrH;eB{`߃aGuM[@@@@@@`D$VH뼽,^_T:|jl>8m4ڤ%/jӖ3^ѭ KQqߙR,i=le)&Finu@@@@@@ȉAxI-#.}"=cg!ٜQD.+!^I6&# :@DG~~"l @@@@@@`_85'ĞP_<]uYXUOoV.[n֪sd%?"F\"tȌ:d%7kꃋgI;e׮BrM~5=l9G@@@@@@ emؚCa^.I*l1жK' |!)}ܹRLw _z.      _ 8ͦQ/\; pTR5u{p9I|78Zpoȩ^:6      '@ ";Zm4qQ)ezӸrjWϯMz۱PG;姹emjlbmA|ٞad"~n^nv9SBv@D֡u2yiIY)9d>.MJ),/mL^&CUI_旖-F-G$_. ]%wvZ^-,QkBx()\X7}FN:{iߋeG?IwH fʧ3W" < Dzv_Jک!o۾Kv|9ߦlڲCsȼZzoZp7O|c'giˍg6 . Wݛ1Or67;kE׷o9\T|8mʼ r,c>}()o/-^-AL-%]r xXs2v˩6ӇJn{綋z2r/,S7|}t+t,ڲ_=qҎcIV(9)R48߮}& Vl;.*eS=˭x)d_o&!_0E^]nr!Yܶb-c5*u_>NGˤr;muoR.v=ok7m>>Kg/'kru|\M斞'zLN=}ܔY_IHk>o.c&MF|\D֌ꐛέkXtw\ݽQ29Qs3rjaۻtnHNB&Z(6#rûeβU/|-wޱ&|=>d}hVtm/;y޿݋i'|v/!@u kN15tvi^Nqk4p T,8.vHOtv '\I}y|)3luRL+:cmН3%E"G]:m!/H ['`bHs<-?:$"nu b'-.)}Q˿Hz4\q!\'{{>߉2LuߨH*ٻ>_텥מgk~/O[w9ս"CfHxJŒ1lV5;i5{`^#yR۪a)KK}<y9n;xs}* vXX DsopVgX{]44랶eI*h    ,r drOmIoM9 z69_A;ɀ1Css܅*,y9>Y; R{L-iװ) fT`ƒ52ezzX5(֩6x"\֕1`a3-1޿G D1*]&CCZ>IjU.~,ۈȰWmȼN"j[f[_ D$ DXBF;Z;3t|YZ `7p;lN"\4YE;ɨ_CG1"󗯑˟{&ɀ/YKVzނ?2v{kzI:ղ;]'cߧ׶= 5778Dz_E-X&};A)3:DJ/&5C07<LR˺եCzS'-l:[-aCI3Vp w7߽צs~W'ZjE9ӡRZEYqzM{g_ ?S׵0"ۥ'wǷ&&|84{, {ߋ@]!K+}=Y~1_z 0f6x_rl~s|zbh`)8Sߘ|8W T-WFzתTN\[̇_!2D>Ą,F>|hKWjײn O|ϩǷSf.^!#}Cr +tN+?%0{ʼ%rkCBNˮ =>$avשRAZׯ]N7>l~oɔbҴVw:$֋׾zlߡ@=woX((^ՆXߘ z}/`A>Ļ]oRV ӏZ?O-_]5:MS-/9;ɹǴM|YՐﷷ{moA6xlߎv_C .{|=AH D\qLK֧ؓ k gR8q)u@{d@ž*$wbc{5똌TwOp ӓduZ7 CR;koT'c"#:v]k`De^RR}ow-5O`eVثnaV)ǝk"fJlcZqqv~_֒„Uuz`=ᅱͳގyPv'Qqmȕ":NHHB˵݃%ǾCu}~ ksƂ=G$^ Wεg p{V[*ܵO? n6??Ckj~9J+8-s&=Y>K50G11/^?]sP"b[>{M^@@@@I:oBُfv^\{ZC휭 //s]iomh]# Ꝫ"C'bIvO]Z5*0噡C =vՑ 6M[}9O3okZ*"vC;/Dqb.a8~6کxx+R "Rhĝon_ɪpCKGu;OY"EuhTt}@m"1vJfkU6ܾ57y DtjPTg&! [:Nue,pjXh'nyRJE 3n TX? ; ,`|0Mg vh=>b|h-cݼu[H祛׶ W[+wsZV,p+<^ǾA^uwlJ\[% <344b)qy]ĶMe Qޟ٨syÝ;M,'jV,-^,C*GluGp("RĪ X(ys-zA"!ʠ;zUX!d^|>#w{.А0U).Y:cs[ *ܤuNZEцh[3P60C:m7^FZMhaN4^;UIu6kvfۖv 0|u ;ea/ᖛ:ū4`qmؠ'4t0}Efֽer]=kKMun} qjzF-aXû$qĻ(t].08v^/U 63Zhp̳Wqy-:В`,^h˿.@@@@}U(]"IN'Smm!0ܬ+ y߂ '&"a4=n6lܲ]{ku[ۗ Qx@D5ӳVxhlh ;ޓ|񾭤}lq7R "fE!ZLvVyO@jxb]S[bu8<XWYmxj^Wi ‚-^ ?OUiXM?[SHO[C~s?' Igq񮩿}oQWG5Ӫog>=o၈ֹc4byΗ:˫?Uo    ~%7@0e˴LFqy3Nh >4^Y; v['Cj۵ :;OG;%DrcRםq|?g{#FX/æ4XiDoHȲoVM!-Z_"ˆ&d;lXn+  iP`| ް,=Vp˺ Gq㫃#F+e糆[~o޸B T.w/^:i6$COӿOp--tyJM+c􆇺w@h+oȌXwVJ)[*Mw7 X8%Rje5,B`"")eF "{p+~ "VQCv V ZM"9u8ðNo5̈́;/lz޿R޳6-ұZtjS+3p n*l Mmh"D;2;8}apxx iXIUxל`)уe Ge`ۉy,F ض\H1GCה3tͰ+*G|:CS#r9 ;ߍDv99n[>5ٲ~_]N1F zKk˔%bnⅱbn    > :'۠Ce]Yη'V&gw֩bVghRt,g4wyvfy!3|㵿l3҄/4ı~sZ;YK65{pߛMt`;JbEd>iH""UoޟDr-bb͂*7xdž0q:6ߪlS[_ Dg a,5a~c;iCnX#Eh[Yx\`7TMJ%vN -څϏ:+--Ea+" `!Iyv 9/SDVK6uz_FV5w"m,Z}e{zH)݋^4Sͫ`CG509}˶eҵ뵒˿em %%c O͇w_߯Qq>>}jo$@D*{-1 V)͈G_ аHQ@D:6)7.hƢGZi    ~#ُ>DZ)#wOn=RRK$K{io=b)Y Z,bʹ^>JN算[4cemr/",qK"^:19=[6Ǜ&/Yª2< /WaGK eX}:CfZxeHK!IE i }N vols dYa2B G ^ ur_xz͖yBn;LDn~m }*hLvļ=v>[ǪMɚi$OZ$햏vn~vEc>Pڭk:"aiʕ.*0is|Xs_3+-oӭ:@D[ J<}eOx\҇n؀GY!"z}N4:uG D,]A73-}xrsﺏ:$Ow9Mco`zpHọj2@ I{Ū)\XHn{chpUoŝGNzÌ2}?`dp96MҮ}0[`̤Yp[ӎlxl,[p \^7Uv6<55v]? /qy]儶MCB'[(ouܽ7V @DJ䐡>Q=eAXA!֓^!{kR@DO bDD< ӡ+HcEs9Җ\E>\bIE^s,ybn{99n['5&D~lzxc] :FCluxE@@@e =)UnCb*%R:6U*vX5 )CfXGW'|Ψ Z\ ?N]-;G-Kmr :AѨI:aCmD=83޺0[% VnϔOi k]vkZm*V '-ThԬP,6tezaZ-@DrսYT6:\%ihAwp1CZV97ĆİJΰOENIm[J͊~a?OWG Up d7aCDF6⽖-,C(3ʵ{*ZdžszqbiAm"EvV8]s1-gusUbC=CnUpCgD D,_1d`ak]X;N` \x"ig,c%l(5b p_ʭg(mJ ,`A^[''t-mg X+DX bݦ^Ŗk{h~|ʞ!7rS!†rߑRN$fm ¶}_'-ޢ ;X(S0EN? )3 >'C*l>yǿzMv vn{#XVҡIywT}|uWW%bxBԬSxv%P&^էvz^!3@Z,bC«t?FuRv{F!L".vw^ɖ,TDw̪\pBIzǴn$_pWŪDj""@\'gTo:v-fs\AÐպ4Y/u^MGu/,U buAZE3BH@ br/UB cW!vy> D9|<$xvlXaw0m~KS!d*-@DMޱd7PEҶf`QY+Q<&pqKsÀpCjjM≝}'-❼al囒eT>q˅$X/??뚺|a/:b6~^iyEs( :a@D7     !k:=j\#TJ7%@B;aZp`]vd"{:lGr)^GuG/nWN9Y؛#^YoX gɨ KD"zSK®hg:DZ <cno[><eNSom]ZW%~G9qR*rMC@7]i'TYj\̠$![-퓗)5#5P\Q{~}Wjpg7CzuF>=dLġ$옭RUXDq]rV'y&Yz]0nY=^u@Dm3l V2TҊ|=^63XwjPS3v rzC}@?_ac,BVje7kV3٢B. :6=7ٶ=i"nU;yypz^7\xtnȫYnV%;m2}F DVQr/uw#5`Lpeߛ:K[7.0U4n "7>w@D*}f-Y)W[=,Ьv5y^^E 2_eИ9>NV;"=s4 @aN˥!rV †t{k9錌29I/ɩˀ fZ>G Yt  DO >u%Zg; o Kfv9 6_n։Pr=V aE&zdv X"l% VT*[L6ߥkIr{B ajB fv*Sy,r&x] B_\Kvc:̜-I2X+aTHͬ#a;&5qbۦry]:>^Fn-HCܿ{:oNs4o6Tu$=;HX럝彅+rҕ/dd6lD^<Dto_Mn9|;i<1xmCbj)y^4SM|ToVj[ R "y[>+4ĵ!dhKN# %=c{HG GaRؐ W=7w\"tH2#;"$h-|8^JGz[{{wV˕ϿͲNm:A^/w*Ͼ{c'ߪ)K!B0o@U(R,dXͳWqe]rr]4INNn "7JxAIȈgUh?I v))rw̐s/~&|2&yͪw.!;< K^ D85?Nuh;Æ&N žE/ \4$2-YS۴. [kFlkvW|{?>x,(Ę][.*,D:@phՉo:I!ih[fsFyX]Y#"D"s琓u"rR?,6iqIyZnR@@@r9p_aD蓛t"_f"<+~vc#>nʪŊhY ɏeSv㴷 ;m 75z~x59u%oto}b.R 55p_\+1S24T1%wn=:ǞE?\ƈK# <a3\ ¦Gjyg^H[z6l]z=([*Y>H}>з侎Dٍfj;O B1Էs-Z:Ng.^ֳ'‡ۄjCt-c:#ulh됶7ɿ{V¾UEB*D;"[-F sXPfYR!!0HW/d3U*ʲbƐ*:mիUv"l}!Rܪu2KzuzU=z[p(4Li'>99[ Oi[u\>G2nr#;e5߳{!'mJF!UYJ4;p=]ۖy޸s̟WL::]M~#V ;A#}`>e,D͆Zrsz>؎5޽FF~C!J SCޟҡ޸^U.^%Xi]XlΖ~"kV.yeCbn(mo )duvVm7VpA Iz Id[%w"aVyj! 0Lk|~ ɽ:ā-Yabm+C?DXD$&ݷJ9NeR!!yQ rS!"?w99n['Z ;v9Rd`UA'+   m Dw֧̏4@ "ԨP<* aak(\8 6UFm @IDAT ya"8ͽyҼvj۶[n{o&Ƿ, OveăGyb" Exs]cVt$ۦqJ/c >31!j[B'\'7Ϳ -Y⟅%޿zT(2x"/ w|ÕM \Dtݎ5`#C4]ʉmxvk67z^v*?;]2Qlډw>2*T*SJn~mLBn;bpڰVkyc[eWg@<ܶ] G|/#Z)ÖuzO'U~6a{b}h/_U*>tyI|C8fyoymhkׇTpՆ>x>pZM{'BEfV:&I7oTyص _Uyn^@?h0C{o eޫZe1K}zt!םЫOx|w<і :FN-lMrxU0[Bw;*;ʭs \;2q;"wLvሸ~> wXXsF2~ڜl " q&ri7C yL D$:xiuGpA: CeF@]KZP$t o{3}c|=\ /7|}0 b&"~%bCX۴\n#^;PN-'C [ ܏HC]"Svك_:{.<{U:np{`ANrsܶN@͑*GwrǧunbQwe\ W1    ꕑ1o{G݆K읽cZUbE 'YgQ\";ue,0~ b"6{ewGOY(ҵ{3],W| O 9:ora1U)Ҷ e/R=vc~~kb] gyS#[ϑJΓJM=2mY _gru[$mk4V13!k児F\5W<9zrok6'ɥDD[rzYyNgc]Sߚ?l(4',D " @@@@*`mݩy'b}@؏D0aA;:A锝5o">ztm[%;,sU{3?똾\4uT_hD D4E?m$!D y!l9iroҨVPl%+ D+sd~xҠb:-PT~煞n@:TD?2"Zi "Q_Nۖu苔=C_XյQ[ʣVӯh+ @@@@@@%@ "{ &륰Ձ)&7 :TD +`Okwj1hDzbަѡ(lhhm0jj9y뗬 \0[ʕjB8)iV%];u)lGh63 a͖9FHemqgnos^Lr`dVҸF;3vn=Gꯞ@Df*\U""_%=5XȰ+*٬!]zINrrܶ|@DvסmQŪdső0.|c\58k%NW@@@@@@D {I,֙oCjt9gx֠fyXOOޜ,US3id 8X୹KxD"N/.a0\gUaf*.ZD=}Zi1{E D(K,haƮٹ[uҤصp-R)~7/;pyp*c9v,٭Ί@DivڻҾ#6ye‚RbU$}ٹF]kzm8![ˎ\:_4(d]Oi VLe^5Wok]j/F~?~ D@@@@@@ "|Nɻg}<| DZkXѿX*DNC*e| K\Ajƛ-a3ߩARtfD|[׍8/ uV3 .x'@eQwS0Mء:̕Y;2ЂfA"ye\lxCjnΖJup>#    ԬXn;^/gF6'9{-}P">dwZLo"l656ˍ, yz=}cS\"~^ ?_/@cMr}eR\C|XᕟʚIi!Ab"ܺD:h5$] 13țᶑv'IaC|;rzRZ^=ۛe [{}w؇JI/Cu{>׉uxok,/!we1y\0( P0ɚUAM›þ=j>;Bwr-d ZRBS3nYHbIl}Gn۶/m2z7|FUp};5C+Bro@@@@GԏoWr\|ad#@~}/p9{됳u "o)ƕӥSMRE[Gfx*EFN,^jpv*Mk$ɸ9RY5^:5pY;ѽaX +߼jlC 8D.MNѡFR bSQ ^X$Ov_x4IdP07?0,9mgZ+2(( kD[>Q_"f0~Woh      +"|%j7u}aomޓK{`mrr4       _֛]&5` {T,;ߵjģ_Qb+G@@@@@@P ^=wT/e}g=۬>W ~         (YIM5K=xJ2 ]䕟ʖwzEҭz)Rx7de"       -@ "E:j4.k$IRܶ`b"rọqoO.!EvE6']bRN)Ytgww#3WN         o7Gޞe4Ue'`#oQXYAWƈwG@@@@@@wDD O}S]Y^"TjScjFT*Œwe GXEkw@@@@@@@R@D>_oZ:TƼ5)2vvj>o}\2ҡN.S~_XJk       @A (H}         V6       )@ 7       $D@DBX(       g         ae        P" R}#       @BD$"       @A (H}         V6       )@ 7       $D@DBX(       g         ae        P" R}#       @BD$"       @A (H}         V6       )@ 7       $D@DBX(       g         ae        P" R}#       @BD$"       @A (H}        (te6       g        8e        P@" "       @D$Ζ-#       @ ( xv        8[       $@ -       $N@Dl2       g        8e        P@" "       @D$Ζ-#       @ ( xv        8[       $@ -       $N@Dl2       g        8e        P@" "       @D$Ζ-#       @ ( xv        8[Kj.IE 5i -ui^[T,6qݼ5{(Ust]-٢z9aUo7[)-Z]?RD1٘!>k>V.\ߜE[h2LG ТF)Q4Y3v%aA@@@@H2RQ{n1in ۾^Q7:7ouكaFFեbYNUe-2~,r;3Buˉf;T-S{ָi2n>,,'medYBSɛ7g8/K21uhMڲY+m;$v˃?B|9~UYtf0wimRԭ*KH锢d]{^wl0\n~2\p3vEk7}x]Z./?N3B@@@@T DLk OʗSxRl:a3`nwtm'ŋ$H߾CX"?k^Z})DP|iꘖ).߰Y䈧k΍kxf\/~tS&F.>Ds'eZx oxi}pm_7xT[a~)s^- '7ԵU])\(7+#0_᣿ =^#Q@@@@@ @ @Do3 žN… yOkiX}kw+D5ѺfEvQB'0$Htso$-k_OxDZMjJѤ²~6mފD&dc .m>o߲A#`RƎ]Ì!wvkU%pc>@IڷSo*V5b_nC@D~}W8lێȨ@\+! Lb-wwǐ_4hM쾘X=J=o2v " cM wh'/sy||Z^iզ-Va(גjzir{8}mݜN=VX6^62n][sl= oV""^_1|w=xue܄s ң{@D=\bw,ӡʎ}/]s z'.sވg3nY{{gagk^Ms_~;lZ^Z@mئY+EM{k/gOIRe~?ݖY~Jj%c.eߣ]ur_=wV+^+ۮnl |7oٚ-gU( ު-E:G[6aqldcfL%%NYCDjϮBt_*Ov*{Æ_i~[@@@@H\"C?$:E'hkĴه5IP*ʮYuٓͧR Nٓ#&uWxc[e)cnyRn DXG/;".BO!Ӵ{#a"vLd\,`AH:#^/oVĪ|{q!pv/ e#]~Q~SCωk;H&qERBZ۶3Jf]MzZ7AxǑ}I;b,l#}-WZ'ۮJpG{޸}P?5dSZ:e3߬~C!::~,gKimxՑmx or?T{ ,toUٻ8G& Eņci1b '&{챢F VHT{}g.wfgvgG)->'|U޶Q9FӪMι?hpi}(K ;0s^ݳdȉ>`}BP5 χ)!"ߵs}`vt(`;wis\.ם3'D6Zu=+"{spX@%Ds-6ߘ<3|֋JFpF5>q%vnhxܶn5'du!?':pz|M9'|L/fvxцYGߵ`#|`ĄM~'?OA=u^L+&!"ؼ0PT/mKV5>N"8fnUUI8`W~.x,zsL_U-3Q}ѓͻbDvyo9O[RΣ|PW`0[B͎eqt.VA(褊!jz7Lkzl?4*\sآd ri̲aqBDQJBD)`|]hsԞ?j^rȲ>aV؏Mq^ "ܴW%N:Z_s&ȰOPmW2.BUJѿgzan,]䕳,H'#֧AN^ouֵD&ٱ̺k:`j;t>G)fIw$r&i˦䬬s٦{ט-!b'I%ީJA?NEj65w'7ˏST?%h~ >]˵uJ 'D5/ZliؽJMp~'hA󩺑%D(O"툪d ZG!34ֺ"z{`_Ú֡cg)?UIcYBDR'Iexz>#     %@BD,6͛c:*|wnSXO kp磶JkqOWa@eЕ@y~GE^[B&TKoἎa:C $xelX]|ëf5!B7OUHþ7DBap}]Mk%9nid1J?6u'U :l]Z>YUoUjmG^5y %DlJfѱ[_ܕF|\7% U'T{$5%2XA jޢnltOi+!@CMIR3}ʼn8!BD鵽WzPwCjAx/ҩ]k'K7LU-K aP߄R 75Su_~Ox+"b{(' )qB{۫qM.jv^@@@@@ 4b] 'Q-3 P  (aMO\+)BM3o$euŁ8`r9-(ٻxԺ?JA'*M+{J*P#+/!}V MkH*ݎxs/Y%ojڗB yrBAxk{dVSW?flYJd}\^͞_> a՚${_{/TкUC>EwkΧ88mMmk}V}T.w%%tF|WVԄ;r㯇@ddOߗ~GߤL{u _]k^JJ<ϖQίyԄ틺Q=N~$j:Z]"~ZPj.BB-N(4]<~5,B$RCuݩ~-QBzO'DS%wc]i|X옏Bbe<_kLZ\uog@( š%M)AMQ58pȟۼУBBm*ZXߍRp_!8nȬ{&'DûҤ9Pb@΄5_?^֔ jI(հC{Wcv'vDxNJ(dUZe 8!trqUSuҲmr̈ (YCuD)MO|*>@}z9q8=.b"JݗB f/!96,Nǖ`ͣj ۗ[ȱGnŚ'{.tos*&@%-zڿta^^mϜ=!q+5!"6{|%V-%nEOe|})RPUD_){OoHP"ՖV}M$T3$Dh%TPu?]!"{/ k|ܔKr]wJP҆Z\αtNm.2℈|ԓdǬ~jJ)qj kȰzfUh۲[$N_Tƚ?oыDą8+`; >JSq^t/QEe-JUa(yFsV+ߖܠy>+b֋-)D (IB߄:ϗq4@@@@@>!~U^O e=ݩ`UM kijWǛk ז9_A℈tյBXe8ǰW6)_bє^`Q@a88Oc?ݟmĶ5孋I(u_%D u/Vi5(3a y:.vKχậ6)9n]q$D*WbQjBD]AUP*h?Byqwq־Z\f? /q|Wtu?-*>`u}OH6.{<8}7Ù ^P@VjUP_7t|k&D`ݣU9 _"J2*DhxأjϜ= .-_BDC\wZoyQ>*Sv?{i>KTPRñ}e,"NZPa Mv0O'fUyBjkUC$D;,`S* EUT+nйdܸ.I7[d>u?[O_C5PBD+n3uGWg@@@@@!H?'UY*D=}YmOݝDžCjP΄Q=97zbѶޯݾν Aڅ>9_ry| xϗa+Q.E  4 )AѓP b%_BD\?H"ݸit+$V+49(ʑQkP>8>nVMƆӏlyG[sR9!BT7[* q79cy(}J#&[l&DV .Og/}Tj9Yk5k uw4:MAG.%D4uWWRS[ j%J ^J4%DĕEZ 4~7 u7TjnTԶA5Ay}#R/-ߍGC$DNXXWj_K_W&DlSklQ|wJVo~OB    K %&DF8qn1ʙX@ȶ/Iݦ&ȢߟTlzYuIkXLv껂gܞjM5=M䐗yQ(٤"+/qxrk:O*q[޳=WR),C OE&KhPrD]*Dz|)aq֝DX@͋9]GCmhqZ|nbOqW&3X<0[ӿ_E%Y%ާ2;=We+v=q:PiiUk45Oc T A! mmנK+Z\$>~i҅%=<,,*LcлGk. Y~Z}C[Aj*h\3p͓y}ҢU} ;n첱kjQp/}[ ZBOnt`< LTMZ$DS ZH?y^aC$DGچk}>aͮ-};vC\w-qr g-!"Dv9yfīk 8nۑ=0`X}W&ΪY6.!"n;0թ]kקR-p'NO%ȼWw%D(>LWĿqIYjBPZ+ A@@@@XRH(1!B2VyYe'NK+AU5=̗_S?Tyqغ ?JU#암/HR@BOsѵuO5&uru뼷_~5fAnaznoQS@]]XR%htW VHL+|.2]{nM9nL BֵU1 B3^za~?T9OHоoCn,:mYѹ.Vrk(C#-!BK1\rtTeBi5MoiV+Y"4ϮwIo_!<ԨxA@@@@%T:$Dt_m)ЫB5_=7 Og곚8O/W|&MGu~z' *0c4YK2Y 6v,6 M`w~>>@IDAT1!"R+n^rul&uȻa7iſm4%+ 6޳׶}QDwĵ% ^t-Yo{B-*.c}ְŕ]4^v-Z:_5hEKZ|,'5G×}%_B*YATn2Ӿq2&]}5Mjiv֡$ KѸt˽GHW :Na24.n'D %:}DZ>VX\&&D>Qm7߆|ˏ7DBtj}{D)l䫌,G |kD %.uͣ\c|/!bUTշja5qV8ktM* Suۅy>sHFwqͫٺb"[-<6yZTf'n4ִ 5mž[ [(%!B/Kӟ>\wqo~/a]"BS[U# {\AU%}E=;qz\+s_T`m_ڂzʧDVDQ9,z?lC= N޳v($>a={j* hZ%qֿG([oj:u'ԟt]򜿎ǮԄ-3zڧ͚K'DhY4'rw=^:ݸkR׵uKc» *2員&qP۾G']56\ J.*c͎u:DtZ9`V-ilںQא'& mgho9l\l_CעU@O'h|/t~Nfki2,[Noy>Z9'aL^w|_'k^Ѫ7%ܷk oP9x #|G.g KU'K,_ajV~uUu_Z!꺡`o +opԝͬ.-Ta~*E5tJt58ZNPwxpjޠ*J1o֪mzWO ]%|hͫĚ^ tyc݋(q"]CPIjV     IQ&D.,F [r \gzY*iVüjnMW[On{/u_}U& _u+бMKߝgfG%ujeխ TJ]ID ixO¹` [*yt+X^l+ܭ⟔w`Au)!b{P39IVYTdӵV ׁh4U]A jrbO$_qEnrT۪mmIMk+(D|o}Jlө},+!Js~+UԕM}hXzuKwx J,WSV +S$Zצ$u}BQL<]۷ DtQ %%"ly*f̝ᵽk*ۯOP" P=ﺉkB3}8 wcghOڶX>2cQEǖ]A@@@@ !){       @ !џ        P}$DT1e@@@@@@@h$D4S@@@@@@@O;       ^F        @ Q}ǔ=B@@@@@@@ O@@@@@@@>"G       4z")       '@BDS@@@@@@@F/@BD?@@@@@@@@Hc!       Hh       T wL#@@@@@@@ @@@@@@@ !){       @Xfo^@@@@@@@*"p3        "8@@@@@@@@HC!               Pu$DT!e@@@@@@@@@@@@@@@:"C        @B       T UwH!@@@@@@@ !s@@@@@@@N;       9       U'@BDRv@@@@@@@H@@@@@@@@ !);       $Dp        @ QuB@@@@@@@"8@@@@@@@@HC!               Pu$DT!e@@@@@@@@@@@@@@@:"C        @B       T UwH!@@@@@@@ !s@@@@@@@N;       9       U'@BDRv@@@@@@@H@@@@@@@@ !);       $Dp       TT`nԩn֬Y?w_~o… +ղeY5mԵhµmֵouuСZvNABDؘ @@@@@@@s1cƸ &2+Qe˖[nW^nWRHXz[      , ,p#FpG^ⷵ7wnM7u͛77"r8       P.cǺaÆo\d9h֬߿ٳg=JBsR@@@@@@@`x -Uz뭗Э+fQ>K       ~i7i$,`.],[XM#!,@@@@@@@FdT"s-E@@@@@@hXOUs$Ddr"      "0vX7dȐRfa%D`mu={\B|ABD,Y??^xk7?趰r@@@@@@%`w=oqxmf!7o^%{h7mBķ~>䓼seqm۶wa5ʍ7-r!W^N"-[wokٲ+'ZN>ݽnnnW^MG@@@@@K2Y]g4ڄ{=裵.l0oCuM6u&*[~AfJ符%\1c3g… #w1Ǹ'      ̝;}? (&+my?HG@qtG}ׯ_<~~饗s=?堊 r^&MMpDj'xbKPE.2W_ϭ{6m47qD׮];쭥qnСyw4@@@@@@*.0|Pɼ+feP7߼"5ƍ7ޘ ?CnaU8]3iL;ԍ5\S]?3B%-DSO=խj9˜2eJHPr4Kr//Cպ~_~uuu٭JNӐP@@@@@_e˖%Q !">Fk ?ƣr%D<!DmS_*YMU"noѪneMZU[BDU"!!"Ka      @Oyʯ56oiC5޵j*^ǍjyDyh˻oz6}ulI$,"tA( @M]5ˆ|m…Q%>nڭ:O>nWϙMPzI3AƗ^z)\x㍐}4o<שS':AV{w݌34D 0qDmtMÉ[oٵ quֵkWkׂs{4'x` No#եŸusv O?#{o.]~c\RWC qSN ۦPU kd Nc5U裏B.J8ˋkTD7x ut.r5q+%o uTwl$DSe!     ʱDUWK Y tk׮]#N8cԓ'OvqBO~$Y믿ԯ aSU^-fΜrϵ}P̬Yfk:ᩧ (Χd5-K۬k %Dh[4i[z0{2x #KbxoyWoY3/ ,nƢ*y>CwW8YM4?ϓZ> *HYO\pAdrͽ.O>u-f~߆Nn B[S0~rjݻww~zIqZ\UB.Kwܚk:묜Y~__Kvi{4^)H}jJXvZ9M2kzvW:v;%軆g5%|YÏQKL1+#;찃;szaa.NIOSw"Jcz@@@@@@`*3!"t0bg}BO>q>bnkUP"젇0\/5KP< v?OCPr5Kȗ8b]>̺CBD J׿b>VPXKtʔ)I*0\xɬgqFЦMw饗&ó>(C|UI3?$حԾ.MT^E7Y[o:-\F>tj-/妓4ğmUAKiW_}3fL?%ĭ u==ڶLJrT5eAIx)YAiCOEN爚n%D"eiY. 8A[†=0]bnAk5ΝvS?QccbNBD}@@@@@huՠjLiBTzCìZ{%D(AU%Ԟyk@<8!_B⥚Gpf m9 / lMz9 a2(jh$D`~1"t(` BA-vuR}!ඪH]!(%?vj}Qcz>N0u-,́$X}WR`MlW0Q݂6Pw~!nwU= kG٪ f>nJi݆nᆜY˕Qc 0aBRB5t))š+:thJ6hڬ B :2dwuW5O4]~Mn4>G~;ĽB֥YtշQ_AG@@@@@/`XjLuJ/jKPEu-_11Mz PPqv-TϪHa(ϟb}޼y;Zu)ƬFB?%DD>YTD*>O +' -'V(kJ]Yffd|$@?Y4Lڰa-Fp9}vik=ܬE$ʑaY3MVAvma̵^TJЀr%Dj;믇sB'Ch5;qzoB %j!nYݘHOibUbzY1BB]VXȑ#0m B~T? mc]>}z PKmOzu_wU~n4qiDС}EW3!B`*ƩKfL#!Ԫ6(&_9qw'/=]}"c%ۤjIG-QK'39Q5 ;wĩfe釦 2?      P~JmKMrZs5s+6bĈM#!B*+!+!"k U|P3w}ײQv.PSRիW,JZh۶mtn7XVSU[/|~I'LV l{i9_uG2j^zi@PVUB[[|y晅U{BD9>9>=أV׬ ,.]f{!yAccY0g:Ns$L,;'7pCwgMR0"Jbb@@@@@@" eF*Dsn:TxW x:}-!b}u+B/UPvſԴ=^O>q>`ʮ~̙3I.3F-(NP W]YXӁ{37tSѴo͒}aJ?kD +JW\]vnu׵e7}U(%حW{BD9(D*ju]CA3„$Dڃ>p 9o$_mٳ]Νg /~<[{~du@BD]@@@@@h8[o5'Z5AbTHoEj7(1{IORkB;+iG*:YB*ȏ5* [o98CO?u<Ap~Y^5.3`.W% 3Y#plMw:ujΰbb*X W^ IbI&6ʝ{tqE|HwPJ[/gB.p% ҆0rtQcbРA!9O睒WTD>jIPV@JB+gZlZ)Ygf͚t~"NBD@@@@@h0{7'V3!sZ z衜M;C׃ͷv[8}BD޽[o{Bl-^WE:!_BD;6C!\,QDBԴiӜƠ%_~yuZQn2.천}>ӜloyLu*B /;vlҝJ H$D~N%wG\r8qkӦKmgL}  @@@@@@*9rdV^ݲ$)ΕntUAq85%.amuga=SnZ!B3(ՔtmrWXa0,;b"T]V%sl&/I(!!bĈʓ1X'6mꔰP%@)Я駟ԣG`Y|=t{WztQK vk0akѢꪫNȶ7+~g~vtQrҶ<.Y4MVrձ1j[o-V bȐ!gk YjY,Qeuq$D|F@@@@@S`:Bn}uzp<_B鯴JhiݪX|dsE~B -?(YsC'Āt5uJq3gNpY`Acڷot< |&8ޡ2ݴ[na@ 5Yǭ> SLI*v)^b=L#L)!!"a      PJLP%+ Us(v^zӉX~ѲeKwWju "JH={SetB̙39)aA iZj.^{`u Km[oս+!Я.DҔQu)4T/4h[ǸY%!( =ަ+"qL-A Jx w7g}ܹsΤ]w9nRNĚN:$߆<*Դ5%F(kZ) ;      PÇ̫{/݀TQBBpG-2Pwnv '>}䌏2ʬ6ʳ\~QIU8lO?4$k(%{kҤI4eԩN7%RݚGq"QB^z)g5tڎtIo > RA;SNej?7P<*NаSuol8ӟ71Òq 7,6*(AYkկi5[F:!B>{ > 5jTgGlR"Lw@@@@@@ΝFwzSji$DnOu?7ؕ0./T@ ƌMaWAxSO=} %Hn:p;%$=3sвԭ/C82|<#Gt7tS]Q7lٵwyIY%_?IP2;nb0`;kλ_e]fdĉ_g%ò"{LUhȐ!a;\Nu~N(AEM 80|_t,?sk׮PB&ϪbY)⩧r=P,3u;.GfȲg""|G@@@@@W_tKsW{d߳޽{;=\]M",Ȯ@W_CTU@n5խò.\WنÏ=EYT?|/Pϧe+ȯDkڠk{)Bu-Z)nONU>wz~$E+AqJ 7;nJ2[d)Bi[u\OP } >dth;]G~8]tENM <@2X݋x^z\#jKP%\f͚,G딥9)g 6H#$D      @ (v=t\:PC9$=V^x.WO}TN`nҤIe˖n*jŜ}_ ^r{Ϻ>-nww]v-yr>2PA[nu)Ӧ@@@@@h  ~|G[e]6s|5G@IDATA@@@@pFꪫ-Z$I ͛|Kݎ;|_?!@í.U"x,"cW_O>}wa kB2EȥoU :~xY&ԇR(wwM7 [}IOW8%T\(e]S BV~d{mcl=6mZۻVXadX`fm¶+ֺ>j]aMP9sxWT% b@@@@@R'D<NA;֝p NX 3/m/} |''g$#SJ-7xs*y3ۗxJ9$D,ǧC+t,Jڶ$D-] j&sVTrOc;Y     P-$Dd$D:)Щv׺>Ν]vG?+.\6x0̟?ߍ5*$T C\¦/M4)T>PYZ+g}ҥK(m}a{O?0P!BOF6oeOƏ:uUh=s\s\Dkͫumva1[nӾ\L^{mkeY&LRk߳M6umڴI%e(qFѽ{w+JdzgvS[}nv }?q8+2Yl5ҁ m֣'ux^:Դ.%M۬m6m~;CBĿ0KvyXh_eluN/kݺu{= .묳[mҋ>unUYo;sIS:_tPR\OV[irLtj9j*g⋂Mg6}о>(@oʩҐצSpOY+e\=<!g]7:utaͷmϺiRlvn61lҤmF/[tE龨Bt E>]rxw]vSb֭{~)]tQ~l8s-g}1=[6|jɺj|m)M7U0aBұ^}sk-~,>#    O< m6H[ *r 78=կ`V3< 6,(I?{%XI8)$Ozڂ ?o»ʜ+馛 (뮻ƃ% 80þMW^$az/e쳒lW JP-!B{!pk|?,/wJP;#ݘ1c\Ν݃>h%o}(%W]uU5nZ8b'?vaԠA¹`I 6箿6?[ H۩`ݫֹsfwN͘1G? ߳"?$t[q18t,d *I:uYdMJ(4/o|mḥJ9΅"_:$۩scr?wKu>|+ظv)TǺddGKR9j?K:4fB裏+k Vq1pns9'|W%\Vl'󝿥>!﯅kltQӹkZZnTYJ>i-.][o{ +uk_ۑAب:"mw-a5݃uֽ%>FAͣRGN~w5Z1)V)m׿e»\/ob9      @Hȓ1dwi` 4[`UAIo| N<9,w!oGMs=᫖ 6qIR j⁚ Μ93|׋ӪF/~𤹞WӓZQ 32455LT"mlˍUCZ Nwic=B†Jڽӓz_Z0C=̠'UAM/ jƍ }ꩧBE M<#ajrUbJ21KJqjKon,!BꫯV*;(EEu]+yG qcصLﲎ/Z9-g%YqBF)9_@Y=>kV F:T=䫯r[ou؍#8"$D>Yb ޕ\Ucˎߵo:'r4ԵY}v*1dzm̺ ^.[/~okc@Z)ۯ]ƫ : !jimH~w{[KZ(Wnza=v}NמeJ,K:JW%h?{qe6Oϒv,іU}ʦL}W2*qyEse     PFhot^Z)>O'$0OHB-? З}7+$ ÇIdG4 lmޭ1"mBw2?Ś 7Hr&e N}-|dR9f/d{OS }p#|@k.r!a$ًO b>$l҅>!"YOH* p-'%r->l/Pۯuhk>%Y>kwdy{a+cjޒcI |o瞛lqkzB2WHS2NǵPi]OӦMK&;z}+l,[tJ8_ 뮻.~1DŽv~jOdHղKסlV㓵. [}rB6KնڷBɬ}a e\۶׍9va磶QZ[-+~okmǰv|%d7tẗX=o(clYJ]{^ma|s.S)b OI2|u8d\QN{JOi*=lguV2\l     P6Z,T`Q= oֿo8`'#a3.?+i(ș?վN Y@WH-4՚?ٚ/*9#O}Y`-_cςPS`^Vq $NzY\X|H:utMMPf8hBv,`PƵmF_?kYoO{C_k;nf {Bʼn\oe´̬{u}I|pV9cǎMPݻwwÆ s'xb޺P~xwC[cidjBw':>`Y o . gzҷoQ^h=9gWj1>pn{}ʉ?-U\Ӷa׹OǓ6o0?{ə R9WnRsVwOaKdFE[~ya?K2eJrG}w`VTǏ MitQĂ;6"5aA1X@TL vQ#F  w2{ee<{N9s3sgx|9m:kyرa}Yxh]Pm_tu/5L{-N9}2~L5m\m~7K/4?iO,ji!3۲z?4lz.j m8x}b}a M~mף*Sb6aal}{al^m.'ާy!kNjݦkkVsniKUQeL      [{W4ݵkW&MrmX[5ldW86ִ5j(8M6KDGy'\t3A6Zc"_#: 0)g1ǜY+̎0ԩSXǿ9 [N /y()EAD%z(|:?,SƋGC-]A$LVz#0o?'(a@I7'pB"BlSbPfwJPRI@7nW^yeHɬӾ^{-Z(yP9rocѵc>7,~c r-i׷o߰fi? x0 jma|r]kŜl) fPv'ͫiBm×DIJBc=B]Z*SS$0M%D(Nq[nRn!wktH._p5WO ݺuZgǎ[ 1CpŖ[]  F+JPa"_w|ӂQcF-ם{q{衇@zP8WQR*~Ȓb vkRn#L* ضɷߩSҏv9묳B62bĈbѧzxgC/J=D<(P׳gO7n8.4od rwuׅ(ą9[@U5l0:^Mįܗ]v{]=!z%ӛJR{U \Z1'۾ kнP;ulَմqUgBSmwu_CkCgKvv/ϖtpB Q"ٷ"?R%ŗk%Ou]aq6ʖau'7Zk=/L$%}"ɶ     P@Qk|饗15vxSOE^*mX+-KEomƘ=ܿ]\hx=eO7öC T1Irk4joFǷxh~3fDDho~ew٦IΡMKrmk'FMfΜy3P|BZpՓm}?0jOV ]icRwepګ,lMTz.k!}ʮ[iUW_p'      hMIKં-?$&<#XV ֏^ wy',; 7(+d~XݶZ: i39:S>Rc;~h~ |h{3 I+,Ϝ/@cZ[o&wQ_i{{>ZkO Zi{գe",[u,7|sԆ ]<J dx|7Ѣ~$&?y1DT:|lnjڸPn$D}>ks@%Dw7 ^]I"ٷPՄs]/J.ߒ&j/B8-+9-!"}ʞ'g>9xlǖy/ @@@@@j !"tVo}gyO.'OV`XE `t7xc %d{'U\%a/RbzPL=2XM-V[fsLu }~ԶK/tzPQ ޴57 u\v q*u,j-Sڏx:MPcm^Mj[M';\k^mU﯅agKбlߛz}9ߓBJ[Z/zJ[TxBҹ=ku N5w>%DOş#G{];:xuP:~&L#    TA`m[HeI|zu%L87º?⇷pw>f7oܵj03t>O9Xi۶ 8p`ǿ AEwC" 70,ǿ|xXEd'\~\s=ñi.(ls+s~st%8xd1i$׸qP}|;CN~>X阵*^P]F6i_5kV;k&,77x`[oڟ2d-NiQ{:ӵ^xo-hw;~MvuN }۵n:w,>hthvڹ:j.n;g4_~`¼+a:۟5m>:V9՚k|aÆQiγ~^]w]燰 )azs~Tk5_ͿW\I7`F´|''Z)DultuYj귙>^W١Ý'L~:ѣG>}op}U{^ⓩo_ v~Xܘ0a6Y[M6-34ndRSbO ѿh?lӿI;8|r=CXUᾜzꙪkBWI=;+gO, VmD@@@@(^^&D7 '$DV>oĆm;s6/6-دviWd]uu)QDYǿbPmc12*%JwoZh#~'*-|$\Ůiw_N,H(`kBIϳ=3Y~]:gJLJf~n7Մo%蘒m>iCp߫WO,.KPQٷ=83R6iBVI%-j|NQUbsi"To}/^٨sfEJX{mVuHlT!_@@@@@ ˄"ոP=C\׮]]&Mr֬Ӏg,D 9s›ꅢ):SYIzY7z[U)Wp;-[Vn%n(`k%[%Y=5Sݳ8>}SHd}r~jYb2.miϧZ:~wگzU_J%6j5q=UoQFݳ|BI~+CD)%)1F.>}"@@@@@ !x;D@(3%Ll$~!8(     uSy^9*@@zy70G^JOw$     @] !.M@@ /N;hY-?hh#&@@@@@J:]4@@S15j6l37x`׬Ybb@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+D@@@@@@@2 !LND@@@@@@@$D$bM@@@@@@@("DL@@@@@@@H.@BDr+DM`޼ynΜ9nnҥV/! P 4p͛7wmڴqZ@@@@1"j@3fps; b֭[]4@@@@@$Dς)@,u1i۰a/;@@X%K8l4sLl2GRDl     !ꭀIӧOwJѣkڴi@O`Iݺuc;@@@@z'@BD;0,iӦuٵmve5" Pٳg}Zhw^t=l    BR(@ nҥW^a2$@Q@gL25hmx@@@@ ds r|Ѐ޽{܆w@ xl    % ! |9v@<;#A@@@@ Q0LJFR94@ <ϪǦ     PR"J@> @gcGϳs.9@@@@ u s| Pk ՚SAC@ xl    % ! |9v@<;#A@@@@ Q0LJFR94@ <ϪǦ     PR"J@> @gcGϳs.9@@@@ u s| Pk ՚SAC@ xl    % ! ~}aF֭)@Z/֟"    ? H:H;w[]&MJ/*^|y,XK׬Y3׮]"ka3@n Tn qt     P[H-gcn57vM6%-@ ?ߍ;D^ܨQn+ڔqGz͘1#=3݈#ʅv"%(yVF3@@@@@ Q eܧ~-Zu5jT * &Lv_9r4"P }Wo߾nԩAd-t۷w={v7d7|Z){#p^xaF/?Cꪫ={FWzO^i$DT  PIY     H*@+ Bom[Ҫj۶;sN;d.r>hh/ eshh@4h7nԩ?~SBҥKݥ^%<䓮_~Ncucƌq'|kk] O?UVYs1t$DSE@<%M     PH(+xyzyW,B%<(n-LɓG}:wѲROWrs ꪫvmWM/cĉ i0QiHbm ~&Y <8 iM@C@XcJ7wЬYJ˾kioV[mJ>_-"9sӟB"Ǿ-[tݻwI~v*Om?egctMO>qk[wuzx饗B6l3K@!!"#@}H谟.]8'E= :4>4 Xpig!    +K">T2?Fg-oԨ4ikذ4_o^[QP _3C֭mQTSCsh]74iRi}Q;ޔ[ZWo[~iw=YM6 Aе^u))@oo)a"^=cƌl 7t6^ӂ*S&p"?VuxnaDo}WTTD-r N[YAc͛wQCX5jT0Лz\رc]ƍ+$H@: 7;8wg݇7SOECi"?3\ ¾5!^JBP2~Ͽ… CO3ZW^ᷮ ^z{N+s9X2t\=X:k0m  %iZ W;찃&"1sϳz#    J !]w͟??l9dȐW ijVP]ojuYaU%" |+ )a@e[J6Ž߼ysݭD C*ѧFڴi-yBʿ/NoTkH U>r0قᄏ %WQSJZcW=[뮻zYѴ9U{hM(7ӕ,{G=y\r%!x۷o(B ?cH:S *aEo[ѹqPH#6w7lذ˵/%hqƹa4 J@رcku'|rVRQV´z|0`@-X=e]r^]Tec=֍3&ԩdKP2%av'tRX]:*$]^^. j4D %eV񇄈0FbigMD@@@@J*@BDJn `A?mz}USz|PyO ިVي[ Blݺ o"ꪐag~* U Ν;5{kHϳiKА#WQW[ѐ& 0+ʄ ›"XBzVH0 > YB9rJJ:*6!B\~sFIadƏ%%aE=ШG稤I=:ۍz{ml؜ɓ'(,!BIMfk뫗 6߭I IX곀s{]8v@@@@("R$ ͠%90 6OzZ=#p  BG+ , U0^*VwoĉamyPAE BjHG"2y} =0!>|c=VH[BV,- =6xN (*:F޲e0YQHyle.A%h;%EXsL@=qh n&C)6޼jm}>($6d 5CzxQ҅ R%A~aں[y?i".pҰJzf5kf'%DFRY >[q     rHH韙Ϯ@TA]ثFzpP}^4|zM'D(IBl!tRmvaPÂ%/O>}z׿;~F=1C6lナWM76|s§zuyoovmQ_t+ kv@q+ԯDJJ͕BE@ @n{7ޘu/Jd~i:7Vt?k_~NnlJ׆駟BbW_^zh>}^i%MBڇգOD2!!"4 P:ϳҵ=!    HQ[fP?Wм:zPR׫(yazɒ%Q@ӦMoŠٳg*AB=CXIqFoXm! u`+!Bl4Li4;kɕh_3lM?#SNtC Ë/?Q6a|֔@N9~6DcT>(A=wxɖ!R2HSu/2eJtk׮a:MB=*hΜ9GUz~ !"(@Y@@@@@HHy)ꭨ [ϾF}J(P Kd A 8Н~镒lO/ŋѣG; ` 9C6!Bom[0Vǡ㱢a)ԫ";8o}s}Ea;&M C<"LϚH@unP7Y476h#'-B(A;p~xُ?%WYC+œ*b={].]* ͛늊{J !nTI/ wBBD Prϳ7"    ? QĥÖJp8C+2{(@ S J\*<?"sO7߄UrJMv}[pa=AH+xroӦMꄈK/)P!;l#FpJkJ 4lz`uo bQB9âkZM(#P%Dg~գ~OFř9s3fLH~P3W^y% =U(alwg[(s IDAT8:~hCkh%g Ct׆vh~_~:OBD` Prbg%o$;D@@@@ E\g}vS@^VZ|Ws='6ljV0J|ASECDg¶է?kڣ bEEE*ޚUW]zP@SI*Z߂O6PB%7ӊJP95m6!)444CfO swkƪ C(Ê:ƻ+d\xH|XP-)T@1Yfz+4CU۷ VJ`g{ꫯfWݗܶߕ$}lO>ep1Ԡ|p߭mJ~m#<2Lov!iN LJ cM6 )aIe!L$DH^Y[@@@@@"4 ,`!pTwV,AH7J2bۼ z?Pp^ 7]?Sot={TOH6lUW=BI+JP;cքީ ˡ^"7'J߿O>eu-ZT_Z֭[ݿdx'6daJ*OhaСnMBթ4ū.a=D6Gi K|%|hh aEÁqe+psk3t s{B     H"<UaȐ!Q=W5;=XBWJUW]w[s,9bܹ!HhIHAѱcfMr%XU3.0"qxݽ6?SI2D~ (qeĉw aFe/|UbE|h nO^ih>au@ )AC(IzSIJJl5n=֨M-E==(]vI6Jll7%--Yu%oRbz%Vd3>OXFhaX<[G@@@@@Q3!.Ѕ{V;,7j(.@ Njp#rnin%U("IYpa8^$W%'|WOǓYd[ٖg_ި|>Bu *\F@@@@ Qtwy;#'`sV|= @<F*A@@@@QdvH @]yV"ǀ    @ !~gjZph @xU @@@@@D$D @ϳp9@@@@ 3עE ׽{*a+@@@@@D$D sӧOwGiӦ  P6 ,p[l֭kժUٴ"    @#F@IDATyMcØX IIRZB="-$I,)RII m b,Xƌ|u3w{,̝y}{4ܳ|9sg}ϷQW ظq$''Kҥ%::Z"""$44􄜛   III[JVVDFFJlll^@@@@@ "qB9 V( @M0D=1@@@@-@ d?N~vݒ&'*8- BBB$,,LUf*>=@@@@@!@ h<@@@@@@@ P@Db        E9p        P" @@@@@@@@h(ρ@@@@@@@@ Qt       EC@Dx\       Ĥ+@@@@@@@("s*@@@@@@@@D &]!       @ Q4W       (@ 1 @@@@@@@ @@@@@@@(@IW        P4DU        @ (@LB@@@@@@@!P)))EC@@@@@@@@ DDD Dn       [rc        %BD?0@@@@@@@| m        d"q        [@o#@@@@@@@@  dE@@@@@@@"|        {`\.        ۈ=@@@@@@@@ Dr@@@@@@@@F       A&@ "       D6b@@@@@@@2A\@@@@@@@-@ ·{        @ "       o@@@@@@@L@D=0.@@@@@@@| m        d"q        [@o#@@@@@@@@  dE@@@@@@@"|        {`\.        ۈ=@@@@@@@@ Dr@@@@@@@@F P@))){nIKKn@HHIj$""(_*׆     L@D1{ PT6n(E )'L@@@@@!@0Dҥ%::T  -r/!Zd֭%"N3 @@@@@()"Jʓ>@$6l C4iD*VxӞLtYv E4hЀ3N     %As HHHTu$%%IbbKFq       PD{-99>Fϭi={]UV5M尰0)[lnضw^9rHѡ+,}̞:@ʕ8](˖-L(;;Կ"ZmW!      Bhׁ|g2e8W˗]wej/{Z7o<f.\h/2ok{'k5ꪫd۶mfG}Twyoذa2as|˖-e̘1y(N6m^(!p      Ln-t@aÆҵkW~wٳ}sΕz DtIveK 7ߔɓ'k&aX@@C`@@@@@ S@Dt"]nZ "UTq{?7n,ǏؓcA"i/?zt['B; KV<.      P " DhB4p!?dݺuvw y^'|"4֪UK4  Wx N,=y(O{@@@@@@ 8D99wyv/~{ Dh06l`)!ԩ#͛75jػhbGu+VCJ͚5֎G1^ZBBBD+IKv}۷lkҤ/"ҪU+[}\VV,_~ZlY>))Inj###~XdXB֮]+ 40c5oSfZʄLt?J*Y]l$&&J yTR>+Ҕ߾}4mTtOM-֬Y#=vi<զL2rg|qʌ޽{Kjj9_~i(I ^<6b@@@@@(yp-9>kβ{nyG%>>>Ywn"S;묳dft]C֨Q#SvٖoVΝ;˜9sm֭ h@}O?Ԟbܸq;mv:uj@ s;v %{oN:Oʵ^̙3erae*jXag K. f;F o~͛'O? X=SR|yZC"ZcƌnHp$u3,}u:@0^R?'B@@@@@ ȃ3^!B9r= j=zi5?|AѰ@Ν;+1pou#\pVL3\\ְߞ@OkY ,H bҥޚSh g }nlotUx ߗ{뾺[ _??cو@q`=%IQ^X@[O{I)R+Dyۍ     {yxľ}⋦p3NZUA4ȠUe֬YvիBCCJ*f`^h ~XtA! бcGg BC/h_~Y6olvװC~*DhuRdu-V#@V 38V>i%.]d D^_}U̾ZAU ]Vٸ__T\ x N2`4g B+Xקp4J%iW>2kJ:5-VK5gpͼ2j9zye%     ]@D@V)xMV "%%EV<6Q\9i۶ٮ/YĬOAZL]vC=$7|ȏ?h5O뮻{5-_\&L rJz?z_ڴ?l@^Z5j /U35mM"4HShjFfyYFnN|G&.F!Fڟ"iE fݟyJ%v|@|]y<<3[=\:h(Y%{ɔ~7KDXlGˍC&*HT+ڞ& W-SWiu@@@@@J<>Tvi8+ygU!3Wr3N>N 'O-Z˭W?Y dXkItlْ$?K:5]/ ~I 7˺     @I '+ƎkzJZq@Z`ݺu x D$$$W`ða<-;_r%:5\S`g5 rh5 :c…;u$v2kͲ3`6~>V`[ B'gb SL1a{GyĔ *LW 222J 8ŵg\vnٲE4 +UV͕I]wVW*U*u:fZ<ՁNMu c7ө|ߋJ*YN99Y""":ts6}K/1.ϟ?_nlנ5PWnl`/oѿ7nu![[v6~ȶV0`@u୯˶nϽBDno+D87׭.c\2     V@Dmn>@~m׷zK=\馛Z89shjVO?)tj:`߂f.mթ5g B%<f˗.8͛Kv8 dBs o"ĺ޽{?lpѣ +4NjBwΚ:Ǭn] x|7&L.ըq|:@<8E{1"ӿ{3gwWC&|7?zglڹGr\2{&?شrZP'nes     7yx@VgJ :P:Ȑ!C"~W[dd !r5tlK.5k/6vipu&\ɔy̷?S,X`]p"t]fMjԦW65ܠ{iӦu[ BC W\q)IV= )16ol83{H~LD7zBԿ{~[:A TPɓME+Ph@BV70w">?4N+@ZEpVI 逿 V\9V!hڴ9VիY֐N+MYsY8q;A+VHzթi5֦o)zji֬ٮUn@Ԛ5k<&Lo@ۡ^T<ֿZ C+c?@==ZޗNI-iTɹd!M :㿐eL0}9EW+Yu)cݦa}AWi15MhYblٝcsEҡEC-aޒd??\\@@@@@@ u"bӦcL~ja]:wm/t@N㯹uyp?V?䓢m_-[ޜ-~װ~{]H B"w.:u1ct"ݫV2A p?~#\S(q D8+zxJ3Ѷm[{]Et`9V>j(ưit6O,qVPaÆITT9.@S+f@t uk)}m tNu,[T0o\?^OAbkj PU_]x ^6֯is"t ;ϬG-"f ]c7$557Û4ibj lZDw#L(¹OJ䡇[MPIIǾ]շ4^Vdq6HC#0~߬ƍg~WW|+nꫯ@%x?4X7 V Z||0wm* aȫ@V+yLN;i~sD>1^Dאa=VkQ DT(*֮n_5j\"mgkU;i()|}8!",#    g'jA#""@뷸u =跻iXaԾb/_޹3=zm۶sXԲmq--l8=N oW{u~[ ZZky}.D Al x{ NamP˞={̲:ŋw|"2@VO^Z^ϥjVBh}oެ~h 5Zi'*b{:LbM"U+:ЊCPNCU~V'9sv~|"Vn!NVvKYRByrL?'i⪠:Hr ]9Щ+LVVl.G2LHiiP<"rUox+[޼3Ez7tU}yF ep~8"w*>.L]ܼvnlo2Y=T԰65SmI @@@@@ (8 z D܋5 PZ@i(Gi0Ǝk__o6kZ 2@]d֬Ya! 6hxjժߺuK+ZZp +:u2}4t9uY珼;2*s9ȢE찒uXmʌǛv DhD+qtQϟM7G|+ՀfϞ-͚53FܻϵNO!@į \agB9 -bz=k%w^\v״w |5P\Y՜{F|"w$MDXIq(*Ǐ"VnW7ϞSʺΩYdkMbj[W6      B'.z' Q WTr|"f̘! V BC˗4M6 9,7ijv=ESO0.7o\t*jyw_{6DɰZa"t JN9 #_~ 08Zƺ_Wϴ yh[_5bUհWo Bw۷C=z]a*W|ڴikls DdXh׸x?)#d~7 a3uMa7ۤ_*?;~a:6Ո{jW4Vɔ{:W9odruregg}C " +@@@@@(4F[t;&Qt WV|"\?Y Op+ ?F_w;^,0eb@@@@@Jߤ~sX˲kS˾@|"FaWT&裏5}kXjV(y饗~X^iUճgOyQV ᄈNa!ÇjZ A@V MQE+D&ZeCv%k_Sf믿_|auiĘW8|":Ѱ /-T])HcWӦMnݺ0ۥt[nm޻_vg, DpРAr]wi2]kPO>ҠASu8~"s 6_4@0۳NAǸFҿrs=} -c5=ƝæJ2KhJmi*NX!%y_2xٵw>q o It CG5M~&{.L]|l:n̶&ui(y}F͔u[w&@@@@@,@ 8?] @ncʒ%KkA /Pԭ[L;Na5+˷~kVkEgO9y'ͫV(Њ2~ꪫdΜ9~gaoҥKOK)Eʕ+gޏ9Rzeo DXU5A!0asSZAжl2i@!CLxYuB C DgSZ=$11Qʖ-kתB^>ڗ@DnT\8 P|Uc}]Aծ E*d7Ӳ& DL7Rzlٕj \96}D@@@@@ Qxpt[:pzPZATmp袋d֭BYw}T?L:Լys5\Sp85RjJ<(Z!55UtP>,,TTشiG x 'e!/> 6̄344ɓ'-b6_ҨQ#>^xlӛĹlPO?mO zYVy>"t|~^+caծ)#L7J :ŭoL$-c/7C?6\i򀫒ZW%-S_hu̲3qpt042Ľñ8n     -@ p8U&p~ʔ)plD={AkC۪Vj<ؿرӤI3uy@u WӪZBV4,yw=N1j(ׯש# $v Xy]x Thez:uXt zp|`B/zNѢB6l`U8am!σ诗ȴi(jkO+d̼vA 5o O^13_eʢ?\cVPNN_[~\M7Q\Z},qM,N3 zUi_wɁC27 @@@@@.@ ?aNkqF : T:pi(W.!t* H*Teu76Iʅ-O;.j+eӽ@?J     "sb/@< <pCσbƑL⥱R\ZYGN,fv5ò_C֍ҥJھK>"CB aw     ET@D}0\ P\/.O`σoǩ˘yK}=Lvn<!     {f\1 T *PĜ@@@@@ G@P/TޠC=2.@@@@@  GC`ٲe)qqqU@FFKHHjժPA      %@ ’@P$55Ubbb$**PA!$.5 *@@@@@@ hD@HII 6HҥI&Rbp@eڵ% 4@@@@@@w"G(p7Jrr EDGGp(p"١N[0Dddk@@@@@@x (^ϓA(ȅaB'       `@pRݻ%--M233 d^$BBB$,,LU4Ep     %Ys       %Qs       %Ys       %Qs       %Ys       %Qs       %Ys       %Qs       %Ys @HIIݻwKZZdffDBBB$,,LU&      A#@ "h/7Jrrrb~7@@@@@"@      I@D8\â.]Tӧqt@{^ t@]<4k̄4Ѿ}{Yr/>ϟol~74o/R)[̞=ۼ[9J*9^5\clذ̛7UVIjd׮]f">>\wDWݭ{ "PX)|{JTٔb~TT*?T@@@@@@r  @xjJZw %5q]wرcsΑZ*Vh%wa˗K2edڵҴiSë zeׯ_/-@IDAT52s=^7|#_|>i$馛t:UU]B}ڇnЩ:ڒdʔ)r 7亿scndڴi*@D &v'@ 8䢕E_=ycE_i      pDxsΈ%T //Rkڶmk*;_ Z!$$ܲ3+.]*ڵ3۬ɓ[n1t fj]v̙3DŽL~mZmCJSN1^|E3UV믿DuX+/Bz!FVxJ ]v3fHϞ=޳پ}$''VQZЇVp6t]&~ b9W+}<1a~_׺a]~N      Pp" Β@\>#ٹsV30"U@Wt Z8@Էn*:ͅ6 '89"fJ>LJYf,[LW.]tQ6s=W~4h8 Њ _}zѰ6p\ j * b8[n_~YyQfϞm22q^S >;50Gsa}= nlݺƚ6 sfNj׮m:ЀN㏋r^+f &?`uJ7x@^ D4mڴ1 t 30SbS{|_%\bB${Ľû[ƌ#s=KӦMe˖-2ĕW^i@sʒ͛K&ML8#&/ >?k 6|m r(~a[}+xoLp""+8q]n xƋ즧/#YĵҨv5yï姵ǦiSS޼*pŵ?0)|n'n-@vkL-15us_au+     B[@@A"o8p}_|Qj֬iM˖-_9b!!!f9++Km:ņ?~;]m۶MMW6zZb߾}&SNXmϞ=Ү];Yn\|_Hhh9ݻw>5lqlz*DiРh½ihaժU2qD[7{|Gɍ7qʥK8ע̝ >A^d>SS,v?RʗO\ mVEk%w\־ç{mIg-Ir9qv&i刹7f-e쾜 "Rn'ڇfЃ*}     /@ MK۷o a5g G{MV B=z%Fq[^W;k0 j >܄8.YD:,V06 J\uUW4nX~Z9cРA:ŋէOh֬\26}SJ7o\tE9{ZȤIrliK\nVQs85y>VzG~* wK&Ϥ>KLU;'R;{p-tit|(_%ts~]#?[w&v&<5RjV,\+{#ٱg9tIo}@@@@@&@ "on[d"hh%ʕ+']v|w@:NwK۶meRrei+F%qqqSkX!|P f[l`(֭fgņ3gpC>ZB~lp(jˣ>7]ĝR[^x5E+7WO\cm>/mڇGC@@@@@ Qx*pO>ʖ-c_5|PuZhڴPn]СBCھ[  cǎfY]Ԭ`믿.:N_]&tNST|ffSyoHJJ(48OM)es&#W%6"     P8" Ǖ^@0&HV?{ɢgˆJTgvo+5V>~K      pD0jN  mJJ1GEx߉-      pD0jN  ϏG@@@@@$ (IO{Ee$33S$44\@FFKHHj@@@@@8)"N ;'EJ@BBJLLDEE\ $ILLpiԨQ     %I@DIz+ PRRRdÆ RtiiҤTX\K ==]֮]+YYYҠAu@@@@@@*@ sr@d lܸQM(":: 3}F,4bٺu CDFFJlllѼX @@@@@pp` pPĉ;#gʯa r<     H'Rs!dw4̶7EC $$D¤ZjLQ4 W     ~ @@@@@@@@ xDϳJ@@@@@@@@O~B       yV\)       )@ O(vC@@@@@@@ <ϊ+E@@@@@@@?D n        <"Yq        ? @@@@@@@G@D<+@@@@@@@ '!       @gŕ"       "b7@@@@@@@R@@@@@@@S@P       #@ "xW       ~ @p8rܹSʔ)#}}rrlܸQJ*%[VkBBJٲeO=XB>,ҨQ#mٲEv!!!!ҪU+aϱzR~@-s@@@@@"x8(n૷e˖Iff\oРAe)]tv]dӦMf(ɶ(ѣGbŊrie^51) DdXwl;Ԙ/u:n&[\{`׮]M_ձZ_zO0תI[j C{T+w7L{Zu7O @ @&! 1 E @@Oo&Wy5 D(R6pIL>'B&k7 ̣4eҳLzg";l's<)h 믿,m$tMv'3{7.OZȤ)|PSK.i'otnL^E V*QlU&Ƿj]* 5 01㩫Ym,^{^ B٪/5~jourۛ/~5N?rިiww}mʥlkuow /tۖ2 vdTx3ׯOc 'q.M HO}T_g\{۶mԑq:|zLKqPmJ @ @&% 1)I!@@ ,̈́d&3 њ | lp?U6LgI\/]{oBl$p̯U2ٛL 'dV'Lքm]I"+. TdB~ypB0A;N9Y)Ϯ˫2Dn}P_+8K "y4A lMj"&+jicڟ-׻[@ѭ&'NX%[&+t=&GnkYc 6檻Gyv's${=j"uu'EB8]! T0!V_\9+T;:^QDyKa7[&k%o .\@D}OsnD %ؐqU_m<%{6䵬c}ut_co \?~={;8ȹ6 @ @ 0IIj#PkLDgb&kbqt9Dfݐ_ DdEVM̮T{;:n֝<5a^+[K "׋_VMH8Vk"FRmumbgu^73yV=o@DP}Q}VשSүW 0|~埕Ό\Vh?hl;r@g>6Q"RAܩL Dt{ɘO"׮L]?R6EˌO j$Q=V<@D2:V{^m;O @ @&! 1 E @@O@DN.n >G[t z@D>j5c} dtϫI<: ٵQ Y>Uny^ý[&eel}?vuzTDW}[a:^}5?:ɜT@V*:%fb;*c5m@Dweͽz[#+G}TlRq^g_G de"R66߱cnr_i5WO Hu رcy[>u$2@Dѱ:;ڛ @ @LP@ b.E o7IL$kQ+DT`t1"|mu:Rʵ'MOM8wWJg%Ԅo<5F'TתYM.Q珶k=a4Pk~n*+OeZk}_fjA]  @ @ @$"&)P@DN # dw90Н`_wlM֤re/O?C~-O DDv7_p $rj欐qu׵+e6[yM74u$ը:J_>2!8q y,V}ٽהJx[ϪsNVH/ T.Q'?yU:[Mp\>&$鷽{.RTg;l9|4"*PaԛG-qݺ{{->i1ЭsּSGM+筶U Tp+q Dg @ @ @h"F98J DrY+x) 2mt&73U򸀛nJ2U_gr{i'>[Mv'뼚ԗE}w5mHx"3)ri[ڤnr^K]tQ#Y Vy6&I6Vzc{yuo(*}]i2l~>K2kl]9ʡ +Mw9?uJ֝$o3֨ˤƍG9tk&y $:k2&Mgmc,2vro9'㶜s뭷w%,RoxV.GWTᚰNV E- P.s^2*P׮ךtO?ܹѱF8:~ӷ-TR&+PGJ+R ~CڶZ "@Eϣhj;xZi y=\hpu=֗J1ykCq  @ @ 0 I(f B<]in&+LvDϦIl۶ml{u2ڽu~őG%D EB rރ>ض9Lc YଳZlt&ݰɊ');$Pq$[2VkcE&Wzɑye{Q9[ n|H2:^:}IV+;` @ @G@ bqҝ @ @ @ @ D  @ @ @ @ D,\! @ @ @ @@  @ @ @ @N@ b  @ @ @ @ @ @ @ p ץn @ @ @0 @ @ @ @X.uC @ @ @ @@1@ @ @ @,@u"@ @ @ @" @ @ @ @`"K @ @ @ a  @ @ @ @ ' p] @ @ @ @c @ @ @X8R7D @ @ @D @ @ @ @ D,\! @ @ @ @@  @ @ @ @N@ b  @ @ @ @ @ @ @ p ץn @ @ @0 @ @ @ @X.uC @ @ @ @@1@ @ @ @,@u"@ @ @ @" @ @ @ @`"K @ @ @ a  @ @ @ @ ' p] @ @ @ @c @ @ @X8R7D @ @ @D @ @ @ @ D,\! @ @ @ @ DlݺU @ @ @ @+lٲeOuX bX @ @ @ @`Suq @ @ @ @ p+D=3  @ @ @ @D  @ @ @- ћJA @ @ @ @`(C)$@ @ @ @z DR @ @ @@PzJ;  @ @ @ @$@ @ @ @" 1N @ @ @ @@Do*  @ @ @ @D  @ @ @- ћJA @ @ @ @`(C)$@ @ @ @z DR @ @ @@PzJ;  @ @ @ @$@ @ @ @" 1N @ @ @ @@Do*  @ @ @ @D  @ @ @- ћJA @ @ @ @`(C)$@ @ @ @z DR @ @ @@PzJ;  @ @ @ @$@ @ @ @" 1N @ @ @ @@Do*  @ @ @ @D  @ @ @- ћJA @ @ @ @`(C)$@ @ @ @z DR @ @ @@PzJ;  @ @ @ @$@ @ @ @" 1N @ @ @ @@Do*  @ @ @ @D  @ @ @- ћJA @ @ @ @`(C)$@ @ @ @z DR @ @ @@PzJ;  @ @ @ @$@ @ @ @" 1N @ @ @ @@Do*  @ @ @ @D  @ @ @- ћJA @ @ @ @`(C)$@ @ @ @z DR @ @ @@PzJ;  @ @ @ @$@ @ @ @"pB"߾}ϴ @ @ @5k7'|r/8g8a;vhsC @ @ @f.Pƍg^4+F?i% @ @ @G.pgo;1>/yT+1]T bvj"@ @ @ @`w6wmj 4}KR@TXgwQY @ @ @ < W϶B˧R@TXgwQY @ @ @ Dx&O~_(%. @ @ @x@#M@D^ @ @ @^@ bh" (I) @ @ @̽@wD(яQ R @ @ @{4P @D?' @ @ @ 0sEi@D?F~NJ @ @ @ @`"澋&@~"@ @ @ @ D}M"9)E @ @ @.H"1 DsR @ @ @s/ 1]4 Dc @ @ @^@ bh" (I) @ @ @̽@wD(яQ R @ @ @{4P @D?' @ @ @ 0D{?sG7֜v;uܵcl:ݬ_پ{OpͦSOǾV|]{{}=aCn{hH"9)E @ @ @E \f }7akhR(_/8pgomkoѵ76?w,?_׼}כU'>sw]Wg}sg>zfG>u}sYs\Rf7>|Ou"@D?B~NJ @ @ @ @`fX 4VuȶfMӬ[Z]Kc/i.Ǵslݷy[\W׻oێRڽr ۮCNZz5Ww5/{Ӛ}ǹm5Ku/[B{V|x֥ա5ZFVl!97"9)E @ @ @e V[(<˚j{Ɨzy 0dg딱9QېB I3}lǞ~W WI7q͇>޼OrK͋ڥpD~/-V;z?z+~^*OV R @ @ @{<+_nWOlo^|5ّ"(<#'yï~_s.kݟT "{ko)𰥽k6G $U bG @ @ @ [x"_fuMw# DyGpl۹yoˡV񷽯h?d g6kZ?;p #@ @ @ @ @MS,^Xv W ߕvUZ \mرyO7 DyiHjJXnԔv<2@D?' @ @ @ 0 Dyh_o59夓lԜwl~u7W "ovٿToz .{r?}ןy;yOk^?\[j6ͮc붝kwln߹t|Meӆ朥GemevG 17ZF @ @ @#e beՄ+'rn b`g'!o{/t4~ͩ'ܽy7_+sj'+D\󡿨5O:v@Gڵk{ޟ{!+?y!w5jwۗz FswS@ D1o"k4 @ @ @G&0@ă;w7uۖyiw6&@ĸ/0Kavw7}9w}ͻ~@hk7q͇>޼Or>懞-?Ї6@DF5ol~9?wo29Pw}|a)^٬_s׽ۜwmٯG3O|*ܹ-[rY_T b#@ @ @ @fR]"a DsR @ @ @s/ 1]4 Dc @ @ @^@ bh" (I) @ @ @̽@wD(яQ R @ @ @{4P @D?' @ @ @ 0sEi@D?F~NJ @ @ @ @`"澋&@~"@ @ @ @ D}M"9)E @ @ @.H"1 DsR @ @ @s/ 1]4 Dc @ @ @^@ bh" (I) @ @ @̽@wD(яQ R @ @ @{4P @D?' @ @ @ 0sEi@D?F~NJ @ @ @ @`l3ش潯{_`3w~e˖UJ # @ @ @ @`U+'?{۪e|8l>/yTnB b*֭[N;U& @ @ @@\̠&U/׿e}T .*1;k5 @ @ @ 0{?}j3.5/|l.,1g @ @ @ p"jv"V3V  @ @ @U kolf=ǵ-*?:MNi.zW4?©=&2"iL @ @ @S:t+ @ @ @ @0"o˭XC @ @ @")#1~j @ @ @@t}~u @ @ @( 1N6Y a @ @ @|I@ b#A b @ @ @ 0V @ @ @ G@ b8}5cY$@ @ @ @\@ b@ b @ @ @ 0V @ @ @ G@ b8}5cY$@ @ @ @\@ b@ b @ @ @ 0V @ @ @ G@ b8}5cY$@ @ @ @\@ b@ b @ @ @ 0sfÆ ͚5kfW @ @ @ @ d}׮]<͛紕G֬5K7uNv۷7=Ps'7ׯh= @ @ @}{6'tRiӦ \_ Dwʟzڵk/h @ @ @8Nhv־qvԔV{"@DٲRĺu<> @ @ @ND_䋍 @ @ @(k֬i֯_>Y!EN@Ģu!@ @ @ @$ a$ @ @ @ @ ' p] @ @ @ @c @ @ @X8R7D @ @ @D @ @ @ @ D,\! @ @ @ @@  @ @ @ @N@ b  @ @ @ @ @ @ @ p ץn @ @ @0 @ @ @ @X.uC @ @ @ @@1@ @ @ @,LBIENDB`django-simple-history-3.1.1/docs/screens/3_poll_reverted.png000066400000000000000000002577701423103303600241510ustar00rootroot00000000000000PNG  IHDR<f GiCCPICC ProfileHWXS[RIhH RK E*I ĐDʲ ]D@]Uwudkc塈.lI]{}{9?%sofЩI.yY|DkJj !˥츸hewy} B=P8C A x _*+ֳ J< bLbgqgq&1^4Ov3Գ YGĮX@'81yy/xƙ1e`u-*!\ޜ[r1ࠉdʚan̊Rb}X!~+!F"Ed59g )ܘh>#S΅H\M.45Y8Sak|x2U\)EN[C$*%sƨ!fs6M3l#S+O(Qc32e{Y|^lH̍Qbg/f4#OE S׎u%Iz.iAH47NcSJĦ/X%3yE p@(``>M=x@h4) &b'DB  Q A!Ѫ. S5[!Q ~W$#ђ#: r:6Dk4a^ΰ%1J$q<ǣ5w=!ppE9SDU=,0 tᚚ3x!7M >FbA0'r4+o5|uŕRFQ)_{j;i{({eԹf32u|{ז2v;cMÚ6U4-^O#OSIkk\H~Y92qņo~!+rwu@?~MdϺAegC`~+dj'kq^00ĂD f.z`X JA9X 6*l`?h :Up `ABG1b"Έ;"aH4"H"A].G@Zu=^^ =z%Ow30532v0N3z \lr &V1bbL;&\ϼ|?l{pQ .zc4(HhTf{cqq&;&d&[LN6??lNsMEI643gg7?jk[8febrYS~KSHK6vA+{$%VXS}3[ZXLgSo-Vd;{oso;j8}s7;v8NN"jΨys11c\...2F]2iq6ƭwf'WO\&-qkq{wvAX|x-ox2<'y~Km]}'gY_oBþ  ;&tXt  ~vdgBqsbeaaIaUawí³#<#F$DFE5uO<EJ-nN8iݤ11Xˍ]{'>.?q'?w&03aOĐUI:ӒߤM2n)RMRũi䴝iSæn3sZkM?7dF#3uffH'I25|# X^+ > \8+ k]V(HT!sUّ[MݗGK;$їHN2U4S,-voEvtysܰ)(V<@^mӜs0˟:ryo[,XкzažEv/.Y%kZĬdQI7ԗjJe27-T&(;_Z^QaܾnheU^&&hzkvq=k}Wfn8W1bFFƮM6VoP%ZRƴfy͛͂͗oij|֮b;q{;w燺&;w~%յ;~:ﺺ={Vգ޽vcsKö}}??=9k9/59MCu쯻[>bxdQђCNJ ;uuf뭓SN^95Tg cg>wyM.4y^/6wvtN?Sţv<`_zbK㗻^:7pu7eo~g }SԧCyCCR `p=:NUTϦ*VU` ([అʭzb0@=_*;*Q{|-~ m pHYs%%IR$iTXtXML:com.adobe.xmp 2108 614 SiDOT3(33R֛@IDATxxT$z &EDDA("XS,) * (UzH^{ؓ3)$Dynf;g7=k; @@@@@@@(B<š"               ENCd0       x@@@@@@@@"'@]2@@@@@@@kt(ZoפGN\ÄCR3WI;ZSV!yzP^=]{Kaq~8W      <^5wʣ=WKPIwZ o 7aE58PT;E@@@@@@ C~*xvrYMSX]N}U4)%L5K&v4Q^iYBn:R@,W(s9~d.Q5j;,ݒۂjaΐ~R^LE2v,iU#S~YQ^vf>-YbhI-ݻ\8'9TyюI]Rܞٳ;4wFɻ3VJ ӷ6:]>܏r_5RnI,_֑4Jxœ*2p_=whGrGM;NZ' HJguz?/ifGLƦó,aյb&PFdmcٺ3A3[,k&Ž[rl)ǩ'n>Y%dKFcMYp ;ZO CX͵]%*ʰi%Z#9'/ "f%#Q>|N7]̶[ :Jgd}Q_Ro%m{_yұ^^ kng]dS\Sje፟kD0rUaOv0Srݗjs@⛺U;5,hBJȹXއ}s/:[NX/]LآdBmRrӘFn3ׂ9n=h;Nݢu{0OQ9U\~&}=+>PEGn69?d[wm5px   I9\6(/[, 8O"%暎y*K~ӇyIw%?̰~X7I̖~OPjUI>\U,< < f 3u ޳On|êﻰԾؙ-g9`^w?"??ۧ~~y|fr~*rgfSN#۳W/*>ITyl4?>U&Kdkr_<&>v.՛Fm_Ĝ~!_Fu<ֱ<=/vF79:;x)T?_ۣ&+7t.*H[ӻyu~&~č4{럞[<2;~CHbB i.|vɖϦ͑]SN:Wg$vlpu\a\M涾}&h恦>eby/$k~3wIȫ/oBzWf?㐟LJݽ2uҰ˹Zz_SkXJ:m:kcڛ{7^0`}]:Mw0O?_K^Lܿ_ދyiR^[!` xN7-w=5N-Rm샦}Nom4 \?qاG_t Icy 7tv|^-jgnَHt;j"I2DÛ]E;OhVpFPcVnz|I*zN>;=6".ti9I?5{?,Һ pR|iЄ592b4:;O{Jk/ukpd⯁nvˊ )r$v)o]v8 z~de}ra j%tKcxIokk'iC  ?=7Oy+M} 쮇Kҥ,'GtvV,0jӰtn>u}B_3U1?z/x|L?(n1v?q[}0ĸ6YUv4UURgH'v{w4aמq5jACӺE'e'/6xuZZ|{Le>"8WIees_ϖ~zk 4v >pQ/9}s{?fdW=of3°o|ᘚ+^z*.97Zmyx X|udB3z.z-YI6 dO@HGaʢ5e?LP?YnSXߛA6߉6&X3/s[pZz]Jʚ5]:| 9naq;㼇V26%}Ծ"9սw7r6#/\]P7xs45kY1.maUS]g=ɅBVmZ=}b{-u>sCkժzhD[Q+>أ듦B!QwU)\aȅKB8`*W=XwٯwG3}=*͵smr^ohmg?3t~a9n=hTT`ي+;禿\צF0MoApz=29]_*y6yE@@@vhs霸̦~N8gPμ+{42MGj<4b=U Ց6(9Dq@[E=}_.4CgJZ ya i;힫s>w 0*rKcrk%'w.} {= y7c mawZDUA07:΄4r[ؐwϲAf%O}k=^+dpVAoIETUZ~ގOy ;.)[T?Pb2TB-4ixOkzeFsq 6_\xAg %yn#yZv7M_[iYE ey=    p $it 7DMfOkpir?Moin؁L&zZUaU5t ]L0V d+:Jps6ȍ}. h'v;yĉ{؇br#뽑mg J8ǙYW͞`w}Wi]/jῺNTEo^wtQ\52ܘ3K;2y z]c|n˄ 5V_ 򴭽sgg-8݄q.y/X+w?veO5=    P\i:|zoMyE?V@Nx1'tqdM_קt>hjۨx˂yp2 մF%q!/ ƒ>~^ah^B-acMiy}_[Nj;ͰN>CZfڍǭSZi4%,N uܦsTK0n9ci|ךN 9E;Z"ZLE@VxRk9KacrUdDv) \^\Վp-0 -S2cLӫ͇|U|Cb*<=Nb x;ߍxz.z/|_[&/o#65 --~^_+qk-lqCD@@@={y))^CRf,&w3[4x1u\ihzc.g̘hنt3$E^Ў+̐ok&|iB;b!} may/w`)])m6PVn>bDr5pǢM(7{@Gq::_d>PVɐ2ghv]Q pT4R&5C h{~ҺA-[AvzoX}l=, mM5XC\xI+lRIO Y=@)fWW4t>z[%%8РF{VhWUWJ~v(ʐw}woD ]M)V iފ ͓7D6 ?j! mf}q P!*$R縮$[!P>ѻ3Zp(xm;* Kp{:ŝ!y_rܺ|kRR.&[2+!~ 4!>& -(o!Teuӳ~{IϨơc    Pd6$pSXK垡s̓[ eSLn#[aͦw4PJ Ԏk^>J,R64K$en b e\x}:WFq|^jW=}uV :vw~M;V,vFedpAk:7ǘkN0v[-ct$&;սO Z^wo;Kמojj=7G!fh y..%M>xx6d6i)G3dN/9}s;~ Poԣf&`;$+ZuDr[cp+Y"_s9H^,ǭD oA:=8@ <0a]o43ƀC`uxE@@@e 5T,h!+< h#]Z'J=e;1v[]n<&v6RiEEc1:zi0A3Wn7G#&v9EjZ%kOv@PuiN&P(3&Wu)Xh77a >+ =Kz9_'3;fos("p4΄%#,֏RfM on<Ѵ.K=۽sff W%!Rn(?ްBhL!tpZf i:K-@~;zrܺNk %~V j!ym/^"JLe zjі\0,n"   EAW#V&.hjJcoU_fN.pVذm\tz7%b>5􁶳 =ָ_;$ƒ^)^A1OJ;F8]_w \<^o)ўt`HTxa4F?pT6ao7eyBG'5t;~ QьQ92槜0{\;r` czOwaO6?7yJUasS)na_S:TSfPM;}g D뀾DWxtjS4C0a.ܼP_wT(S G;{?[EtYfzυjZ^ʅxV!| qݿ;r-D {12{o۞UqufXĀ.Sj@uS5 |e "m1Q.~zj=Wo;vr'-ߓaY7ߐ +x3ޣm%1ܓxȏ u+܋#P 5xgWN6ˆj)_;U@|TIzǷk&?V]+P*EhDC>:[NXoJuS2l{T vZ^^8i^Lh,n8DbԊ]gR7-|s+ː_j!{_1Ny>D < YG:/6V5tpuҭv{y #0ji_'K^\-aUDqY~:o?q<SwU!\=N]Ş6v$ *\ ~%80?X[׉tMw>԰n^_1&m̷u'P/j6~J    @pC7l0j) U}5A;%%3sx}Cfg<;W lK#=VF$QKۘȓM'R<>61qeuG<'W^!W_C[ .޻I@Fv|mҳl[ékȝ ~AoߖQ'޼U.~=?Bux{V xgˑfQ|@>?M0'sm>'gGO 9ԃVzЊ8ym~sI%7SrSMͽtbAڱ[\o0_xa]4ܧad33Gv8Sdp /y];Y=kjqn1]jnL szA7]|׶`16r(>tQrƃtw#3fʞ7 !v z*5 g|߻CkA 0 ,^I}CzOh,_UZlE 2_e$߿x)b]'v~/BTBɝÑn :VA\Ўymt6F I{ u V|?>t-+Ԥ:y].WpB <8MՈI*bRv@Y*!m(MP-ށ¾9rܺNB,U]qx`֔Gf ?o@@@@_.}Gi§<[]3>o4P݄~;~Ɲ2?tQC>ef2JiɏkdGgI?T^6a2ΣR2zҫS-9]5;LGfC *غcw_A) 1wp2q?߽ڡρE;k&y.СZNնJTA;W3wي!A\eo!\A^DZ~ukΖ)sK`VC[COx I3aTf<܂nPú2cSfȀG'G#{e{%'wheIl5}߻~ [֫i~7իsҿ }N|#R}A"}.j4œ.^) }̷7=k{# F[~,] UTȒ/+#mSUm' g+1D:NFSAEi9Ҽn ޱ.=sCgKuΟkkB|;wwD}-Ø+Jˆd̟y y+Mr[ [IC#'2{>ZoNgUʗI^mz-2z \h)8W]^*}6yyM9U$) GL4CZ.Y®%f8 kx)lG 7?_*MHŵ{S:԰z);npO^t64U0mo~r1˾vrE ֎H픻_ U,'1Z,X=:&w RpýkV5.6lUA ym^&҅.7{:is䥱嚮} >םy ./9O}B;> eFp!붦TpU&rYiBӮvkh۴\iz'S$1Kٞ!ώ&:vpE\>͋xpyC['[ݾ^,1iy|>] :} E[G =-ba-p*Q_G ;u4peϮAп:%]ki-u Ld |׋On <,ݒD<6 ^^AxUҶ/{0NMr&7x]6mC [*7(1Cwb]GP*L m ֆ\Tu*ZSV^*zu|ȼeϢwK=wT?stO8Hz{d*ܕN|.8侫] XrjTٔ!opx_-$|ϱw?=X[tMF\9W J@Ҁ@hG@@@(qn n@@ `.DsہO f4T(׿O˳x ңU]%/ wx?r f\q&>~*ytxG.FeR.rMZ3rrb4ij5+fGR%x3      @,7 v~>[ƒ2V;+ߔv h5|8wUxНhyT?;Yib <̊ Q>fBhgm׉pw~Jlpý0Ƙ uux_A}Vnzggey3,ˢM;2PAy\xtfWH~YφNtF!X    zuV>xDkl+z #9D {NCl{4c^֎ =i-|}Z|شVKAn_'Cn>~}7:|ĢMIm~zLO~S׻x\.vȍǭ&tឪ^d$SgxpyZl9$j5ALcŠmmZ}dfϰnVyL#)oV h [̾ar2kuB(BLȽZu.!G!?:TqIV ɘ7Nb!-g?k?]7Hu BZyeIu{ա'9e+SYDilqu;zz Ѯ.-ʅyS MET!A-    @}z:?)z #9D {NCl{4FD^ngͫgJ;)cy$07|[Px-i }L'hJa=< 5M\^Nr;3Vkferdӽ~r Q6()l ABi UC-&kMd'3LJ[ECMAI +agbNuOk*GaBʚ'       o'pQ:iχn?ue*6h[ZR80@@@@@@@H x8Hˉ\Tc7S)un=v      #<:eRb۬5e/=>E o\C_7       *@!V|,jT)v`&HǖUV-&TSvfypWu(=[J-rŇMB.D@@@@@@@*@!R1,wM׍ҬZlݙ 5+w;vkE?|$/eHL=4Ir{RR^ 7 5,T?7       GC~ԢKF=g:|RyEq92jfWvG@@@@@@8\g-7M!:HT\萵tpڽbמghZ7T?^}>e])S2~t7Tр6ǁkr <4aP0y& ;]Gz_]S4ϳssZ}/89R eu}wO}3o~pP܄yܝAolCq9, ^'8P Waû#      ?GCwh:y}608}:4^} toA;koj9ƄNnU\ЅSv>ѾCv{iiiYW3tf턠ݚױCh>jU$Z࡙1Хo~KW%H6jÀh`@j _-?,\c' $ >ZJ uVyЦ x8e=HP(Z1B7tBLU:; MTNkE7 %lVj40ݸ8~wgb:Ocj]ٿr iN;.Nt( u=T}p!kȪvy~       ?[ÁC^/ DnJtv5}On/ﯝcg.oA;_~[\N44Mtq,i羞ax`ɼu)vpdx_Ӄ$%&}}GQm*MATT{oXA,X@TEEQA`łgHQQ*z>lfMMIdvvfv{o4=3Թ!*:}|.ϐL[N|uJjIaAҫߏoG^ jY <|:E AEm]")hJۇ#̱ k0¦-["ADN`hAvWp. P)_m.C˫TU/Ҙ&lΣX˚Λڬ)>l0?@@@@@@@ xdᴠ1A5=1Q'Cys'*0'B0/rW3_ُ f0׮^p Q ^4 iū`8Z8rRwKfmS Wtb3SUWA'Z3;lç̏JqAߎzАxh\U)G],a(jW(eMkTt:Yn[&I5 맓 Uxc ob5+5Loy!      #@!u~J8z!Vj{gM<-Ca=l5A`Q"q_TWH`ܥ.tBCYT~]'A+Mm|:U-o+ǓxЌ-V%oSu,\YbUPe} 4C!!px%YzUݱj[cϷ08^y: o1cIM-l!?oF qD( _ys+@@@@@@, m@T\M\h,L`=u'ڴ [p)D΁&9KmU~ȇȌm  nvßuE0)LaÂEݖu_O_&[}bfZzլBMߴ~8˿txԾ+R^ i       g ՁZ6 VH<:|??4,+Cیi*iP?aQe&͉*03x:!Wg^<:[JAG"7Y_ac,Z>/<-^'%d:Sm_fVL p\SUTT[ #-fo *~DׂB>qsޯo@TM+jϨ `( M?_NY}NԴ}]窆n %,YnaLJCbCx;UH/Tj4C]ϿpӰڠcj{ ȿ"bBb;_YTC@@@@@@@`ثyx8A5+t⎞0>;ѶZ5˗ :WҎ!vWUv0l@81fv9jAA      y\q{ 0+AC@@@@@@ȼ۱&       g        ycM@@@@@@@!9n@@@@@@@@ 2oǚ        Cr"       @qi4O,] ,D8T@@@@@NlA9M֯Noz[b ':\`H,TfOmw\~[eT:uM<.B~ni&O͛6N,ny)lzkX/w;cDy_qnwOonyV[Ⅼz;,eK}ݲu[[{_G]a~;?َ<ÿn} {gO9/^n=;fGoZƎk=opBZr|@@@@@@)@ayOOf,5~LxovJlfM=!Զ.S]Oe-o kmf/X F5|=&N:nlꄩ6}tQt?T+U.Zj.rٯNfTuAC(Uj7ޖ[ܶ1߿UV)jBNjkRXլAH"m6T)C%n}VxPYŋfk1MJ3j׷T/_17hyN;mjnL"Eӎg+Oޕ׸Ws|髗\r6'ݪЎoQk{#}? lkPaVtq;mgTQT8āE_Uد-R0jB>g{o@IDAT{Yf;i/[kӂ>?KYJ,l-U-g L8Y5o?%`ӶuU#*-fcU5& *~uE,>!-tm}qwᯑ5ZJiǣs?o漨6fw  5x=]S&L1e*רllC-GRܼiwM4}}kw7?wWYŚ4o} }*eEޕ.WڙLk-_Z^t;'kVqujO7~xukٿcuBsTY'쩳MKc$3m|z5w8׮w@->\E=>t|fsׇӺ%l2d]|WZtvv}oԿiwb I%xPBׯ߱ǽta7m"+P@      &` I\,XRaӚ~ЃS]ђ < 7(05g=p u_~N.Ou^P=\{hybKyvEZ۹w?wx `Sj7zESC2%ϲo 4md'mϣ‹-bxUk}\kSalSGuF\ .6(1TzzvwE9_)I:M{cP_}V[ۻښ{CX纻^x5q¢='WLOž|w -l=w/9}?;eZD!4ڵ616P'ߤ=`v͢*!߰/~-Rn`>xQE2EW?_Ltr;gW>^d7?`̳eZE" _sD <3v `͠_x;\ӏ1]'ȋ߻NoGz+ Rۮ:xw5e;f~DV3Vz;c?sۏVg:m$9a{vQۋlOlkISNv!o+ic1U P~Gy/Oo::?f~suT…aDB7RF}u=0uokڶoڇ?yu[HCm+Z6{a=Yw;_]RMnrVN(CZc[{'i{ut}g?nWƢ     2>ֈζr(e+&D/b?}?}".岻%xоt:~{$0Nkn&rh]➊Uޙi3&p8Z@ Lmݺծ?zY']~=4):V۟u6i؉cE2})5fwlˑҘc[uT'7&I paq`l?#SBOa[u﵉zueT D!?tE4䎪$x\8@ǡ{%+SI{j2&sO} [MaUP遛wӉ^};~[sO4U8B2D7^}Qa#|ú vձW !- f@@@@@v^xX)1 &Zʇ*\{̱o7oÿήvik_rzd&9!IaMx|Շ]uN)_2\OixM~hXPйUg=o &NԶ:< 7Wn83;uįʛ9W)NtrPTo{_ /NJ|nw*蠧}y0k?J&˓Cp 떷mj?-[Â#g5r%x`=Bp-_ڷj涱lzP7}3ZD|5UsPUhE  QJIޡ{_?UxPZ8n~꨽L\,v;+/> uvџ\Bat>YYagǾ!!f2˸a"<;:} vʅ.9[pU[f]}n v$p+P%_ 9+V[bPAϣ-\\j?<6y)4jݴj]j [>>*LPA45TCUTEgn<+oGZ5=Hƒovպ=͚]VGnIݷ|;t}kdpUJ*nd8bnfIJxԎDѠԎW-ujo\M'+o^A 9wo2:5mGY:^ >8J=?}WX[膹hoE+WL!|=:awgG^8qeU ~Q[CnLDы<(`W[MOu;U]3op AJ#JYlo̾vnn2oM+lЅo hH'y 㗍;z4Z2d7DP4&OP0!|.ap·/誮(T~AUx&TuGUf|S罪=K%WU UP>{oCxߍva .ܓ}}d,Xbgw5L݁vco=Q@@@@@@lثk7׆u *MZ S?;g4l*%_ogx$ޮ{熞R'ծ oE}5s`"u^}\nz}^ע+hH#N>bu <;Moy)n Г~ :|dB7Ax[NMFk SVLQh7CzA`ca#?{?#Cg,wflMvg_ܼӏc dBd㢶 (mwkѢ ׷[0$]Y=sc_!o!}v^/q)nIOruQv#~~FWD2Isdx늻\PD+ݷL*h$I?? '"o4ٛ޴/k2(Y7*,TAP`KA>d{lmﶟޏDMOayq`hM7yf$lpD>V^E^'rVLs$x{Q{\lỏz>C&cUa65݃- ZGt4ԍ[ f/lFUwr@7/V 4 {7?3 Ct2< >s"fkZ>]y|}GI@@@@@{}a6sig/~gTA +Tڮ>_8jx݁qUhHxzbVW>AYŪm o`:mqv'G-ыKۘacL4V8Ւ w <7;~o(wQHh8Z"׿<$9 ] UP{7!E#pnаDD _ޓ5thQU5 @Ol^A$cbZr[ '[lQ?2U      äC쇉a&O rz#ر]}xxl#eO}c쥯R <ֿ{d >'U[~~w\NvbŋE޾›]gqc*6+Yr!vwTθuNIl EHoV#η>Ė;VypZ+X0B .p̡5ʳyjF!<|Ϳoܼph͈1i6VtȻKW{^ռ>7T2Ob Mh?N/Lwzusk\| <蠻\ŦO]=XÚU׺ۿmǷ}>}Sޠ!"Pmfw^y͚2+xH0c~[筑wԿTu$rC2C.!-֤Ot[䪛>/u?UDzxbxnjp;3.=#|)o}P67Rwg{طLD+?VXeםWh׃yçF~q ;ԡ@<f"eu{n4i9Wž7dϰEZͺ5ݶ1J6G4Ϋ<;.`1     ;lwFuUըvR vV|ݭFٴd??;~gwaC遛?ƛʨ_py*#/ݟnZ4sZvS/:͟6iumM^=᫦RvL=pA!O^u~ծڱ4tu v1ڲˬԫSSj7muǜ~7ct7k~Ts7_?Y|/wUnu؛E߬O>n|D:|x7̖,_gk?;A5UDwrZa"|w-_J+d;tU`H{*BԪV۟5/>>$pgPB 7M,4>:vI8f_eŋ>w׏_ko1ѽ~GjU5k7ٝACFvߍX̿?v]ɊOzo~6U(\7x&T(Y=%uy^Fwv-2Z4>\[u#_ )k߁aM꽁 ReKzOucSVfS!_ M%rA2CǔLA*VL)w6ehP4co<@ };3RJ.D0>Nέ:۪"n8?|0BogOm_z&SNvYשr)V`[x6w>0 nX!ͻ뉻\M,=YA o> g$6Ϡ%clA׻4 黠P۰nij ?@@@@@-{}Aʆ7(.x-m ٘Y3정Lߙscwj6=խ{53=&=ɺ_ݽ|\g^dH6A j#?ֽ+SPvٚkl3ݽ-SﱪV jPliVp3Z? ["O,-:wWm?[wjM d ~%/4̆7:m&|SU t~ 0(P}ּ?45\T2Ǭrfͳ5Z\9uu6yxb7zBtCnNFUoecWP҅K-eE+=r^ϙ>J,a*t-\ 4E` _WC;ENY*/T\22?pJUt$<oy 13alܼ! 7^6g/XeS6HoY?_Cohdm[asC>sߏx>Θ7ae%92OzPEiEB>Muϝ9ITA#{#eʕ![6[o]F+R,5oy\V o|zr6鼛l;Α'iw2 5z32g[y     cq-ZfTJ*_`3l֔Y<*Wlu~#sZ`6VHakvx3 a     x%       ~q        #@!        h sq8Ak$7еkW۴ii< 1@@@@@ru߷7 K/Ԋ++6$Ca>}͜9ϟo%JuZ lٲ1Kg;VZeM6o9wH=۷wתU+;餓rrd      CYK{@^ڂro?c۶m{.]ڶmKKxЙ!F.$N     @))){%# \ve<ghܸqFw'O'pڵMwlf oM6͕?s`ނ _|򙮃EƵ8q-[U^zeD?s^<@r8N@@@@@FiwL^! h?<>0g{]A]r%V@믿z6mͼx֭pSi=.ڣ>j*TȞ iC.84@@@@@Ѓk֬I`IA w~VT)w0˗/wgǑ(Q®.^xӧM;(O=T袋: 7t馢/.32/YݺuQFI4m6ݱc][fͬZjm>jM₩/n)va;`HrK6@ʔ)cvZdr!Df$8cƌ1u~+nf7m4kҤ "l2kذլY3=7.\hrp 6cQEF:h%vfΜj]˗/e˖n:!C?omݐ}rEHh"kР>EMΛ> *>GӦMwz.wqV`AWsIII+ŋ^d34qD(ο}MgGڵk?^?T?ۖ?m1bM:Ք*qUR]G}S~sk[9?ptFO%Gd(rwL) @@@@@j߾[O/\qXJ\@@}"WϢT2_7w84}N~d5Ѩ?I}Gyo*bvWѢE*ZW};Ӿ["No:>aL4oPQmO5ټ_~^|-kuy)>U|uʝgy/[uPۖ:,뮸吏z`m_nHQ_{,Sv۶mogϞQ7N??[d/~>Cwc oKm.p1UW]حn0?^7o=j;/nNQ6AK줓N}˽VNǪ4Xuͨ2U(xdnNQg}"+&Y&*TM߅x]'/Rs?< $2Q[nqp:KI9mM R5b ZRCI?d9Tv}>ꨣu֦aݻ D.JDfL: @@@@@|+p{v `7#A=m>[2NdЄ:O9Ǩ'vT=xða܃~?jz`sǺxbObWuWÊ [L/ :~id 7C" _~y)2m<{カdwsPJӓ}qOk;XVǧ.jyKMŽzڡb:u4*XUFPRH&5u>#n:C˨3/WD WBU@'yz>U=д\AK/uVw鳨]O&-G:= +"XRn2Q'Osꫯv:nݺ_j__O!IDATt>Λ+jJũPAvv_媪7m>?F݈l;B8]Nѵo<g|I2eۥ^Dt)&s9,JAv>s?>t}wy7T@_Q#7o ?PѵvUYd3+4?:]rjÇw Z)8tR Ug_t+@@@@@]$a|Cv /t?SG>~~ꀪ*Zfq+PWT`1]ve#ՁDQ_*KVLշy 62xP?+>pUpASbA=`ăKNM~)R: 5Kf*CYvJxQ/lnnۢϨ3[XZ7B3TO~/ܔ 7м>9.Aϻ:6qpSǾ:mLgѱi?Vo*r>*;qyԩ8@@έ$*q)4̇n:zQS8$%UeP{Gfn5T]DMF4GC3W@ǗrS KC~N3+pLj Gh~8:דi A 7I|BPzQUbJ+>n0Cmh #r=AxRo߾.sυvuu?6uQ$V`O?!Ѻ ( #4Lʌ> @@@@@)kQ튦P/i >{ <覬nuتW\>M.]?t \N`U̞C}'?3= puɯ[U1^{57/3CDm     $)>]pP_*!j;?{bUՆa5Ԋ,)115,*&E7"TVvS)Yx>jeh!=;ٳ>33Ask=2 #x,ܵk/ϱӾ[>Zw?ml/UT/y+XقJMnbʕ$XC> 9[o8[a,z*x 7N[o:|p_NQQ{n[?ǏqN-Uv{ '`R5$77V<ؕO pi`10H!zƶ6 TV[{(xHJf#Ș?oƪzV'N$ ΃ڊ0AFnݼߘ/^R$:(U7P?ظJ`.\WKއ [IDӦMl6lkM%OI90摖T72|'O' KG9>xhڤ}f u4ioZsu^cP* "8ƄZV}5ʍgg&㠘g*cI6lPAǾWg"*x5=iE?in)cTc*;,9΢X򐄕I?Ν2x)Ecr' `ς;vn30,<A&)pf!) _1\gDXÀ"h* D#o#WN?_rq/&6mڔ+_0VIBUS!ij6=+X!AU= ( dӽB>I0k~_`B"ȗNӶ*x/ܬY|wQ;;GiIݪ<;/,~˕p`~F|sDTy;u;vLܾ,HG8A/or[$/b"g9[ bX\h۵k;Ϝ9F|ЧO=!Orƌ.M0s=B\pY`ay*tQ0/r08Gw}'o=*x`{9Rd4g&YW!eo1KFУKvhr9T_Y|DbR2-U K H{;GϺK䛿$K,-[_aR裏 0\\$9W=Bi{IR t(&q饗:jYlxePf'+[Ҽ(&Ee ]g$!I{ێlԩqMD@D@D@D@D@D@D@D@D@D@%"ȵTf <ؗ'G&ȝ{x!t Xt++++`ySN9!lb4ż+]vbu*4$J,  ǹNٳgD=z|駎Śa"*<qe+Ap SO[`AQye3@e<_KPU #ѡCgV0$ь")m$۴ic~r^.&LzOT/3\˳>t GTANsX lnȟ ĭ,̙c}+x\ xws.8ps<#aM%x(jy駽Hꙛ$w+ē٣F\H;Q; {衇q8P(U;Pܞðk\I ,yg)w`+N?tq?Bx!WV6nU^7!aK[ۤ{c=fS?\/|Sq@1<0/`D9|A]aÆf)jw<Do1ܢbp xFwjݪ7y'oׇvƍnQ <yܹfFU |% #<>Q@u$}vf p1={X ˥NrF1DgL8ڛȀ} @`bp;1fvAOڳHqQEg=.qA>}.ꖿ(myCpyzh](5I@9O>9f^ T8c3<:pH2<뢋._0IG7|7-VPa;uxj*xERH K \F>vaYK?o6l+1b*x@r,_͜9ӷޅE1<9a>o6θO{ qkسgCCdyPk8nsbA%-GO~mtwɿSRw\y;NvR:84?BwN /TPrG<,Y$W߆̬b&8ݘ1cr܁Kʟq;vpÇ:tj 6xx$KPꜺ=Xؘێ@%i M8 G-I~_D~ f/10V30~$)"A 1sPqWLC9[ O>/OF7mЀp{GoV׭[ax*/^;wOR@ީDYcM}?x2} 8(WNILׄ,|&Jq u֬m06Ղ09J}`E{%MK`{0ٴaUzеkrqhj3Lt{.7I}TSHd8jFAV*t0q8C<|+6;Dd.4 D RrhРAR "YNoߣ?HSzꩧ5 Wr-"\#I bD]zF.фqYl\Sԁ{—k+N=&CpX-<0Dڽj*/".O_(f,w;eD8Uv>f͚eV<Yޱ:"XmR Ǚ;wnxjl27{l}H! x8B% JKz'wtM֥O"(!P0oРAn? 鴉p ƍGxQrU,_OpB^zHݥ~2+V熎Ȃ:v}QhؽqIwKtNR P@ >uvq"b<_N⹰=6`ޯC) aN!C8 8.]Z-Kj:_%ЩS'ףG J .t8" ;V'H"hOG-f;A™UߔISVKۂ}WNaθd6ǭ['q 6#D&qM@i&Xq~ 9'|l$K8wJ<^͝3& př"N@$\m< 1 xӎo !vGL@[=dѼys. Ŕ@43zh۔K'#so n ;؂5I87J۹>̙q`®יv\k7B D+ƍ2v5s7iėQ 7q\\p==ĽCYS{8F1c[MOD@D@D@D@D@D@D@D@D@D60"p`j~j4%xq Xw5E{;23f̘5VD@D@D@D@D@D@D@D@D@D@RHG5U| [ȞG?ї+waҤI 3k܅^X'@TޢXjZ2!` B^- HP-/UK8p uQu_-\ӦMÖ-[w}i4o܍94E@D@D@D@D@D@D@D@D@D@jk׺/2k5#1w:tP'&C|c<ԎYVO۶ms#Fpo۶m 4hN;E@D@D@D@D@D@D@D@D@D@D&ݪU\YYYM^ pYguxͫlYJ'P~}׮];ױcGרQ;&=rG@IDAT U?CBB$5* (2WFQ~2""(.@H ,$oɹTttw:I5o-N9TB @ @ @5kVz'ӳ>Ν-Z-[zF]/q]O;6Fx'˓!'@ @ @ZxhMJD!-]@޽BLЖaS;lEp"k-\kUԇb{z"0a[d @ @ @@e*R/0cƌOWe=`ԧOV֟:+,Y >|x;vJOy1cƤaÆ7 64W;A'/4 ;K=3OL&Mʛ} @ @ @*! PnЈzraܸq׵N)O^\-;3Ki9̐?-u}]K={Lo}[O @ @ @`<fi@Q裏plA{W.0bz8&lR8Er@b(/v|ޞF Ckur8uk0  @ @ @kE@ah#{'[FvhdkZ лw6۴zCT,˭d-|.E| @ @ @UxZt뗶bn X1DLО 6!nZС>Pޞ69ܐ]^x*>  @ @ @ Œ%KҴiRLO2|"x;-J/N뮻n8p`wmh)$뭷j @ @ @@xZk~b:\=/~1}[js^xt}FZqr\hku:{l.o˫+3f#FC&ϻ;M2我=8∕^+WzӲeWZuVկ~U%~?y5rG)FYdM;5rM!@ @ @מCڛnԎ;!a*SRĔ9?xqo9m:m{* =RɁmݶ=Vh_o}K̙S!aXYmY.rXtrL~{Yi M(m]'m#-j.)Kkg?K1Bt%\RL15dȐ)$ @ @+s?sE;+JaE[ 2?Omo{ վO+loiC4Arc_ A9t sox S}C}u."d3CvC!Ѩ"%͜98lAW:O#j!@ @ @zz1u~@Q0ͫZ~s\O<9뮵=)׿4f̘ ߟ v )SЈ~xMߧ+f:9?c{->瞢ȑ#ӤId;7,oԷל7o^zS|ƽmf'?)-j @ @ @;.p\+uڿoÉx8𖷼'bGdU}n[s Xzjҗ9t緺?XT޿[צ(?./sx>Frǧ/Jzkԧ>Uz衵Z6, r|{EU^~'ҕW^Ykj<G/ʖ[n7˭kSm :4-i)³QGU",pmB!"tK3 yzE4Z࡭IΝ~_A|Pû4bĈtצ)׉|#)6pOzU-+¡U @ @ @@G:1yx:ް}/x'}#Lo_1yl/c=.]ZM9qC^7pG\7fěfJc7.pUuZׄ?ϴ[~_뷗W=G!rȟc}ٲeE-F&8?\y駧!޴oa~L74 Q'/R^˝xxbڧi+bc*/o6+ɈgQ3dɒzqs!(rL eUcjn8WGў>wߢăx<_t֑4HC1F`8p`q.*J#kPm1mUɔG{ӸqZ <nV"{BFb(!\/.{}W R)PA @ @x \!#<=`oDް1/SO=U_Cx@DZ <ߺ "NãG!J}j!R' WZ'6ɩ L`eSy 3~=tw\u1{ޢ^+Ĵ1B2x '.o#DYs[bNɡ\ou.e^/'Z~iCζ֋>7x_rI?0~-߼?HO_ߥ#FԶY ЕƔl/<ȁ-o˫g|Ǩ QO?Xxrx-6/}Kž{NDɁbmy9>(^.?y%Eb$݉P뮻.=z[Vٴ#Ua,efk[nIwygiOÇMiR!OU <lux4Oz\1Z*qjg׋E"5/oyQOL7/[?C[`# T@[kjbFeeSyL|ޖˁ+2WM_WR+ ڽطobW[Ńx;oFh%Jg׋sƃKb#GYz1IL[REzjmCI~Kmˁy~=gO~צ~);{-F-rm3r!a(o ?ob<2z%y̋{T"C1MLK|Fh$Jo<(_FJo{rw]^i„ )ᨣmiCѢ-C(PDZ/ݲۧ!J}C&%-;CFj @ @VA@a}t5׬ 7s@oXm <Ĉ0_~CrMŦ">Lve4eʔvfLݐXBiC49ܐ?T|(?ϋP@ޗ?o}X\|OQ?XssΜ9QzN>b:f^YX3Fw;#O<0BF*tӟ4 E8 ŨFQ.%o<ĽDx,J{Ҵib1~iȑK9!{!+.6x(EVv]wUvmSn⑃=GbD\s$S<)e} @ @ @@G:1_~ym^ӟuo~?W~@CkҤIyr,ǃ A1rC}>AuZf#T;XSN)#"K5m[|l[>ˣ Žzh6qQbvک"4%ѧO"Q!Cx џm}Cm6mJwAW^,YRAӖqL[9p"3=XT-%Æ +kΘ1#]uUqHQ̿[qM6Ix;UO @ @ Nvunt1t7,5/A[J;C!X}+C<>}z"[ouqvƃn 8ckBomzew塚?,ȁ./ׯ_qO/pGf#& yoDYxq@LY|Fl?cR x(C^^9u^7?6'BqvءV[:GN<9͎#0`-!6#*H"p\b*#W^YLc_\s-H{Q5)="Gm=>F&S?S6\_i"ps|I\;}#7k֬wS!$@ @ @ s)]4^ @ @ @Ftsm]HFf{ް\_}Mx 3nMϸ~̍Xw\Q[q,Aag?X6:]_ "0/~1}[j {*Q CrK-JLpYg_-piw̛k1EܹskƌN>-BDl.;r @ @ P!m}2FV7Y óF͏cҕkz_~Pښw=O@KV=7vvy9m/#tBm) ,(ȏCK Cr| Q|/׋Y'@ @ @ֶo&z֌ A +cZrS^Fں -կW^v"l#@ @ @֦wnbHp1 NT|?4D.b(rn޶&>|#ExJ>  @ @ @|x;vlΙ3'M:;D!-ZīigK;Da„ iNK @ @蘀Cʡ~!C>}p 4%ɒ%O1… nK!L6-=sE!1%DK\MV֖#vp sS} @ @ @*# PАaO>.]n zF]szh1U+P @ @ @, Pְ\Y>̝;7-Z(-[ҕ #@Kh zJ}M jwС|1JB|-X Ũ ]h/4]Ǵ @ @t_ @ @ @tY.uN @ @ @C{wN @ @ @C: '@ @ @ @W@;'@ @ @ @]V@v @ @ @ @+ }ޝ @ @ @ @.+ eN  @ @ @ @@x}  @ @ @ @@x] @ @ @ @ /_ަ;ݻw9rd8qbٳgQ @ @ @i5-j!BwqGt:th q!C>:mlj @ @ @ @` <9v]iUv={vvmӀu{,=iiԨQ:vMV[`A;vlѣǚtk-\0=EHd 7lW^~ozUw> @ @ @tԪfm;|Ӕ)SѣCOXlY:s?s8p`:#Cvuץ76l8∼<Ol:묳Kָq>ԯ_ZݯEذ뮻:/>#Ĕ%~z}V @ @ @ U*Sxӈ#ĉ;t7xc1@.\Xbc^xa-|{C}I1DDȤ~;#wyE"vV|󟯍~T,  @ @ @]]@ࡢ=_WǏF!FJGMgqFq/1z5151q}sb|#hJ{^zi_Z)WrJzgMp@}[ b8{ 1ō7Xl{ӛޔ>4 l& M?N>" ԧҤIj=D`!_/ϟK>h> 2X~'i5,YR^i})b믿8׾s?7+F8Zs[%| @ @ @ *k+vg>bd#ă\򕯤o{4cƌt~b( ,(w(V~viW^m

Si|_xENasI'  V? @ @ @8;No߾N JW]uU?QlѣG:SBl>^zb_w3-\0]s5bډSN9C8#m1]F&"TKL%7)(?S!ĨF*QƍWL[aV!OuZ <s,E!K$@ @ @ @+ }ڪwcdBakx@AkS;[o5]xᅭU- Bx㍋:9yԆu!Z+1FarHaԩ3l6:C\PĆnXln-/;. E=e @ @ @CE{lUv[1FmFb:;L6avJGnakβeˊ)|fQ1]1E]f:th:رckxbĄbK$.KÔ)Sv1mY:cj?鬳JgϮw4:裛+Bq]ws=8C @ @ @]P@ࡢx@?򎔉' &4{ޑc̙S:"pצ)FW8c0@1EL;rD`3J߿S^ty[:GիW9rdKUl#@ @ @ @@xh7ja)˗N#,0dȐ"0pzH;#mo߾if @ @ @ @xXpU *!C @ @ @xTwW-, @/OIDAT @ @ @zz *!C @ @ @xTwW-, @ @ @ @zz *!C @ @ @xTwW-, @ @ @ @zz /  @ @ @ @ /  @ @ @ @ v mh鲢}R>| @ @ @ Pmύ7ޘfϞv4`vK?x~رcuꮼht״i}VcG=Yݗ\m%^,ο.[O덫Z9/-O>f͙ FnO{. @ @ @^+a6k^|4eʔ4nܸ"~3tn%_?p^iGM]s߯8-}񥢽k;p^=h/\3|Y[@ @ @ @mxh .4bĈ4qeC1zbZ_upGg6ām| 묍:ťwtlڇYkE#YXci}kzaS~2Fe  @ @ @hCۭռKÄ moM7ݔƌƵ;¥;)f^yqiђeK1B.g~|ayGyIܢ-[8꽕hW}#>ؼ6, @ @ @xhW4|7\v}9BLOӴ90jc?zXhs/JڴOb?|&xf^Ni xn'nbzr!-3Oo4<~Ҙr[_+p#OMGM{min!+]>ӼE/Vʥ @ @ @Zxhe/bǯr 5"0vش6=l)!roz|tUiA\}Qgt1C{P>M,EQ>ni7lU,G]S P'}Ify>}e˱#w>O鿺~w6KoS#Qewk4y _Hok#C^gS #7T_5&q޸nsuC-J @ @ @ @CcV,'NfN:/mH N KkA _Y.?"hp`_^o?~t/ž1M__6t7l:6}㐽]CKuc۶J50Z* @ @ @ZxhݦB1f}}{?=G@`ϩm?෧]ؤ݁iSYi*5/j! i'>}jޚvj|OL%h묹ϧϿ*j"ʄC]/o5~[vwnO־_8Q<#; ?ޒ/YV'sWse + @ @ @+xXQ5K!n'fCy70E嬫nJ|o?bt< Fmc+ L @ @ @v<\xkVaӻj#9;}kP=S2Ic} iC:P٭'4uE.ۍbi&6OfNw?:3=z~}Fhoὧ^X#"4qʡ{λ$-^XvQ鴏56  @ @ @X+dR>}V[m3,\0s=iĉivjy{KˋS?{Ӻ={_>.NK_|Xϣw.!F}ްUsxؼF?R|[BoZ}ߞkKIKgc"uĻޘij'^9b`/xhQ}ޢRL5{wy' @ @ @*& O6h4jԨLv6mZZ`Au]oܦVW_|o߫n>B5n鶇!B/oܗ~c(FyrtӢ%K1OLwM{}pi-7n,ݴߡ麻Ng7m0;77'Q3i9޲Ĵv6*1^F<\{Cۗ]_kG$j{ru_8`ҢFe @ @ @x`w̚5+}i:äIҖ[nߡ;~t#y{3o c ,FkF(Ws7o,-{erm>!Y`q޼C:m/_v=onVg'/g,MzE{8C3Z+ @ @ @CO-JO?t7o^Z4"B[J޽Ӱal zՖV{`߫oJF\( m ,"p|0 ݠ_wtWKuL6K3<_͡<*DLp#Mak;Nc݋Fŵ҈;w<&/{Eљm[o<2xx"ا׺El!"{*WK:~NxsS8pB @ @ @xhWOKkf;V~_%5OAđCRLqq3 !q{Sry|Tqo++/79;Ea1SzxH{dsy RL=1iڌʼ/h䦩5Ҏ8׬pg椭{U @ @ @ @kb @ @ @  @ @ @ ^i1 @ @ @W @ @ @z]ϴ @ @ @t{n+ @ @ @t=gZL @ @ @C@ @ @ @C3-&@ @ @ @^̙; @ @ @hM`vJtF @ @ @ @j 4\K  @ @ @ @K  /^|릭޺ @ @ @LU|饗RLcѣGԳgtqǥ_~9uI'f͚ƌN}zʕ+cÆ q衇Ƙ1cZዪCS @ @ @ @!\>(2p '* [li #<7n,?cY=! <#F(sk! &D|ץ?0֭[W81rRqa1cƌ=zl~##<2>ؾ}{v>;mڴr\$raE@rY"e]vZ9 @ @ @' 0k>wQ?lذXdIjCzw',laɓ'GU wqqǧ~{ovEřgY|XfM9+yr-Dn1nܸȠDCV[Kqdb%da _d=0gΜF>a4Ac @ @ @ @@pxm%rXxXzu]0EDծ27|v[=eʔ5kVյ/vQȭ+ ުkY !+"T? dx>}Ѫ^~o{Ȗ`Nࡪ0lذx՟>nr:cƌ6mC @ @ @( G70 3'N ^{!+Ad!-\z饭۷o뮻W{.^r-~/;cǎku~oO2%f͚U^xxWs/+R]}q' <  @ @ @ @` tjޫ uvY`ѢE1nܸէ~:zrxX`Al۶-:Xxq (d⦛n~/Cw?+}@5\S*@2ԐlY!"+Ed;s/K,͛7ǁغ7֯_V*}VXQ~+8KEGV.Yrelذ!F˗//T"F˖-.%@ @ @ @ < &iӦXtiEB~SO=պf![xիWڵk0cƌ)lSN3gFnF*A-s}y!ǒmȑ2üy5}9>7민s΍ɓ'c @ @ @ 0N[ <76nO9oAޫZV4iR\~奒B^o#2o{;C&Lh]@Ɲw{TfBCP/}hG+-ZVJ*#S΅H\M.45Y8Sak|x2U\)EN[C$*%sƨ!fs6M3l#S+O(Qc32e{Y|^lH̍Qbg/f4#OE S׎u%Iz.iAH47NcSJĦ/X%3yE p@(``>M=x@h4) &b'DB  Q A!Ѫ. S5[!Q ~W$#ђ#: r:6Dk4a^ΰ%1J$q<ǣ5w=!ppE9SDU=,0 tᚚ3x!7M >FbA0'r4+o5|uŕRFQ)_{j;i{({eԹf32u|{ז2v;cMÚ6U4-^O#OSIkk\H~Y92qņo~!+rwu@?~MdϺAegC`~+dj'kq^00ĂD f.z`X JA9X 6*l`?h :Up `ABG1b"Έ;"aH4"H"A].G@Zu=^^ =z%Ow30532v0N3z \lr &V1bbL;&\ϼ|?l{pQ .zc4(HhTf{cqq&;&d&[LN6??lNsMEI643gg7?jk[8febrYS~KSHK6vA+{$%VXS}3[ZXLgSo-Vd;{oso;j8}s7;v8NN"jΨys11c\...2F]2iq6ƭwf'WO\&-qkq{wvAX|x-ox2<'y~Km]}'gY_oBþ  ;&tXt  ~vdgBqsbeaaIaUawí³#<#F$DFE5uO<EJ-nN8iݤ11Xˍ]{'>.?q'?w&03aOĐUI:ӒߤM2n)RMRũi䴝iSæn3sZkM?7dF#3uffH'I25|# X^+ > \8+ k]V(HT!sUّ[MݗGK;$їHN2U4S,-voEvtysܰ)(V<@^mӜs0˟:ryo[,XкzažEv/.Y%kZĬdQI7ԗjJe27-T&(;_Z^QaܾnheU^&&hzkvq=k}Wfn8W1bFFƮM6VoP%ZRƴfy͛͂͗oij|֮b;q{;w燺&;w~%յ;~:ﺺ={Vգ޽vcsKö}}??=9k9/59MCu쯻[>bxdQђCNJ ;uuf뭓SN^95Tg cg>wyM.4y^/6wvtN?Sţv<`_zbK㗻^:7pu7eo~g }SԧCyCCR `p=:NUTϦ*VU` ([అʭzb0@=_*;*Q{|-~ m pHYs%%IR$iTXtXML:com.adobe.xmp 2106 554 FaiDOT(NLy@IDATxUs (FT0Qk9c^)D@D ]#(D90 |Ts{{:Lj`}+ׯֹEN=BC@@@@@@@ @*q        tF@@@@@@@@B#@С\*@@@@@@@:p        @ Ph.       @@@@@@@@t(4E@@@@@@@        Ph:KŁ"       A@@@@@@@(4 ͥ@@@@@@@@@       Rq        @Ё{@@@@@@@ ABs8P@@@@@@@ =       FCT(       t@@@@@@@@B#@С\*@@@@@@@:p        @ Ph.       @@@@@@@@t(4E@@@@@@@        Ph:KŁ"       A@@@@@@@(4 ͥ@@@@@@@@@C ڮmy{?/U^z ˑzT=峥hے3zΓ%3%sOx`}߃aG #      @b:$5Y%Zn>/X\n:Z3KۼcuM[@@@@@@ 1u݋Ij]1ewE$-L[QJlJ/sL~ G I%tAPDС 5      847D%֔J]CxY"w8 SaZAبCW\A}v\>GJϔݻȹ6g=v`9"     $F÷i{* Euz%Bn4@~ S!?Y{|Q.;WS(](p9H@@@@@@Bޠ4zAʕ̔21 7Kԝ,q5x@n:Vl,΅j}̴ jfKa=|6#     DrtRNmQj~kvkU=?ȭA܊ k`@@@@@@AC.v[V&w]"œCZ]Dz~A]@a=\A@@@@@@ 萇ɝz\au6vsAiZ5]iJ&XXbLi~nZiRB)Qw(V5fcWQ6%d :TZL)K˪-!ێnRDXY*" ^\..,+2&d/GnEn,ݿ\8ٵ[7=yQRWxln2    Xe>7eqkְեAhh1*Х!ҤVU`|z,yjۑdڍ2׿}H)sdd؉]·=eM1wsDzrTr35m:F2EBXtlHoRWD~:7mYG%k6߫ :1`7ϗ3cgf9 *퐧X$ŴOw٦rTz1jP;:hd1=V2v`B)yڒvDz~[khCXmڭ %wII;N;qmu2ڴ$y2{u? =p?WzfnNǤ6o/&]NL*VmV}T9u 3:֦%T申IfjjW6Ӯj Hκ#m'(1nEK-_s]Awbu{F|zCb\yjeJA.>b^@os߮jFy{@@@(/]wjPN^9GLXr#VN:wV׫e?8ٚKz>k6#}Q1E^0RDȿYС7 Cz\VZM Y~w,E-6Ue˶]rNCpӴ>{B} gv)$K?7c9!\mod;3enQ;KYM]In믐eç#S1se˼x8Fhvw~Jן"M}ok^\>&==rïe{냯y޻rYv!~m!l{E>yZ)[*%ۺ"ǿgo7%O? jKQ76.{6s<:hL1d.Y^zg-+u=Cv>we[i˳Co'r#Z3NR)Ń۱=>3Y*`3sT+*]^nN?N? 79˧nLï![wH':IN*&g=Fp{qͪ!߹;wgJ1.*'m+12QRyxl~CCJ/+Aٿ۶\#+z-iڧgv-C!W^ oI֩ٹ:G|s2vLymeM$_^o)9SO޻`JJG] #̘u97#ރw[5ѾPIsGM}=6zb!!r^>n/!C5{ʸ)B^I;ޗs~]C>fyNga۹FI;mЎ+>hiXGبZha >?YQG,tp' \Jf 4JNJ:cmН޳%E 6u xS_VO41dRHs= :$ ne5 b'/--}YǿDbz%44q\y{?u2BuߤHj>E goiP~/\wy5"C%NHx{ߞrr Ora4 \j|~:G_ͳ?=e7_$MkWPeomMKN+/odݗ{O݇wz[eSoA2ŞT冞rHw)G{k瓱Ӄk['ipz 9OoܺM/Z! 9&eJ5K0#7Ho=qo>s ϶̈G^>~X,\N)SUD̀xn]MNlԻ6W;o&3yX`a7sPL Įw*4]yM*N+%8蓞KBa7He\~|\v֯ΐ@ݛ:C2eU2ꗿo(Y.9nCSPߛ>߉^ 廿4P?^hĂ#vO]LnsXiug֎z*I놵Ip-RBשXwzO&w(4_We!/7fw6 Xcaq߻#U.N^sKkF'ił1<qr d'j0/{СUZҿ~{ ۆ_vdkCe˿~O!Ax {:st*9C;]X5Y:O:+i+˵Bݺi!{ڞڷ'˔u ۓٮYcn{z܆=p͞vߘ$k&y!T)딶vHU{;V:5눶cU<"JkP[?n=PMl.IQXU6qb.DA9kRCخj\Rae⮳Wu"a%i:q^+,bOmwu;mwMmV ~YVY6IN.0JTa`iPEݯiX?;hW-U,mmkڌ%f3BXUkڧvޟi?__~ێ!5e>yv^۹o֬UCB w̅Bˮ~W*PmW@@@@0 XU48ɬNlxsdͻK̭AӼSU]3iṫZK&ce0<Sk\=h7]ʅt%խ?o-HA;me""A՜jk|$_tseR\i&d :Ъf5^!P^tqˣu^C޻ ?|p+YhNnn|jZ {j! YU뜭S>{w0IER#rl!'*dx< 97bu [CC8b`nu~,r۟xzirstqլ`x` ٖ֕}wzg8}j6h?/PM ZU p:mlt{>ܖ{Ugh}t䚝m#dFEtyToCZfnymnA9IUc-o1n^[n2[x\& ˂xa,G@@@Tl$9크βwX:M9(,OB~KMDؿiF{j܆Oؼm\t6Ex{rB*aA T{j/l$AkMgeѸЊv.`ӽ[ɑ*"^`]HApgj`Kֳ ioUސ :o-<`OIgCTS:-0`Of` iH ۞kmZN?RΓF5ُuGLؗA4ӼNj;&{5+5/ O[ 1Q"2:mx>{,_Ivd*G\ҠC;qY%uk##~"lH@`X[^-nI-\aYtqm(Ƕنl'h |kܭBVêhp" bP*6JxsWͯ-` Xu;ko}Xm#Xa`rzoE ;n|s"]O}Wn\ed}esvgG)~;o1>p͇O0$pѫCl,5LhUAT"msJJc^Hp d{G׋M eD :XU 6ϗ vylQL.QD[5^Tj}x`5[&'ApsXƿZ$5o-V:uw[Gs۬ռӠ›2w/ nQ?|y`ՒgM` }/oqssܶ|kꯔa;G;hI 𴪹͛mؾv[xap.{?*cA0M@@@(TZO! >B.TW}רGt{(2B"h  ;@Q];ȷWycxG`jՠtau@% txErO㞰a:,p`,ѵ}}tx#iL*QYՉ^aWD :*)oL7gvd &נ]/L$`J o}uY ާv?,7Z~[TJ#֓g:\@CU`uڸE.};]b:sxpߗA4b#7l;W+yٝ?fþ }r B:c_+ѿa6)m.b.A})`۴€bC Migvj<ܶ1_8[ݟ3P[Q.|_pN.xBwh0b\rЊY{۷]AJW77]狞y78-ZFsF\e\Vڡy%yCa;& "/&Cfa DjV]`\A>kJBǭf֯ 4񙓠cJ?h%}k6tIבGX:'kn i7BNiɛIxJ/땟i:R=b ۠yڴvz&˴3(Cs>t P Xz v ۾VU>=pQd dN8z nm'^߆I J`ƶGtqU:g B!dF]IN6:U V&xvll&0?~7qImXf˾}M77lk):--^W./sk/dsCD@@@ױ=e&-g&l.l{ߝ"8Xq\ ZgmWH1ͷf;+jGg9:-ԡ)+6#MRbwڱYEn; :<>xv6q܋;>?QJ(h D`$*Dx"— lA;k@96։tV X} $萞92fryvHf7|Fn6F}*g[2My|Wtptp Y?- `nk~qWhUkV]uァ՟\N>jspi_z[-:nʜO HwYHGlU˗ pX:\pBJ$y'wRa~g)_t!,"JWgjN~7~JEa뽡UQƒUqWk۷)Xe ޸poȠ%bU!ވtȋMJ%?.O^qբ\b( A=נCcռ"DO@ۓ3Vu?^(!Z?@;( =KCκ5]6L/:J 6t#gG|oe93P,Ru`uȄ 5`k×9o|u;|]LW`tXO`/ߪ߿uХuH# SwG rm wCm&IM`6Ts'mXuR|-&kӒ!k7tT '&Vbi\\5aŘ   5`{k09^9T}z|ђZ*Y<8^;sxM]3gAS,k_7:{e( :LQЀANWO/6K|,`a_ ?`OZa?.NyުT/j!vhH ZfZ?o_ǏR?>+_uNlvB Wɵ!.d5}$+"A U:^0ʍg6;??:*+uBoJK |'d{!v[&wiu^gol&6}t6 z'xXu3Yy>9V) t\壝ӠC$pTvssHmx eKl*R7ͤߊhtxNx-%9!|QGC9^kZ5]?{Xè5?ѪHD۾,`-m+7c)P}Z!(%|V׿zo_8Oˍw D5[Nq[oåcm# :{2 0M,Y"O rP?Up uA6=<8:,jn1\ۮ&W.:"   ʕN)| :T:UzD c )CWXE־nbZ'&-ӿE].VE'V%jxԠd!/Dn?BW {味Iځ!oWatPiY!f.,+ư qY!V+PB 9CMo{)S2)oy :4W9uG"Z@\?,̰5}bCSX{T ]SXgUhS6C#ڕ #~,}nj Up 4`C5A6⽖/,("ʍ{+ZdžsxN4xk8"R;\iڮWy`wԖȡkӸUZbC.n=tXasVB*֬cv0z+n73s?gUpU!AlH #,l^x-J)ww%z>/oߗ $+ r[ߏxf{}Wz߫.Mя ڙB6Ϯk)rhH͇.fy-^u߭a*ծP.8:pXzrtȋ0 :uY B3 PۀYÌ![Ur9bs"5ƒsW Pu"'tA^gA阿nVB! f>    @aխ1s3kiv-znJ ܹ%"`U&Vn.??&{$b7}4A<??􄿢^sOGM7-+so;AA,lw؊!|hAwnWXp_,=\Qط~wy]K"/la-4$ol;_=}l'}MN={-Z@ ;|=ur:YG?e딣thW{{.yovn"TVҡYEyp4} |uWT Yg@kjK8^JAxEϴc:nwj5 p"]֑ZKcg=[El}"5PJ:T)WVp~o{w]~T޻+0Ex`mj7޾R&9$db{PUD$`ZgɮU(s3j-s,[Q>~ Q!X b7W07baBJCE :\^Pɖ,STw̪~\r!NhDtʊU[:DR)\j^BKov]Z\9qșwnQn:~7-'C,pY^.Vmյh)o'SA1*H_a"f!}t~8,sNl0ahpym~IFCi[8A>ݖHZiޱ4X%Ҷv`>1+GNEt-wUohJ|:uip[gvܒ,_,R[.57qmX}# ?U %p_羭\4/A*:)y   X ѰRT!(T{5:ˤ$x:>3Fȫ6l)?0XϨV!렷x㕴sgv,fl7nj=0ֱ92f`#A'ԑkƯkoa-Z O =bˇp jmؚUϱ:ڮ}^7mxIG.9CY/W0(p?"utV yUMJ 1TP6Ӟе SmN燍8Uv hᶹ/}ܕ[Dױ&Kn-wxtޱtv' toSK7e"]Z5{to=zC^o(_>:-Xix_RUț5RlBn˞{ϛl۞lQTB9׃ǺaA%Młc D9 :هstWMBϜeWC^|Bg -֐nUJ/M  ApBu59 QyUn:nTЀCnھ : !o-77BNiir' rs2Aw*UpA?{"iQ-;hC0D:@tptY*Rͳ׌]Ed2wY}ny :u*%/mvEz沂?8!    p |ZQvҸfɫ١gK"O]q^IJ8tWwiO17]e'ד;Ch+uL 9>z#dvԞ Nl% LT)_B߭kim ajSguR?U*Y O޻cV(]+7-ǜ I9[>ʇސ+eၣ 孪ȃw+ʵ/fYgJc}zkX58IŲAct@+`&J!f8\ܹ>wUkiפܭMֿrrtȋ ^\32"Y՘kGI1;+h\ߧ I^j6Ċ 7B6eWNDpo9 :wSŴzkV"~vޚ,)9oj5Ku[G :ؐ65;k>lC.#"=3lNW+hwnC Z%䦛^R.eWHzٖI(&/CCArsܶNBn&/--}YA    A.Gyk6퐋 }j3L;WhGYС?9~mr?qveq<?uMpyŴ&ǥ%}qЏJ m8 HoxItҭ} 9uo8t ua)ڡ8/_)/ Ttzo2o{wp78}[1e<+\Gjk2ŅOg]HA;z4lz2t(_&Y>h}J>зt䡎L٭}s4秏[J- `n#m{2lHe_C:#ulhhJk7m:S|5KքVAvn9 :1ZȍҰN%B6C`(.^xgˇO*Ekڰ9@g|unMUp*1тuP%z۪{m:Gs{U8z^taMM ob7fO~tDDŖqo} ?Izu>JOߑLYjem[h Qī`OO*6νy;@V-~7.@a?ӽy`gr[ߏs : 7 k_X`7l8ha$?jOY0^tXoukysY:$QCܑ!_>M>MUMN*/:Nfb4m|TiZtn.=rClM>?OkgA~'{wD!{-fIހ UdߕBrtw[9jl'ݯ#U2*:Yo0l$p@IDATd_׎Ui+׷uSC߱s "h T v>pQ 9MUydt}2x,=;Ʈ+\(›SM eK%I76.,%gN9W[ނ&cylt&yVՈJt Z-@1:tEż_37B )7kk.b| pkl.} :9pE{}Z_^,;$b6V}J$:DcS}9dU&R0 \q tiu^g>#4G{7V.ƶJWV1 ymR^F-7=VلM voXx pąL4k;֒jU'P\ul;hZ_"]]? ifې8 :nca[ynr!]; {"sG2aD\X{^~⃲7iK9a-Z ^V:Q]a#: Ʋ'm9 sTO tLVA {l3V;N~q^fnr^~Nrv[۰-Iqþ :2ŦcV&0۠C"?w9n['VgK⻽M?UsIi]1[J&ֽmDY.?:c@@@@a)j̄QXvjjҨF-B,ho9֑ۼN5_=)`UQ٣vVX6lvd+YP؛ Ҩf L kzEl ҡyY%M+L:×)mՑ4VU@jVN['ǖ ߪ2}^ Æ8^ 7i9-Mj@li :etH tĝN\]Otx[7ȶxAG,vuaIr`,o2|j 7|B'H6dRegMD|J[۲\~#^.qYnJt_܏mH/_"Svك_;oԭV!(RAa3nQ&GmZnkYN5(oG@@@@@ȭA\SmV}oqmhdUhp-VG=]}\D['6} aC\Do˄7u1*H_W#p\Pj 2nnjEtyRAE :؜^$6ą5[g\$-_UK{r5ZD4{_x1{E :*[,@aƮٹ[B%i#mj`µH%ݼ&n;zÚk䈺[5tVt 3ݗd~YPV&,*#Vޗ7콷b-]ͅ>y֡媣W.3 hөrtu8l7{Η[}_:ud4g,]}SAp>#     Fs=p9 ٟFNIGN[߆]EWV!*KXEۉu*vmHe`!QS*ɹmySlfX(fuLľcdr:1nϝ 4^hZQuy :}Q]ï^#-#iK!_N%'A[ΆY9vZ|-u:@@@@Ov Aҵ 䬸^F^$rZ!w{AtHǞ5{~>VƷLol6VQ_"~Woh      $ZC h7,uqx`mSʑZҍa h@@@@@@@ A_N\!lr0v{<\zwk'Uo96      O tH{|Y.䥥/$x{Y'\<~&_̨@@@@@@@r+@!bXVJ R.p`.ze3SuٖQ4A^qtmQ_,W iqY&"       S9rJTIےzN[aG0e{1&9ʁ'WΒb{" ғm/sw\f/)gOd.xz2{u4        yQΓjewf[;|=|yq¨k 8]Ee蟕d߅Ese>       @:qxUIM\dsVFYe2wEyy9GK]yszIfn:T/W{otYns^7_կ*Wjd]H\R_t=ױr}z`mV;\T )R\~B&/^]MI2cWNI3s]1x<][7~3vO7       pPzul!M5`9i"^;)sVeufE\|=]Ij12];Z&?k~Z1PUd Vn>}t= I္y@+U:HII.mkbl jBud$ѯ|9eAv>$+*x:D%c      P`:f2w[7`V+ڭk4_ "uhAUI%hQMd>]HtذmYvړOr'6-œF= V_ݿW1~ ozСnoBZNYi[s7 amVyβǂ      @ЁCstGmkO8TV,MrmN( A;O;-jTK֧YAXiV:m([>7^AoL3x6&m:'_-CJҾA58| mޮt|mz%PS9^U\lӧg(-YSncd8egl]KnUͭͳa,T`8ul\Cie%ugMkI*Za!CZ&2n%TNՊ dŦ4GWz>VB*D2Sэj"b)-[M s˺RBiY5}ku9d뎝2YtdM-YBuX%h0]_}-ovhCor]ߢEzA.?],_*E˾+K7lϖ*],$5a~ڤzs  ^sndhEVLLNj-dS^oPlّ!k6WnNOs~ZۘC{@@@@@@`tc:UhT6/V/-{sVmr'mu'vzsj5yZNl(f\p󚓠ÕǶc&v/[!A?DUL4`lYtR^7) W7=pz{)U<ɻ;E9IAXz y8u}Ir"HIN nrLMwz[gkw?=~ilxZ׳Y? }޽hgÝz~okU(B̓EP]u 3vipgIlk(B]wk(k,Ԡt}blXo/I]jR-5o,&ܵZp/;.F6}^ɋWռNHI`&|?@@@@@@)@!ACkU;C;isofy+6 ?L?iqqem]+ƳzO̻_'u7l7|9ZUȭ3RM'@i0AcZJ'm="ZoCG9}Z>=mD[z[aOE[sa|{ۆrvHZG;3kǶhOXG:Xe{*Vfj /|7ۂ0vsj avTzr7&:c)}.ymQl߅Γu tSk נaZV$XA#W" ߵط̗S~|H=/fWSG :;wѾo 8q /d۵qJ7+P&5-=u,0/nyaVЪ1Ҋ5mX!cwn"?{&E},Y $PAEW@Q@U̮9GV}Vd k@\,YWEQ@ d ~u4g\n@@@@@ "t xj ^zVM8bi@<_쎪+*vٶQbâN;;i0܀J;Tۙ?:qrUly萪/f ;Q+?WMOpאo~aIPwvkUA-2Cܸj!u^0Tv 0x-^K9oz/\ð\aW>k{ Y%?Tx뉂zw q¼5%ڏkzA5 0inVWWQ#k}]$ݠCCUP+7賂] 0WvrkTڎjӃ09a}VłKB/\A7ԢvfAE׃$jt(7v$y5MTHQB 1NDsADS 4| à ^7ZpOt&*&P5{GsP07ƭwHL65oڑbGM+A@@@@@-@!+݂';Sj,\[8 (~4='r >G={{۫3Y~0 ndC*h6AzիYL mHCTbHK/m۝㺠b=9+\5xX!/, m4YKWyMZa6=oAyIG0˃)Ƞ349Y|$jxPm$Md;dPV:p% ^|y졺u\t%ڋǟj^16|BXD(r&n(zxTo]m268޴v :(?5AXL/:/     t,͡+7u;]G\pUɂny-|eZ֭aN_$Z4˫tPo[aK^(^n:A S~1)7''/>=-Oi} ~UkOloWJ ot^m2Xh+zM7x:= _gSbj&./&2ӟm:nС$__{(4rڂ<=umMyF͂[\!n O3[#rB!J9~u=~YL *h;~KtT9'Q`݃a0y[ƭW?i8"5UP%       PT:t8SP^[ʼ8\RCh(5ɠ#аjZTH?),;]ctϠMY:D7`xaKJ'}|JE FT஑_ _ձ:r?o؆dThTPdAxAg \;E5]Me_Ky O[aؼYKW ɚi groRtoN=XfW`jV`i YAuΪCCMq [[M-ۿ!A@WpU(]dWбTc_kXGqߒz}MŸ\`( W+ 0t PmmA;.!9S5}'^{q:9TFUa#      @ fAWJɚ_#O󔃛ء,t#\ԹԸ쓂6 Sί7'ϳ!+ʕ)8u\m:n^Z'^ǺԻ pWO9ת9+GNŊrþi T%t:.#zkߟ:{n qRnS W 냎1hX %RO5t{W!A7&dUMWBNmt* cOs[6g 6g+z *f|Pضޝ:|ftM;EÔE׾c7nС^t?Tm{g֛p17{QٱƂPo "7QQOf-6 ; "ЭUCSoiWа6um6>SAf, WSn}&/Zn~^~ߪĠ =nJ>ՠ~ ?n W@@@@@@(tth Mj=?}}9giй@3^A m:\J+)z}jT u(m'SiQzXu :h+nkj^QomS矻/ l S`M_}L;[9n^uLomٽ~ʹrN:itmܥFCd(pҁvS.,\vA6S.zhKlmy}(sȯo0nA+1ٶQ7=*:h~IMQV4˗3lVJ+C5LÜnt ۿ;~+y|qvlP LM+RtطSF4܎pmmA#R :hc1%}m     t8csӼzW.ܿ(kWAS7=q_jܯpz*N53C m`KN3џe : :wtom@SȠ{jSùVLJ~ڛjAu eZ[JKWN? ]tD+S*qUia¼enR_4#~@;QBa !AOgd?w<_6SQwm^.3; aZGט翜5~f{݂~q>T%)rAMn9ǫy9pj؅u:#˂pNM=2+|sG5 ?:8*8U |i3 Ixؒz}:KTo 4TPX{/R8> imp0-•̬`5W9a-EZ{2ߑ/TiΨryۘU$ aih      @Q(AL^k\v&O\sL'ٶxꕃ  C?1:iTSQ%̂"B#cj(x7U_h\Q!ptPF훠2ZVÙTū7y>Sݴ 6CCԩ<R ъUDJ+7⾌b{UdOcyVҽ[ihmoI%"m      @A t(ha@ 3A@v=wMK5U4 Jj7nT)3     mEpt @{Uv3Uw+oB`@@@@@@(@С(^ @BklR M0 @@@@@(@(#3ʖ1ӬXL^.@@@@@@۱&       ABgw        ]Ct;D@@@@@@@B P@@@@@@@ tnǚ        P !       @tX@@@@@@@ YC!;@@@@@@@.@!k"       @! t(dpv       :DcM@@@@@@@(d @@@@@@@@ Av       ,@С       D ݎ5@@@@@@@@:28C@@@@@@@۱&       _ Z!!       :Dbc%@@@@@@@ȆAlO@@@@@@@$@!+!       @6:dC}"       @$X @@@@@@@!@!@@@@@@@" tJ        Pg        IC$6VB@@@@@@@ltȆ:D@@@@@@@H"       dCC6'       D @@@@@@@@ >@@@@@@@@ AHl        u       :Dbc%@@@@@@@ȆAlO@@@@@@@$@!+!       @6:dC}"       @$X @@@@(4IDAT@@@!@!@@@@@@@" tJ        Pg        IC$6VB@@@@@@@ltȆ:D@@@@@@@H"       dCC6'       D @@@@@@@@ >@@@@@@@@ AHl        u       :Dbc%@@@@@@@ȆAlO@@@@@@@$@!+!       @6:dC}"       @$X @@@@@@@!@!@@@@@@@" tJ        Pg        IC$W3gyGLR̿oSLbJd7?)]{ X(ʹ(C@@@@@(BWhŊo5-25j052TRhkOډ>)_|T`?~ygq*@ˎ?hf̘a.]jׯoZliԩ){uϟo5kfZjU       PrtRkTXq%y®t*+e˖իW7~h۶J@@@@@@tXvʕ+c7:RWn;e˖ؼ-Z:ٽTg d^ h芎;~i,!16olÓ9n2:tٺukLB fw7~oe˖5_|iݺulڮ^03?jaܷqw\gϞmp0@@@@@(Q%:믿>d_nԙ7u?sZ{az/b2ٞk|HI8_r% N83ە/̰a)jLR5Fa>wС[d}?j*ӰaCzSE^zԬYԭ[ԫWϾ:w     &PBj >ʧp Fq߿svw̻kq'?P )\tךowz˛x*U*Ԯ]=\A!@@@@@@%PbzJoO7jtMy^ŋoݮsGN;-Nql.8tqӽ+棏>4EwLnҤIs:(ӷoK :lv@@@@@@2%Pbz2|ƌn3{WJ G\rR:7l`}ĦML:uLƍMNRz}fܸq4S*޲eK_5Ǐ7+W_z=KnI&v*T̙3͔)Sԯ_r!FP6uժU3x`-Yn￷)hҼysIWL03S^&Lt`oV;vy(0#g]vڙ}w&NhьW_}o֬}:]4LGa+ؙ^?=6j$kӧO7?ݏ7out .49CmsFu.l۶ԨQy)o[n1+VߡG}4zk׮UZʕ+g:wk^)Ssu,Z^Ot^[˪iTZtN]UHvj5k֘iӦYf߱hO*T"q~}PwΝkm=>֭[Pĉ"±      ؠ*8_"*U2lpgQl7d9|:T~a /؎r~ڵkǛm)hMCv;a k*=we˖śmRJ&|;!<ݍS駟6 .ĻZVNPVت_~ٺuks7t_M= ({u~x6(P+{P:]S {2tWɓvt,6uu(4jo#d{]L^rv=z7VP5 ߺOT&1*qUWM2:dJ      @ :;̜})_M=UVu2tk~qgFig)@{ڐj !?vGOgu@*̠'ׯ_o) n5m㮻!ouDO>i+X}]^U+t,U';= B 6{l:+Vhh~v8Pu :M.,!?=MێΔ}^ rCMǩj{챇}J^U *P@M~WShDM2P(kÇ })RUUP554Wڶmk UEBO8nK˩Bvu}o{d_{w{8¡J;U!E&gU7ްǩk:J'4OM6˾@BAzi]N=T[AoUuDA%~>RU4h`(7Fhctk/]&x :Sa      PrJdAqCSN1G}tVSxUPza9tPDд#<Ҝvin}3gQǯSN =ڨ3TMOX0wǢՑNJt, T({f^]g&OӛV9\qԕy׮]ƼwޱTJk5?هFOa_up _4|%K.'pٳgx%PS,!vbGY!z~93v S :oWС9l&JAc$ܱa/ߡLs*80^u]ab O=ZFpV! px>'o5РA6L-ZOGMYgfWtpo?{_UPBǫJ^ :%|@@@@@@d Ƞ<:7nwQ!uNkNO2$"PVǧZΕwr-$@%Ҿ&_^O~oVE?_3TE#^ywܑ+oy7-|QuBk$ ѫ:S :|ͶY'5 CcQSE mo~A c5ԇ*H7;v߸{S=u{*N)|_oT$T!3kٲe#F؊+! %-Ai߾V߾{˗{{Ue:VF= z8DsT      @(AuDCZMruU;ۻwnpzرzUUkfKkX ._|a f)khx}Y;jVݓW!QBUpDxA7Lk,WZ:6͏} as?7/ X\^A=\p%aUkgg\sA]?&nnݺ\q@ !nY a#TU4*UnUWT! l ;G A` :h۫W5 NǣD-R0I:5M4C"#     %SC|A裏+bgD0Xp yox WkCӦMO\Ńpaڵvɚ硇j9ds^z;.ttp!]]dگ)^jڵk;tPhFdU~Á Wc(ALܳ 0K4̃:tpj$cPKUU>E븠CJ%vU bܹ"ǟ=A(j     t1cmJj?̖-[b-Y$Ô)S*s=$U:DA?m~7@:> v$Otشi h:unݺ>83=LϞ=ço1{FY5{xa_| 7næh      @(A=Qޯ_?y]nXhAOw;ΨҁklWJǫc1YS E *~gSti׾&:&>/_n;]G[ J'['Lk~wn1y7u*n; |(noSveʔ1WܼTѫ*8l޼4h{Njժ}IH:Ց/,047>ZߥKsgUrC7;t7jE= 'Wn==#oȣ>j_\vڙK/ԟ{! {^h<<oa};7'x?ޯ֭Ki~/h>S y S*UʜwyN9]gUضmIq*޽6g=UPHHaE5U9{\P!P(Z6YA#tk׮ma/UQ\ʕ+gC3&tp"     tP:(zZ~Pڙgi:w/b2WPY~ul2^}UaԁWr8w-cItnFfh7:E)jB:ZFUVXajϟo;ٿnI_9ߨA?!E.;|;0 SeQq9Ou6} /bNPxŅ wngA4T(j+V4C i;Q+:6P:v*хbܼT^OnnpڡCبYfluU1b: /@KVTpÇ7~aW˅{Dsa)8m6>Jtܐ0Es!R!HfΜi~kTon&nyp"     @:Uƃ6 ,QSU:{[ֽTg uMB\R% j9 Uz\S':7mUŋy-vi]4OT~uF :hRBCK1꺨ڃOS*1{'WCǩBtvZժUm%ސQAO?dwl໣{a!XxXH+뾐twVDU6 Nkwj hxA޳ 1($嚾3j W`颋.2 ۲er #)bTeE-O]UqM"yG=r '$xE@@@@@( %>.*6|'P7]lܸT xmɱ^uP'뤟7of?o9rd!/mڴrc:C5OYǬ:OiI{ :yrږ:O>Xk:Lc7^3f.?r:UPŏʕ+;S_O.3m۶'{Q.Zh{Aպ ~5W=b7\sMENS:Ct_T5~|;udN=TR\5 o!}&M=NB9ӰaCsWɾgu5l˪UrP~}{,zMy{P;ܷڶw RAM׵S/iZ?fܸqy G0      P:nVgPPy&MDЪPܹsm'б$ PdlLFk[U 4D^mfڴiv e7mec_T2XdܠNs]#U H-_ܨ*6h 2Z^<PMPCR~S0ۖ-[U=CCOF~s,\ќ9slCM67ܳ o~9)XAS9 'eʕFSZn޾|g͚e+v{OU"h      Jtԁۿ"~^2/TC:\xڼ{|gv8>}d81      A%'.{n;v4w,)'WK4h]v=+V4lE=X @@@@@@2"@!#FnjаڪU̝wiT_%h,ӊȡjR+"      @ P.w'_=_\9C:uL5ڵk͢E̙3mܥKsgȪy… B+}QET\9@@@@@@@As 5 ><״uwٜ~鶃<2[n5mڴ1{WM:-2}YxYfTRQ;5ѵQk۶]vA.׶ǎk6olZhaիgmܸњaf*Uk0~xn:{,:Ll[υ!P,YbOnc1J*Kӌ3O?d=׿Že(0[Qٳ͂ LACg     P:oڵf̙Fz652u鲎3tMv{gsϝaBfK\ٍFa kˣG!?bA]vg1p@ӳgB;.]uWݓN:ɾcu@:e˖ݻo=ed EPW_5s=/Ҕ-[qH^{7niذy׊Ie(0[Qwix SjUf/    ctXf̤Iv͚53ziڴil^[7SASn̶m}Ѷu]܋Jv\(T;AXA=Y_LluCU"(AL{In4f!    @(A :>+ޤI;܀;LkO?yMm'KA:o\AO*{Bݦ*iLX{i&c= -4jv    @ ؠùk.]2d9Cc}&AYc5(!7QDT3C5&Lt,yK/>c:E}gm}h߾y'b[Ԃ)SĆHyMb<7-P\Tx D񊲎K"@{#:턛)Nф    ;[sh@IDATچGED "֣b`;ۻcŊ7. ;"bAEĂuy8_6ʲ`﹮L&;L23Tkd駟vrH8?ݻxx!;p -{ܑG{n_{u1iҤI,?t~agEq;m]!_~-bUVi᧟~rcƌq&Lp7[p,̒oF~G25\i6ƍƎZh89ysL~m7L3_5o#g}\~j߇5X`\ӦMt*۷~뮿zwM7g 2$lo۶m$+[ofݺu oZI&9' Iz衰'Nt}Q`ꪫ*}2SuYst'OYfkGwb袋֭[gFA?zN6to^xp 2~LOut\u kF8v_85kۊ=ck6ד/7:7,QF}Mң>!?|ѵԽby|:`|8-Zk-w'},v|RE/psu|q.wiwyg0Gnr*lYe˽⼳˭_8=z.?8nAυAK)^ePi=?vuW;vRup:螓D]|ubJ~IwYg`ϓٸ\2$x' :ܠQqGѹfVF0Jd(5#\ ,㶍V f%CfU#߄ڮ2Z G <F j ?p8/$2\|y+/ M *F@X QǶnh$k|dYawڴM\t2(^ (@_DԁW_Y| CB!? 2*|NWC,eS2X0C>OJ {AU7&C6lcIk-%0}'D/,*n距+p2b YƝ<1 [mUh|={ˌe?1f TMۋťh'li>KQ߾}N[.b;6ϟ}b 3'RK:V %gFA ZZ.W$bҽ8k$,{X9%)lrWz?Xe%kzw:ʮ"rA,\}%Kt/SAvo,ti)Da3B݋m]p2eqEyJoܦX*'meω{T3lM:TZG;֎j.%fv T{/OQR!EO=.-,}V,TDU{+O%wBKH*:蹫_vzGTP^$ݫzVQN=jeg @ @ L'|'u ~v!Ys1Tt8Ӿ~Tt7ȳ?? $zIG'\ a+\<6o|I1-[E#Yk;ɻ 񆣰MK ÇOX֟ܥ7PT;C:]|a5wmPS# ~zl!^_~I!I}aGuolKKI 7ܐZ9jGxEr]{ʥm;c 5+Bȑ#l${QZb/ g ('~R>^T<>'? ƻ۽a{Hv '$yorWrQIo*7N'I #9>M_z2z_Z$ޏK"G*Ui{]s&Gx⢐wnztL{ouc^>f}B޽Wy|*OkU*Nxc\7%u^%\UǩLb qɡUy!{T9i1^IaR+~Herdh%~.dޘuJ^<C+^tٴgՋP)S/Ky ~=>oVV/KyWx/LKV>Y^S 6/J+yN?WmB:N?q˥}e]Z'׶P=$~^8#Bu6/9PjR&}b횲caešw2xapu\= ^ӳ !գI"V @ @ 邀Uht'TܸTyHuf[#G lfkB /tf vj{gFU l'/Ćۖ^z5^{mme2dEg.$ `qA{" Wy[8~:8ǏE*qx=bá[gƌXl`T #Lɏl.GO&۔w`J|lS~frl?2=#u`y#tHscs^$C~|lŻO \%R%SOMqb"Ȅ;&n2C-B\IS*+V@:~qUoeW{HX?v *w˶g-Rfe[nf?MC^\Nɪ'+:+A`$:ԅi]2j3 T8[z>T zn(=^sVef|u#ryo8qɘJ|V,//vcKŘ:2-*o]Þi[!{챇Ed *}'L?u[VTqW|^=m@ @ @`"0;8bna.g!GoWS=}l8_s-7+ּr! j_Nwvɓ'WX vNr+ޘ\ԛ[ُڋ79"O.HCn2ً9Ÿ\+K]Bw8#N ]"NΛ z̘1a kji5 O6LِlX<~$i"WFCOtq1bD-7ۚ]zѐaWs/WݏGf7S*K5M_ivyO5uE] Թ(QՋc70UW]5L"Gm>o m=jWyֿ{OϚ.uDrY;g\S>䅺[yyQ|{{^XT_]@1n +mBFuaRzh1o1^TkJitM4 T4M-iW@ @ LAFu+1sJu@2hO2%1vKϽ⋮[nNeQs饗NV\qqZ3H)N<2X:\s|4: >+[nI`N+%tI'ݬt:l @0§r~Լ#(Sg(% 2/RAy\y%B)fH^+alxoq/ OY7׮] 1$TCF漐e:($<[q~=Hl!aK,o:Hph .Oֺ=YVzB( ~6D~J ޽{K+wV㸼{EWu2~z罩R׾}_)W z* 2[F(Tz+ge۳y~:*_{+K=8{,S*~{) B:EՏ=IuaZ !㴞Md-H wZ@K/z{1p,܏j *}NJ ?KP:TL=G$7|u:Hzdž,%j۶mXj 6/dW&G|靷I&A<\տzQU#J7u:~գ@ @ T=F)tH !2h4{ǎ]ܴAF 6Xq`OA#u8ywWλ7e| &AO.]yʑ .Q2 2 *:J!KF~ Mo^!6d?fqyR<3F-n]f (fHV-ZS+27|ؿfwb#wFDs!cf@ $xL -tÜVZ)<68ޅk֬Y\d]bYVzbذa ?L'Lp /!fV}"aHgG&(G<uһۏKGh>s Xl;-:s^|Q0%6W^yExCx7'ZƥM77'|%xyWO<|5/$i.t\z%N>Zq,Ζs/.uN^Xʠg‚T+<-]ݿ~i4ވgF%۵biTrR=X:]P:TKuYPc+رCOO Jr0kdj]H\.n!ny'7|Ӳ)̫GF@ @ @* ,U_O:Gϒ4f|+Ց%tׯ_qғm2N+H0N_uf#̐~m?QO _V'u*!’Vk f9٦b3HX kIRbF<9(NuǨKC"627z>v(S*!S/t} beni]AfT .$^π}=)6'Y{Ď_JYsR꽧g TJR岺G[ږ{1nϕޞi:G]rڼZ>Qj:馛nJ%tJ~b^ςYx3~vXr @ @@Iek3t:Eodu{3fLئ}o>:B\֭6mڄߥ 57'??ƕ[Ǚ{tMr)\ڊv[!M˖-|~kxY4ǵZ-EΏM|u :ҽѬY)vkJ!Jt-UN]W>u,o[֥~}=vKmi.yސTD\;nQIɩ,qѓ.LGI:QP}{^wTyB[eT;f:} KKd<'{*-"&\tSiQ\Vy NJb,/vCy{ 6T퐂gYPZkmeJBj]TE}{= m$PY+y'{7a|{Sz;bG47Ϩ @ @>VKNQudpN`ūsVFmcȰaê22hʰ赂Hp駻eY&Ne:ujK/< wRzu7Nˠ* qX/N0ڦ2<axx]Ftu @+b[op~u|QUC=ԍ=:\X c &Qzu#{ʔ)ΏTTH0`A|hDDDݻcC(^O'>u?Y5T!ĆSO=ӶK{p~4g!P>t2AD8:XH47|s/O #+vo 2nyas2q {Gρ;kvG Z}Ѯ[n~ 7p І<÷d*?;T7+%# 峼T9"D z"d<2f t=U'rC޽k DM~$s89hbeZ.WKG}z$:8]n9,+>ڧe]8s!ќx,^/ܺkQzkdWLڇx{,tPfԅ׺z@m\2 ʈ+..֪#NX; VGyVR~6(JRvBm [ybϋj3 D1n _i}B%Lz&XGAتUPyB%{> T]nAa>/VA"PA  @ @@uhBC:ăC矇ڥ:nyCo.] 0YeRzyg =~Z]Sg"F6mUd uN2v+Crƍ:۷o_1$eqF6[Vy)\jiE2A_$I-:׆:y3PUSd\p+*>ɠg0314>Vld[(״n+ux^r󺖧>>RPjRe5íPqQ:[_])SW}]mw9Aɓ'ƆW:"?qlCVi\m^^uewKvڵ@4l11jbݖ{aK 9Kգv| @ @@@P׆AKityh0>N-=W!@ @ Brr2 =k@ P{hРAx+RFJ @ @ &С Lw:LwC hjUVY87|[dE @ @ @ʡD@&pW/իH̸~g׿7e׵kW׷o_ θ'̙A @ @ JC%s@ @ @ @BI^ @ @ @ 4( !@ @ @ @O&yA @ @ @ Р:4(^2 @ @ @ @> tO@ @ @ @@@Рx @ @ @ $С>i @ @ @ @ JC%s@ @ @ @BI^ @ @ @ 4( !@ @ @ @O&yA @ @ @ Р:4(^2 @ @ @ @> tO@ @ @ @@@Рx @ @ @ $С>i @ @ @ @ JC%s@ @ @ @BI^ @ @ @ 4( !@ @ @ @ON0qG^ @ @ @ @۶m|:Tp @ @ @ Bwɣ%Ӑ @ @ @ X 6lU}ΣB) @ @ @  t_: @ @ @ 75Uq( @ @ @ T)Uva:T8 @ @ @ PU:TpC]@ @ @ @UECU]Uv9( @ @ @ TUvAPe@ @ @ @@U@PUUv9( @ @ @ TUvAPe@ @ @ @@U@PUUv9( @ @ @ TUvAPe@ @ @ @@U@PUUv9( @ @ @ TUvAPe@ @ @ @@U@PUa<:|7~p:urM43.,o>׶m[7\sn @ @ ԸO?F:SL u\]V P/NGtǏ9眳d'[Vɓ! @`. A% rSou֮_~A`q7|{gX#FiXb ky睷`{ou'|rRcuǻѣG.,NVk}wwݻwkCg UEY뮡wm]_@. *Nuo&nV,ĉI'l;Cr+YS%:kXF=EvmjmFm6`Z?S݄ ;.|,0c0/C4C 0m{}W6ڵ ߵ .`x wM7@@z~DZ)nv7|$Zz3UVY;)V9[w} C~@`&Pn}?z!^4\pOy䑶),Gk޼y&w\sMV[%T{}y[nB7,9$;V?Zu(?}-O>E=߱S=5JO)Yg(}G"ڝr)I-x  P'ԋT/f>6eW_}פ ֿqqw( {n9Yᇝ}e:. ueI$|1$Jv 1? B*qG]& '~,ԠȨӳgϰ]+/bfZE*ҝ2 +PhӦ;;.rKaw/2(R>*:&k{YfR@UB #,.Zn~G;=I>Թ0tPRABvء6{'C~/AXRAmԈ#B;[ouby5!?S{DyAsV! B[df;,xxP!CG}43"ms m^뻍78)ozψGTFƿX(B$s馛`*fw )=3]l|J?/{scB B)㙼H8C'^t2u%#uiV";ύ;6$`* Al)hIbm⽻vU^UqN; <8mCo#[´ڲ$^y-k V x tk]B[]v%(y7jԨpzC"AS}Q0i #VJNAjk}D3ƌC[guFHeb {>'x"d)6ld|&@Ajg&#O?titF(A'ONTcN\ƫ:-{:{jٲYqr駻/2df͚FɃ1Ku2w Y'SO?<Olߚ6m~s>՝r;nܸT"=壠sкP(쥾W а~i$$@_=8f~8q AqSo;i$xnZlY_qꣳz_yBT: 0~j=Ù| Zz,״ K yv5 zK} iW]uVP"o$h)Cs#d׿^zxᗌ0x4nZh{jLʞ LH%hgqwygiN-n{,NKenq|,P[ bm'\MKiJQU ńB܆[\Ae+6 ! :R-Hp`%T竂7\s5^ q -P~w*N#8~cq(&thAei7sb3:J듼>=TQcƌaUDV#þyZ:h*]}vqg}!dK'ÛfG|.wDYm/Oj]yRT&+}@a P/Sx^(̦K-VfC4'CuxdIdq7p`[Citb힥g X:TK(B.\ !1Wy TQbQq^?l[Yj\~lnWЇ>*,2"Y: P>GLJmm{prT`#5uQVgS=cg7M[ѰVRݢzǼJmի̣:$is!$ӱKCHKwu׹W^y%$MOaUj(}}XTR=gU͎ԋ#t%Xhu%6E[NHd /4nv[N-Qۥwa ˈG#36Uv}Uϻ[o5ULǤ'a6}EH׿bqY_Ѥ=HjJ 4~gQo2ؔYBџ@/:{}am:][^Bߚ&(z-I  `,:mW￟3f1bO[~޽{;Ց ;w4EZ˭JIմڲ4Hb5vڅ=:nⲰX:TOâ.w߂3;v;B ZA~#I*<7I,tjܚkN)s'"!@* Gg @ldV"5$q>P83p@';s/8gyGo%?2r-a<:Z[hS:MK8]vqlK  գ'6LCK k3zz $t\z5+}|~8L5tOOvEcz; sOò8-u; due-W6l07a„;nB B**tN:zhwe:H)$"/5j ])vR_uUaRmqڬRF} $PLI&:FzyСtG׿\߾} N\C6K># yLvP*%tЇ>i:[oγA @,(Kl i'Y#BQӊXG0ԙ,Q[iy,4%ʣ%thA'ASɽ1Bpi\'J^$XD>ܾdҺG#2k)Zݱ8{G!MΦu_a!/~qX{?񹦅Fq5ׄݬ3C{iu :0x.]$w=~b~EشMηv@qh^-V*m~* /$+vuW+iA/Mk"^mP줓NroV(ޡKٲO:Gw٫ ?̰:T٥Vøq9J : 欋 2)F:؇PAAqwz*J|Qw-2C!@> GXDw hF</ڿ6'*f)}.}0j:5MEej{ƫ ńbΙ% @GO? ؔk0Wua-tMC ;쳃.:~CW-w (1.PiG?|7f̘PG# TZo)qE "ѸyerVpYJ 4C+o :%2;WM[a#z\rI^{ŻsD^#cgbKd6#@ u]n/F>MӜ]r%T=nXC'%8`%{S=NMt&Se=nȐ!qqº{6P:TVN3! na+֠ɨ ͡$? qO?ob!_^yd.a{MF*eD: T7kB{M!:J(:vYF +S6oF7a>zG`TXB4 X3NIyJyU\:g!KR]ߞV0߾Y:TX?GzqV/D`MŚUyqw< ԩSL 2MiN=@67#Ԗeg:CLu̸:Tٵ>6dбF#!#BܠeU4ij>L(!32eU'7GJ)#RV>A@uu-bvYA]s?>)UJ/fI`~NYT:H' j)CVf}Fgm# i'?q({F)' _Adp4C+Wq'GWeմd A @%Iܧ|,aرN*vڹc=֒wȘ%tʄږ8).yA4񺌜ҩn7 q]]qT]87k;3N: pkUnI0rHzU ;"{>O窫rTŖnB^yWxX:TNI*d5(nVZѲjt,.,NK5\z_ao,?bwڭ[I 5e]A?P^S)NǗPRcE%aȑ"Z$X 邀}ŅU'~u0믿7Kپ>dB? PLPN%/ wygKN;-g+FjXsjN\ *g.t^pCiA$$@ p~Gw饗9Mi䰾kpūX&Tށ9JMh80|K7^z%mvlh}D&PYDʭO#,A}z;( D{'3uTu!ޭrQ@`%@8\;yҴo>ACٲG< 0@Peװ!Uv 0HK:QC @ @ @C{Q:a @ @ @ @!tC]@ @ @ @UECU]:T8 @ @ @ PU:T@Pe@ @ @ @@@PeUvA( @ @ @ TUu9Pe@ @ @ @@@PeUvA( @ @ @ TUu9Pe@ @ @ @@@PeUvA( @ @ @ TUu9Pe@ @ @ @@@PeUvA( @ @ @ TUu9Pe@ @ @ @@@PedĉUV"@ @ @ @m۶dd2g(3 @ @ @ @ td@ @ @ @DytC @ @ @ 0@0^;J@ @ @ @ht:4K C @ @ @_kG!@ @ @ @BFw9a@ @ @ @K{(9 @ @ @ @@.9' @ @ @ @~ t~% @ @ @ 4:%!@ @ @ @/ﵣ @ @ @ FGC0 @ @ @ @`%av @ @ @  tht @ @ @ L:L׎C @ @ @>I?Mq?򛛣,lN@  @/?_\]-@*}w?tmڴqM4Ca 0_ݤPmt~BQy @fDxU]6sEkW\cgiSJ.Ni(/ŗMvL-:ܮͫ\@?p+fi&׬YbIjm݈#o?Z+~s&LΝ;V}״iS׾}{׺uK ݷ~\p>ps1رbCڵs:t(m*=R|jjjRSzX:6!@駟B8s/ӈOT+I&^zvmK.ZTAWF7g+wA9iz͛  X3wᅦw. @FCWdҡϻ_~=SnNmc7mϸMa섉w??u߬@@i3L$KݲeK7lذ͙ i<+}(ȸCeE;裃"NԩS'w]wUlDׇAW_] Ow /p|Q{lkuni+=ɓ'c9=sI>t;SL*=EVF{BG>}vK. |np-Xl  > 0}0YgwᇇА*vj^J""t+E٦W_~{ H![]v^y/w ,@cIvf P%:Tp!Nq[=$nhb`7%;oztXAL>t=`uo~)s5;~t_- j!!@I:,Y;e])7!v{' ]L蠎i/|e;︶mۺ; r~wqnС!in݂~;G۴l_ į$gnV- ^-EVٷ?v}3=5؂S6IRmoݍO q7gkgGתEsܤ.:̙징|9^egq,U,i1 ~tK,ۦeHO\V՗\A98(!JwwuokqCGrє2BhJZ5o޼ }]?O_M6$dVZJ O=}ٓm;s`z),a]w 9i),hڍUVg/u~2ܼa^m ꫯ~ᆴo߾Xd}Fo8ț:X;_m'{qKe%B'_}gAܢMʩQV2Yd$ &itN@("|GG/bhdS^}U7se]`dig4uƧ~ޓT&#FUӹs8d~ mرc/ޱJb矿V{c? TfN4j(o_!SWd"S |1qn<[p.VPg]1֚"k秴}O>s|k/5(5)Qo&۩V_>ƇZfY^F;5[u~vnNC'@ odw0hB)CR߁4H#=F]ߟ飲r KC?kF' )^m̽ 6X`6 il>zA7 ڶr.ɶ7~Ꮎ~)1lC.Sv0x;s~={3Y 7>*w'[iݶg\&__IrH0~-NYM˹YޢZ"t(  drHܦ Lwi'w|ř@28k kǭuYJ38 =\]S\"5\`V[m ;öZ+"=C8A^{-1N:xSX/u~z lY&&NJOA^#="^?0:HF^?]B*IbQ2!|h~Kx"q N^{;¼'|x(!@$+:[m ës]w(Y$a>dyR}mgٳ{zMݻOl Ck$f;%,Hl)1ib8h AUX<{7R]tM,! z?%a "r06hY~7?&[jy"ϛw]7t߰rI?X輾'q<]p3'#?շMOM >bmCzA(ǦK:/w}Go6?GUf\ʸ~6x=!E{oƎwpc>aSBMݰ['Y5uv/~ej=sm6u$}@Kti?{{ܗKqۯsYw=}=<䑡EY<-d  ׇ>41[~$>Q^ScL P ?;#X@P '`$V@.C?4Pso?όܳ6:th萗@#ճ}ytKŋ.(r"f͚^:xP#H" 2+=z^xpߩn'CHHQ8 ?:(JeЈ^&(_dxhX20(yhC @L o.SPJ˖-'$Mo!zȵm;u:Jk-W^9xxǜ0(TvFD)3<&=Ӊ!:KAx}6Ib _AfiDzZh!'Qo*( 0G^p=PF~2rl1USnq5aZg)rm2(K̛in~jyLyJ(ѹ}['U=NSWLsz7W 1O{_=uש?@el}z72yBhM S>^*3p뮻V]]ޟ  4jʸO;'CII.7$T A R|v.aj et7} 9~j} }8;=)6C>n=ᐩ#LGt&6l,s|eޮ}9BrMs!7} [wú A'oNM9_v4p;=C9H@ }.袉H!N٪U18!+:g4݁Æ @Fm) -Y<å^4ReVGI:%JyNK{De]ίSNaqlHC>Syq YBb' +Z2znwt"K m7ӕ U;vL(G`1Df%vO &tށ:~W⊐Ǽo}Q GqDR?@Gh 1 z?iU$^S;ci<%)"*igDyG)!̱! M݇_|b T ];!iǡ m~5a;ju,K%@.`K 4^FmeԵ믻=3wxw[:tkԽ)m[K3$0q@kYS(lrO{wӤ˟m Pbsb*Σ$1PI;c':ca#pv2Q IK. {n4ZW\qEɎNz/{@`TXBAw4+;aqSE`#cw)>/n7[Ͳq'XrYݝGNa: >SF>tԩ.@72OnKBLOKGeXB@%Сk ңäg NJ4LjX '~0xی1")$ K&?xٍǟݟ~vGU|ک]7耩,J Srk.ŲqL Ϩ&T0thw}τ<> C@,odBdp{5mVZpYgQY5Dc}L&)_{'#@B2Д 쵅B;+|ˣn;cl^~1}BJ' <M]1s!lqu+-:SÛcpW>+?qjwnNu]As[©n45_ws(:  9֬B=T i`+*tx:yT>>WxT5RƈJ[aKi7y9ʢ!: _}6`aС$t(jW^yuڵ[ 3*Du!wgDo}(g 3h?WϦLI7ܶ>G"t(>CV& PiH Diq򫤝r3Y^!]E%UP%q΅!qGt yg?-ްW)4eC%B[~]=-t.D!&: y2htRKx 4#ЫW0XJ&cF*hsVqŌ6QÆ ˢx:hWq3LrO4)pRf͚J.w&6UyyBLD!i2*EH.4(dɔYyO}&}^{fY?bP"́ݰs-Pc~dݺu:EP-y{A~̂1\rCa-b,%-[VvL 7 $˗WQ2 A䯯-.=cPWSw]잖 dȓ'wsݿz7XS1;0T CSէF6^`2w]\ C  ~ $t ܆} 6d`i>AH%B?:GПO$@$p(ta&I˝])eFW*[[!` G(p7izаvf>:#H7qXC|nKeR]F͕M{{3 2g6F+07b\d쫍5BC$@$?:,WTIŝf}Q裏8;wK켼3a_B!/Dx|^-0}_rEwl߾]=̘1C2gxqw>0/,dx[y7_zuNJbg [x.DA 'NZO]\|YCD 4 89yްﲝ0am XQ`H#˟?̚5+V'L_)t y+t;v<6kL0a4:$j6vU!j <\L㓱cǎ%PwR'`ٴK*1vuC`RZa%jX5O2S#=g,<GC$@񑀿y#ӟ2 7OÜI. z¡(ZhHB\HH6 |kw/03 SWvpu7gkQ6͋soOV[>ZF=ڭhdEO#=q#JBk_$C0d/:zݚfҦ=|6M?nx0fbV(Q@"&HMQ֣ORW?1?K`u8xKXx 3x@[ZXh$@$@7`.eȐ!2zh]109s-XX!,v0 ~ | *i|_BauaԨQ ::ZC'=zTJ.-cƌۡ<76A ހ$@$@I!<f,%wֻ[2I-,Wvn`7G7Z55kk*"=<:@q2#ҧT巩7 \kD iTt{aF8KJXW28 .^" ?o^z ܖ71zso @F$@񇀿w-/\! b@ʔ)eǎx`EaLYd3ʞ={4 !n~B:lݺU9X3aBm۶ q_{ӦM:}uqT˗ ={vl2yd]x"CztB p. }X8p,YR'O"CLLq>ڄB)S6>-5&L`XXQ7}u]!&Z!~ 411 CE,C J0BP3,`Za/0>9{-PFժUbo !'<^{ᄽ/AIP7N絀X1Co|O|_T)4#u:H !4oO>Dh: 6cƎ+JkC?2G7 @&@Cϭ}1/{\Y,w6 9ut=_E`}!'3Wa ZS*jfuvu*{mϧ-n@~ Ξ(SB:r4eL_Q-={ޣ=nɮc—lwXp{@zAS3h$@$pk %Kf͚9###ȋ]p;p;2|-Ĺŋk LQ}(S޷]1Wq:+xGqc(x{B#X> Ğ|d C`:޽{{zG[ᵢGɤc(n T8[I@d٦]ѬE;`.R|>o"إd˘N:+#'κH]: 5tŷw'G>M$Gzmso}$@$@y#4hc}o^j=VGd<"FOQ| @&@ >({H2Z"9b\Ey;c >-,rz<Э]G?q>yV yf2w0:߰z(=t4cSa qGl3HH c P%A1/_>E[M, ճ}6 @HLj p\rx=rz4pjnt+`A`3gI\</# ^tw ݊ aTP!EB ڵjc!]D+ 1 o}Srl<` O9-\Ik^.}T  H9t萊J+U8ͲHH +E]ˣHHH'O=_0 /̸X RNUx"B*RJN $:$6 $0g5,E > '{1zVHH@( Cv*""BV\O g֐ b       :x       ڻŋK~`ާHHHHHHH :YHHHHHHHHF>|X6o,G,YH…D7R!      S               H0(tH0%              Ё$@$@$@$@$@$@$@$@$@$@$@$@$@$@$@  Q$@$@$@$@$@$@$@$@$@$@$@$@$@$@$@:oHHHHHHHHHHHHHHH !<*VHHHHHHHHHHHHHHYz@IDATHB $:$GŊ P               CBXQ                7@$@$@$@$@$@$@$@$@$@$@$@$@$@$@$`$9ɓ'aEIHHHHHHHHHHHHHHVշ ~:IHHHHHHHHHHHHHH q!q?_HHHHHHHHHHHHHHHHrn![ފHHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;,HHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;,HHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;,HHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;,HHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;,HHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;,HHHHHHHHHHHHHHH (t3PG$@$@$@$@$@$@$@$@$@$@$@$@$@$@$w(t;I3/H˒d-c:m+Wz;"~_zM\mݦ KrE=m#STu֕HF ::Z.^(Siӆ\D$@$@$@ ȩSڵk9sfI,Y8   )KD4a40    Hd(t^XvK<5im/;KtidjZ܂dz8yްt3_ǼXB9eo$een6a?#{GNHhIo+Y $AW^ :K",ν˲j*ygwޱ·+r1U.\8`Jrݜ3gNɔ)SkeĉR`]tIv)2d|Ҿ-;[l;wn\s*_QQQr9)P@,FxF$@$@%Kɓ'"W§OjժiM&EUeH A7mQɓ%(mpO X yo]aʕ+| HH |"m?&;u+HbO :f11,4xiX9o k&˗Dg.L>,]i\<>|̙3Gh"Ǻ ̙3ož)2s̀58@Ĉ2j( 'EF{:t-[z }gϞzK~w9(cǎҴiӐۆBBmƶ,2 2DNjK)TDFFJR4}RNx7HH XfEdx8p2:ϴ\<ڵ,YoР`"eʔ][n>}GYի,\P/^\=DlٲEc?o<ɘ1x8|'F d,9 7;vի'=z0EJ EZj% 2rݵȏ70NǏKv휊4x̀BxHH"`& ;Ѽ={ȑCye_Eo?(tKR4$@񖀙7Bk׮-/6G 7 @| @CORu_u9ygҷV\Iy:8Qymٲj#ə9>&W,MekM/f* !`5k֔w}ONB4?L .Kݍ#Hp'8@&M\條XЇ޽[8֭[P=|GZ_*E_K /e }'Nuƍ%KIO?c0*Vj\jyyTݥ~۵k4lP!nh߾4,<x>{o  $O7p{4ڳgB~e_%!< Sb2:$d.; @#`Lǎh(t0$M$@$_ P%t0__JCɗ36QuQɛ5ȟ-+=zxl IuJyX~)UyW3<+غ[f2prY68Wl-e-YBZESzOInc  @ XC: e͒5kV]\ϝ˸}PtiI:uw,:9rDz)-lRB .PXlKΝYf8ѢE Rt`œwAdi& xK/$m۶5~>Eu,{aBVÏ?\[T,7FƍG?)['O5Pq =ý=wX w.رcrJ2jv3HHxe>%wnGWmsaoL0طontW!9y.F9uVJϩM$@0FxkD8Qk:@`9$`^4C`h)9*SO~ $]: 0iD AMgQWwxsw״B<\U˸u:ߗ3K=:8a @0̀5X&ұp {5mT^}U@,`g0ѣupkn;`63B`"*UxTnݺѲeKر"=vvYnX߰a1"##8PUl31};}2j(0,\xQRJkˆA @qjJx= bٿGb!{'9Cx ݐ!CT`2bi̘1ok\R>}ȓO>iO1 -$P|y7#pO< 9c d*:t^ k=y+WN oQF l=d B̈> Dw  !FaOR/i(H"2`hcy\3nR]b.^'Pc@3g "`wvX0w),O; b :غGLN:!0ER$,!/&|NT˵U/:vR^2] -ߪzG~-]g%@zF-!"}Ahm5(!ծ睄^&߮ߡ=xOzR(`߉o._Jgi$@$D X:@ELc;~uv*&1b4id…:!v߬g3.?sqŋU"E {VZZ?f $({>s{pޓYf B @'t۷.x{G}/.'m۶I5 <4ɰ{[ v` 2eȊ+2`NB=a0B$a;z7n:]` O a,0oQ|݂$@$@a&`0ydxǤ~-d ֬YS\IS0xUnIĂ&`f@X1:Ȗ1#!YEp,>i[Kc፱ 4}͊Rbi=t_~z*25fjRt=x @RzKC k;&U z3I2֫ٲ@>[s@e\d 0@߻9}t:Sv:&ui&ҹk"+>-w;ҦW n\""}Zҵ:dyxozD uڵn?sm@p!_@PN,ׄhWkyxL$@0%JE 2f̨QaY0Qov_K.8gB[j³,$:|嗂ݗ3̾K1Qy3v͚>ڗ?~nQ1.ZBWӧOh`f8 XXhʼn#X`l'xXٳGŭ&.񌃱zǰ_u 6TBX#?3B,v 0@ڵSWH{r,Fw. ~gI~ hҤIC? k&Lp_{=c'(.~;^"mȈo2'p-=eÇÐ^~}>.\p?"O0 ?H@ 23duz]gk^sz2A$@0F y!X/z=?ԩW`6$OCL)9`|$@$@A>`f>WD]޺:bޡ΢-b5h _V8:D &\B4dɚOeaʴ{X_ گۘyb5:eOFT<\}Ƶgm%[a&`%@d5+o?n%`qꄥDPpRyh b:P 0Mo=0m:b7t=kvbX^RoF$@0V_pEm,bQbIiFb;}guߜ ,$i/X(z|X8[hq)t0p߁JJ… }:k^us)[z.oa…`,&& "c.xcfa#?맗z?-ZnO4  [K.t0`j௯b<%yX(? \L̤]gB_A` &5CTT]pO>5|p dkϋN{ P7 HH?fifix 'wmZe]!nZ<ޞXVm9:DrdJ~MPMPC2s}<  ̼p[byyu%6̜9Sa쌍10x¼1tEgRBӏ1y2`eHH i!n9p?K:^Uݪnh8\.WU7._qr{le #'Xl2}]M3Vm WOxtgJvXW~H.rч]'O䵛&5. iUXy>-c\+QNɩf%DApiƦAF~Z?ҽ9OX;*wrM$@0V F~=5׫WOD/$#ćƢ2 o0 ~BB v ̝;C;]_}"`:&{KE\ {g߾}zx0n5YC>~DGG V7 Ll7|U`;Y70̗> vZ" E %`:܈G'Q&<:1zȼ gnB9u|ǐͪUOa/_>:Qbv*1&2O^Z=avϫmlB:,m` 8ҵIH `捌0CĀYB?PF9`c2bPOQ_}"3G<4  MB 0B ^0H^k7*,iQ5Ɔ*ۗ!D kVP/f!Tzuù,Bk6{\//hϳz^On7ԇ;ÿU %b o#‡ p_Kb _BYۆ/"Yz_~˅}WkN$@8 X{da?'W;ӯa޼y7NfNg`U`ʶ/^c%#BݻwKƍUxq"R耛!ǠAt )㏫g ޔ!jpڹsTZU0moҸ>=^ɃSp#">co  '`#OGez P^(? FQ^rʟ~{ߴ.B4 ǂ?tsQDi$`m6 a̯!dBW-C`B筐ykxxN$@qMi 6`9)R$L4A.oV>}Z^yJ)9*{7 @#@C<p,T Sa|A(;Jx1XPxmjcE#An-Wv,v0ŀr9)? h=Գ P/ӥݚk6{|Vza#:+'ϝD N\,uCcy4xlٯGexf-L0?xPB!*o=h^1mjڵ %rb`XGɮǁ>>yV.KZ' oNVFAk!81`agӎf͚ &ơCVZhtU5jN<\L㓱cǎ .(t0T ؽ93)]0c7xKqxL$72)Sgx9̙,x2OK/C؋E$tQA$@$ P%t8xDNV=%y{!h$rޞXVm@S*0B_WnҪʀ"BWRo.uG7˜˜IBF^MGigTΊnC)32mv H߀ջ!Cѣuq %gΜnw}C jx4a*THӂ0 (,¨Qt7Att4h@=*K1cP<`pš/X4B l< BM_%iUd͚Ug˗gϞnO<X\ ^ݭ6D]I&;6sn.\i`xVcA lcܹ{؅111*nA*UKHKpx0j '&,~ / Pڵ孷r q xB,h`!y+t0<K3M pl6v  yF ܹs[kC}9bI&zM $Q ]R0G3E8aß̥8A)H !7o$ 008Hoem@  eJo  $MB :XlI"^u5L`PD6B6@d'dXx^7Soן*qQp , +DEILV՛~2QwT)u [h'K%b >`{P7h\HEOk6+E =b&OԼh?}VW>VNZ8w:y.蜹8&9eo.tdձ%eb̫$G}_(+{trU(#䫗{ԼkDr'm];y<2 @߀ջ,Bc1.aLRvءPfcgMBRvka֭n53'tضmz_E7mڤk>o ׉NX| ٳ;͏{- f_G0bǁdɒ9{-PFժUbo !'<^{ᄽ/AIP2s5k`M,#p54zt @B&hs|6.t@ݫmDZnj;VJ*׆ eJo  $MB 󃿖cSD{ɞ#'_W&{o}b)]oO2a#QG9zuXޙh$*A^Q`c %K,5kʻ8,GFFʏ?v;;2|-I@ LQ r`fMH h/xo_j:Ґ}%)i7 v HH6 n?l f0TSnݖX#! 7NG=rw?!GUcvx ^! 8Ȝ.y}BȞhy av1vl-cz)#f$@$`du z8w, ճ}6 @p;{8޹rBRX |9 چ܊95Ofx84 ^!Μ9&q oHH #. (Tz!۵k'۷B)4VF+}7c4 nd眵ye5/UҚT 'q079$ :G{,  AB ~8A,e!4$  [B'+z[r[y޾[ɒ"  p8u ]ODC7J*,HHHHHH= #F,OJh0[ I:e<9G$@$@9sװA_,RiRp9HH /Jԩ%mڴl! .]sIT$]tF $M/^P>MX!\rU=eֲY $F:S=sQ~}Prf wvďo .H$RV-:+{yKtMX H^*ϟvm"E Yb{eժU3H޽cW˗رc*(\pb?ɓKΜ9%SLڵkrA9q,X0`yX|عsdȐA'`Bi_LL-[6ɝ;b>j WTT.(P G@v#y  IN̒%ɓG%K8Ъ>}Ȝ9s>s$ [AWȶG%O2Srm2hr-sqa- 73ot s2\rE\o @|#@COdo˅+++Q0{kdɐv`!IO:%;vի'=z0EJ EZj% 2rݵȏ70NǏKv휊4x̀BxHH"`& ;Ѽ={ȑ?{w}'O=?\!\$Y :D_$䖸oH0FVvm|~dHH !'zGXs˝U-)\:sDNVIBfeu [6Ճ{=rDp/GOd$ɤz-:0!HJ̀f͚q)t@SLх Bxrou%{vӃ$ E&M4ډ}ݻUPcxjhݺ5գG}^$`__PP7qD0`^7n8)YO4I>S=bŊq0ϩ̥_]Ou]ׯ%v% 6tڷot; "~Z=w9>s<   C7p{4ڳgϝ;'u7ޏxO֨QCWB#e$pCRpCE$@$p̼|رn&BC$@$@A>FNS1>M*+jaA6fLR=GO_7 iSRc^|LQO/\+: 3 ';xe8~ZZ*YPgΊsiyd'Ӧ; R$W˫Cu8lcU: i-GT)+IDAT[E<=h)Y ʜ^ܶWeL'*A">j3:C>L& ̀5.n߾]6o,Yfܹs{.th"B:.]ZRNF~_B#GI?x`P}]l2I.\f͔c=!,pE0 &y|jD6m̘6mR%~aK/I۶m>Eu,{aBVÏ?\[T'Q7#<"SXi'Ok8!>{0{{vl@;]ıcdʕeHH xe>%Ãpzj}/A T#ݫY^ay rw}o "{(РA~9}qn " D 2{Q%CWxOO[wKԱS#Sz)Wܑ'cIʚ9K/>' if>m)n=N mg[k.~AK{]fxyX=JRL.UHN7)GNRsjg"  `L6xk3g8? !aBy@}<=G/p¦ ߡ<.  $EB k#4gIJ:^i*6(=|rc]MRfu=-u "}ҵܦ清bBb+6Ldzw0?n~2r\\M{[dRDA89s:. MwgU{F&sY`x*mV~@ZV-c!(LD$@̀5X&pݚ6m*{kba#a0;zhܚ$X|."3B`"*UxTnݺ:0oٲtQaH;w,d7,oذASz}ժUSB"/QFGΟ?//^TRŚ0bvjJx= bٿGb!{'9CxE#}ȓO>iO1 -$P|y7_ O<&Ϙ1C(Jƍ5髬YFEyP?,={xw@h 1ۯ_?i= !7_~#߽ޫ@c xףmDx}gIB ?r+$-|9dI1b߶̕YӶϸ&.ߋ#گs[kR~l;?2K#_o ܣp H̼wLH0 0l01µfb@C'я?~}+WNo\^54v f$@$@IA<I5/ֱ]c/;{lB: tCSԓAyT8p%-N\;9`' (3lD\Hk}ҦzYpiA;`Ȑ6uJyf$t25 FplwXӠ$գe,3:#?Bc\|E(q5G ?# ̀5H``B? QQQz].H08iҤх,BCx7 }ytK?\E@\xb)HBw=֪UKzެ:tHP U { ƃ&X=k,@o >B}Ā}ŻP~j:`) l۶mҼK  pgւā%(aL2b A DO>I]{}߸Y Jrw07(([<$ Cw>ɓ=<|/ J Z ի Dk׮ށ,o رiDF dxrp|ڠkQOEHK5$@AshEsem<٨SZzUk7ZCb> y-}{#"~)d(B9#_ᩇr%؟A3XY,,ob`}.ї&H `78]` n':`a6aK[nմ>@Na?pMŊcݿ1 HH I!ǿdN`9gk̠ o\I*tLfL\CK^]F͕M v SzK0s*ճ<4`0=S:ٵt+ZrX*D08 ^1G;ѝJ4;\MN07aLWJ*q0:CyHH(Q-R̘1FEd d!R@߿ `f089ƫA0 Z3%t/QgVP!%7cXp>ڗ?~ݵi^ǥС%Ўӧ{4^ #4B v@ xdgF耰 Ьh{v4(& MݻK.O]  [B 9*`@ aJ 7׀N#fe _%|MdL"LV.]4H3YQ!Ex/SHA!hD /PCHLoݞ}TLc'/gHIҚ&)`fCahz*;+,Eld+ E j_\ԩy6̻a4 70?|I|/ BSN:_ؐ.;,!,TGYeHg Q15>84B'wϼ;Zp:`!`ܹn/(GBm_)xM:`gbqU8vB@-XBMw3[o<,y|>szcbw9I\kZ 1t8p@}lXc0_BW?CҺukiܸܹSn!(t1 DJԱSr*:FYmȥ+W=<ϧexz E耹0̉lPM.Ճ<EBmsk#)tp @P̼:"vN*B?PM䱛ef~I`B`Qߛ$@$@IA^DaHo N5b:v\c>tR}Z9Xb2~xI"EbP"́o >`,Y2MbȺutnC`T.}00&ޱ1?\yzL4l'9ÞO`LBc`ȸˤN$@$/_^Emxax3!|UBmV6lؠ"О={ wRݺuׯ/{`~7 ܌BE!rj*5W*2<]ã0GW"ov: $`0'{ѹ &l<|ofBs`0~VHH L(td&I˝])eFW*[[!` G(p7izаvf>:#H7qXC|nKeR]F͕M{{3 2g6F+07b\d쫍5BC$@$?:,WTIvz;.f } !//`Wv ypQ۷oWO3f̈ooٲ(@sjժ}^X^)WX?Ci_?;8!B*x\0f0a*bOBC$@$q{5G#\p$!v)ڷo/]C?#t޽zgrZ >Lݝ?[=4H'`ٴK*JxcY뀇qJF ydѝ9` =:&>]`yL tyoxի5EѢE;:`kK#   Xs5s {FCTUJߩ߫[;۳rsoOV[>ZF=Y<:zO\,ҰZ>)ɫ6 Yx1Bzs6MO{onk)#؃JȦkr??ziVS _12懵kPsG>\#=bbG# w)C{rgU9kLәE+JHI _BQ(X$F C1M"hs-^([HehCb1efYn{߽w}{>|s9_// (hJ^lY9SkZ~ HkKf4HI` Sya~ gօ 2srWgy̟?,Y. ./gɬ4@I֢{wou銉LO89t]z妛n\72_KU& :rr7뢵kז뮻Srz.N~:s맮di%Rb͚5.k>bŊ6v{y{M>_upC^/g;opUk_澸CCy{|׼r'>'|M[ |yR> ?ҟ"ۯ_;M_3C^yjժrE[ouʃg~ѢEGY"w(qu{̖!%+EvrM6n?Оm\;Ydsr9eL*1S2-sX`T Sa=bAܡ_7J|CwwGKolѽ>̬$[qlݻJdwQ.Ga2z@D Ihp?s.H+ݵDAfkg}^cӾ+9s*oQo(w?Y{ݟT _{B}?^|ew @` r37萷~j@]=t3f[f̶ag뵼V!@[@e߿˖rb/|Sԙ|z|g4ec/4p5=>ա_z񧞩2do@Xxw0NmkEM'ϞsT;N;im @vr'SO=Ug+WzsOj|=d8"|'띏(LȔ<%nqȱO=ԃݺuk wu[po c˖-"c׾m9VfرcG09mJ?  @ 优cY镔ay2{DY*aJ:3#S^˼Fz,]̜9srMm˳,-;5e;/wF?= и@o }HeƉU pTfAX[ތ  0y"39dƊK.d?A sy; @ @h@@СFT @`rvں,T3TL.G3ȕ @ @ @`Kq  @ @ @ @F& @ @ @ 0]% @ @ @ @#ف @ @ @.Aw\ @ @ @ @`dA@ @ @ @Lt;. @ @ @ 0dv @ @ @ @K@a @ @ @Y@ad2; @ @ @ @% 0]K @ @ @, 02 @ @ @ @t.y%@ @ @ @F8##ف @ @ @-g>*pD7G @ @ @v4 @ @ @ @anFR @ @ @ @@7  @ @ @ @viK5!@ @ @ @ :4*H @ @ @th-Մ @ @ @4/ |  @ @ @hG@СT @ @ @ мCM @ @ @ @AvRM @ @ @ @@7  @ @ @ @viK5!@ @ @ @ :4*H @ @ @th-Մ @ @ @4/ |  @ @ @hG@СT @ @ @ мCM @ @ @ @AvRM @ @ @ @@7  @ @ @ @viK5!@ @ @ @ :4*H @ @ @th-Մ @ @ @4/ |  @ @ @hG@СT @ @ @ мCM @ @ @ @AvRM @ @ @ @@7  @ @ @ @viK5!@ @ @ @ :4*H @ @ @th-Մ @ @ @4/ |  @ @ @hG@СT @ @ @ мCM @ @ @ @AvRM @ @ @ @@7  @ @ @ @viK5!@ @ @ @ :4*H @ @ @th-Մ @ @ @4/ |  @ @ @hG@СT @ @ @ мCM @ @ @ @AvRM @ @ @ @@7  @ @ @ @viK5!@ @ @ @ T{qIENDB`django-simple-history-3.1.1/docs/screens/5_history_list_display.png000066400000000000000000001122111423103303600255420ustar00rootroot00000000000000PNG  IHDRJ+ IDATx^}XTe? ^P?+_[fut| }]@ u 1, 0XJPSM51vu%[1a5%c%WiPÏuyF;t%gι9߹_~?(@ P(@ P@.\w 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-3.1.1/docs/signals.rst000066400000000000000000000022771423103303600210710ustar00rootroot00000000000000Signals ------------------------------------ `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-3.1.1/docs/user_tracking.rst000066400000000000000000000136341423103303600222700ustar00rootroot00000000000000User 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-3.1.1/docs/utils.rst000066400000000000000000000033041423103303600205610ustar00rootroot00000000000000Utils ===== clean_duplicate_history ----------------------- For performance reasons, ``django-simple-history`` always creates an ``HistoricalRecord`` when ``Model.save()`` is called regardless of data having actually changed. If you find yourself with a lot of history duplicates you can schedule the ``clean_duplicate_history`` command .. code-block:: bash $ python manage.py clean_duplicate_history --auto You can use ``--auto`` to clean up duplicates for every model with ``HistoricalRecords`` or enumerate specific models as args. There is also ``-m/--minutes`` to specify how many minutes to go back in history while searching (default checks whole history), so you can schedule, for instance, an hourly cronjob such as .. code-block:: bash $ python manage.py clean_duplicate_history -m 60 --auto You can also use ``--excluded_fields`` to provide a list of fields to be excluded from the duplicate check .. code-block:: bash $ python manage.py clean_duplicate_history --auto --excluded_fields field1 field2 clean_old_history ----------------------- You may want to remove historical records that have existed for a certain amount of time. If you find yourself with a lot of old history you can schedule the ``clean_old_history`` command .. code-block:: bash $ python manage.py clean_old_history --auto You can use ``--auto`` to remove old historial entries with ``HistoricalRecords`` or enumerate specific models as args. You may also specify a ``--days`` parameter, which indicates how many days of records you want to keep. The default it 30 days, meaning that all records older than 30 days would be removed. .. code-block:: bash $ python manage.py clean_old_history --days 60 --auto django-simple-history-3.1.1/pyproject.toml000066400000000000000000000005361423103303600206570ustar00rootroot00000000000000[tool.black] line-length = 88 target-version = ["py37"] [tool.isort] profile = "black" py_version = "37" [tool.coverage.run] parallel = true branch = true source = ["simple_history"] [tool.coverage.paths] source = ["simple_history", ".tox/*/site-packages"] [tool.coverage.report] show_missing = true skip_covered = true omit = ["requirements/*"] django-simple-history-3.1.1/requirements/000077500000000000000000000000001423103303600204625ustar00rootroot00000000000000django-simple-history-3.1.1/requirements/coverage.txt000066400000000000000000000000351423103303600230140ustar00rootroot00000000000000coverage==6.3.2 toml==0.10.2 django-simple-history-3.1.1/requirements/docs.txt000066400000000000000000000000161423103303600221500ustar00rootroot00000000000000Sphinx==4.5.0 django-simple-history-3.1.1/requirements/lint.txt000066400000000000000000000000521423103303600221660ustar00rootroot00000000000000black==22.3.0 flake8==4.0.1 isort==5.10.1 django-simple-history-3.1.1/requirements/mysql.txt000066400000000000000000000000231423103303600223630ustar00rootroot00000000000000mysqlclient==2.1.0 django-simple-history-3.1.1/requirements/postgres.txt000066400000000000000000000000271423103303600230700ustar00rootroot00000000000000psycopg2-binary==2.9.3 django-simple-history-3.1.1/requirements/test.txt000066400000000000000000000000221423103303600221740ustar00rootroot00000000000000-r ./coverage.txt django-simple-history-3.1.1/requirements/tox.txt000066400000000000000000000000641423103303600220350ustar00rootroot00000000000000-r ./coverage.txt tox==3.25.0 tox-gh-actions==2.9.1 django-simple-history-3.1.1/runtests.py000077500000000000000000000114501423103303600202040ustar00rootroot00000000000000#!/usr/bin/env python import sys from argparse import ArgumentParser from os.path import abspath, dirname, join from shutil import rmtree import django from django.conf import settings from django.test.runner import DiscoverRunner sys.path.insert(0, abspath(dirname(__file__))) media_root = join(abspath(dirname(__file__)), "test_files") rmtree(media_root, ignore_errors=True) installed_apps = [ "simple_history.tests", "simple_history.tests.custom_user", "simple_history.tests.external", "simple_history.registry_tests.migration_test_app", "simple_history", "django.contrib.contenttypes", "django.contrib.auth", "django.contrib.sessions", "django.contrib.admin", "django.contrib.messages", ] class DisableMigrations: def __contains__(self, item): return True def __getitem__(self, item): return None DATABASE_NAME_TO_DATABASE_SETTINGS = { "sqlite3": { "default": { "ENGINE": "django.db.backends.sqlite3", }, "other": {"ENGINE": "django.db.backends.sqlite3"}, }, "postgres": { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "test", "USER": "postgres", "PASSWORD": "postgres", "HOST": "127.0.0.1", "PORT": 5432, }, "other": { "ENGINE": "django.db.backends.postgresql", "NAME": "other", "USER": "postgres", "PASSWORD": "postgres", "HOST": "127.0.0.1", "PORT": 5432, }, }, "mysql": { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "test", "USER": "root", "PASSWORD": "mysql", "HOST": "127.0.0.1", "PORT": 3306, }, "other": { "ENGINE": "django.db.backends.mysql", "NAME": "other", "USER": "root", "PASSWORD": "mysql", "HOST": "127.0.0.1", "PORT": 3306, }, }, "mariadb": { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "test", "USER": "root", "PASSWORD": "mariadb", "HOST": "127.0.0.1", "PORT": 3307, }, "other": { "ENGINE": "django.db.backends.mysql", "NAME": "other", "USER": "root", "PASSWORD": "mariadb", "HOST": "127.0.0.1", "PORT": 3307, }, }, } DEFAULT_SETTINGS = dict( # nosec SECRET_KEY="not a secret", ALLOWED_HOSTS=["localhost"], AUTH_USER_MODEL="custom_user.CustomUser", ROOT_URLCONF="simple_history.tests.urls", MEDIA_ROOT=media_root, STATIC_URL="/static/", INSTALLED_APPS=installed_apps, LOGGING={ "version": 1, "disable_existing_loggers": True, "handlers": { "console": { "class": "logging.StreamHandler", }, }, "root": { "handlers": ["console"], "level": "INFO", }, }, MIGRATION_MODULES=DisableMigrations(), TEMPLATES=[ { "BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", ] }, } ], DEFAULT_AUTO_FIELD="django.db.models.AutoField", ) MIDDLEWARE = [ "django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ] DEFAULT_SETTINGS["MIDDLEWARE"] = MIDDLEWARE def main(): parser = ArgumentParser(description="Run package tests.") parser.add_argument("--database", action="store", nargs="?", default="sqlite3") parser.add_argument("--failfast", action="store_true") parser.add_argument("--pdb", action="store_true") parser.add_argument("--tag", action="append", nargs="?") namespace = parser.parse_args() db_settings = DATABASE_NAME_TO_DATABASE_SETTINGS[namespace.database] if not settings.configured: settings.configure(**DEFAULT_SETTINGS, DATABASES=db_settings) django.setup() tags = namespace.tag failures = DiscoverRunner( failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags ).run_tests(["simple_history.tests"]) failures |= DiscoverRunner( failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags ).run_tests(["simple_history.registry_tests"]) sys.exit(failures) if __name__ == "__main__": main() django-simple-history-3.1.1/setup.py000066400000000000000000000032741423103303600174570ustar00rootroot00000000000000from setuptools import setup with open("README.rst") as readme, open("CHANGES.rst") as changes: setup( name="django-simple-history", use_scm_version={ "version_scheme": "post-release", "local_scheme": "node-and-date", "relative_to": __file__, "root": ".", }, setup_requires=["setuptools_scm"], description="Store model history and view/revert changes from admin site.", long_description="\n".join((readme.read(), changes.read())), long_description_content_type="text/x-rst", author="Corey Bertram", author_email="corey@qr7.com", maintainer="Trey Hunner", url="https://github.com/jazzband/django-simple-history", packages=[ "simple_history", "simple_history.management", "simple_history.management.commands", "simple_history.templatetags", ], classifiers=[ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Environment :: Web Environment", "Intended Audience :: Developers", "Framework :: Django", "Framework :: Django :: 3.2", "Framework :: Django :: 4.0", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "License :: OSI Approved :: BSD License", ], python_requires=">=3.7", include_package_data=True, ) django-simple-history-3.1.1/simple_history/000077500000000000000000000000001423103303600210115ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/__init__.py000066400000000000000000000024261423103303600231260ustar00rootroot00000000000000from pkg_resources import DistributionNotFound, get_distribution try: __version__ = get_distribution(__name__).version except DistributionNotFound: # package is not installed pass 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-3.1.1/simple_history/admin.py000066400000000000000000000224741423103303600224640ustar00rootroot00000000000000from django import http from django.apps import apps as django_apps from django.conf import settings from django.contrib import admin from django.contrib.admin import helpers from django.contrib.admin.utils import unquote from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404, render from django.urls import re_path, reverse from django.utils.encoding import force_str from django.utils.html import mark_safe from django.utils.text import capfirst from django.utils.translation import gettext as _ from . import utils 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().get_urls() admin_site = self.admin_site opts = self.model._meta info = opts.app_label, opts.model_name history_urls = [ re_path( "^([^/]+)/history/([^/]+)/$", admin_site.admin_view(self.history_form_view), name="%s_%s_simple_history" % info, ) ] return history_urls + urls def history_view(self, request, object_id, extra_context=None): """The 'history' admin view for this model.""" request.current_app = self.admin_site.name model = self.model opts = model._meta app_label = opts.app_label pk_name = opts.pk.attname history = getattr(model, model._meta.simple_history_manager_attribute) object_id = unquote(object_id) 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 = self.content_type_model_cls.objects.get_by_natural_key( *USER_NATURAL_KEY ) admin_user_view = "admin:{}_{}_change".format( content_type.app_label, content_type.model, ) context = { "title": self.history_view_title(obj), "action_list": action_list, "module_name": capfirst(force_str(opts.verbose_name_plural)), "object": obj, "root_path": getattr(self.admin_site, "root_path", None), "app_label": app_label, "opts": opts, "admin_user_view": admin_user_view, "history_list_display": history_list_display, "revert_disabled": self.revert_disabled, } context.update(self.admin_site.each_context(request)) context.update(extra_context or {}) extra_kwargs = {} return self.render_history_view( request, self.object_history_template, context, **extra_kwargs ) def history_view_title(self, obj): if self.revert_disabled and not SIMPLE_HISTORY_EDIT: return _("View history: %s") % force_str(obj) else: return _("Change history: %s") % force_str(obj) def response_change(self, request, obj): if "_change_history" in request.POST and SIMPLE_HISTORY_EDIT: verbose_name = obj._meta.verbose_name msg = _('The %(name)s "%(obj)s" was changed successfully.') % { "name": force_str(verbose_name), "obj": force_str(obj), } self.message_user( request, "{} - {}".format(msg, _("You may edit it again below")) ) return http.HttpResponseRedirect(request.path) else: return super().response_change(request, obj) def history_form_view(self, request, object_id, version_id, extra_context=None): request.current_app = self.admin_site.name original_opts = self.model._meta model = getattr( self.model, self.model._meta.simple_history_manager_attribute ).model obj = get_object_or_404( model, **{original_opts.pk.attname: object_id, "history_id": version_id} ).instance obj._state.adding = False if not self.has_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: history = utils.get_history_manager_for_model(obj) obj = history.get(pk=version_id).instance formsets = [] form_class = self.get_form(request, obj) if request.method == "POST": form = form_class(request.POST, request.FILES, instance=obj) if form.is_valid(): new_object = self.save_form(request, form, change=True) self.save_model(request, new_object, form, change=True) form.save_m2m() self.log_change( request, new_object, self.construct_change_message(request, form, formsets), ) return self.response_change(request, new_object) else: form = form_class(instance=obj) admin_form = helpers.AdminForm( form, self.get_fieldsets(request, obj), self.prepopulated_fields, self.get_readonly_fields(request, obj), model_admin=self, ) model_name = original_opts.model_name url_triplet = self.admin_site.name, original_opts.app_label, model_name context = { "title": self.history_form_view_title(obj), "adminform": admin_form, "object_id": object_id, "original": obj, "is_popup": False, "media": mark_safe(self.media + admin_form.media), "errors": helpers.AdminErrorList(form, formsets), "app_label": original_opts.app_label, "original_opts": original_opts, "changelist_url": reverse("%s:%s_%s_changelist" % url_triplet), "change_url": reverse("%s:%s_%s_change" % url_triplet, args=(obj.pk,)), "history_url": reverse("%s:%s_%s_history" % url_triplet, args=(obj.pk,)), "change_history": change_history, "revert_disabled": self.revert_disabled, # 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": self.content_type_model_cls.objects.get_for_model( self.model ).id, "save_as": self.save_as, "save_on_top": self.save_on_top, "root_path": getattr(self.admin_site, "root_path", None), } context.update(self.admin_site.each_context(request)) context.update(extra_context or {}) extra_kwargs = {} return self.render_history_view( request, self.object_history_form_template, context, **extra_kwargs ) def history_form_view_title(self, obj): if self.revert_disabled: return _("View %s") % force_str(obj) else: return _("Revert %s") % force_str(obj) def render_history_view(self, request, template, context, **kwargs): """Catch call to render, to allow overriding.""" return render(request, template, context, **kwargs) def save_model(self, request, obj, form, change): """Set special model attribute to user for reference after save""" obj._history_user = request.user super().save_model(request, obj, form, change) @property def content_type_model_cls(self): """Returns the ContentType model class.""" return django_apps.get_model("contenttypes.contenttype") @property def revert_disabled(self): return getattr(settings, "SIMPLE_HISTORY_REVERT_DISABLED", False) django-simple-history-3.1.1/simple_history/exceptions.py000066400000000000000000000007701423103303600235500ustar00rootroot00000000000000""" django-simple-history exceptions and warnings classes. """ class MultipleRegistrationsError(Exception): """The model has been registered to have history tracking more than once""" pass class NotHistoricalModelError(TypeError): """No related history model found.""" pass class RelatedNameConflictError(Exception): """Related name conflicting with history manager""" pass class AlternativeManagerError(Exception): """Manager does not belong to model""" pass django-simple-history-3.1.1/simple_history/locale/000077500000000000000000000000001423103303600222505ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/cs_CZ/000077500000000000000000000000001423103303600232515ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/cs_CZ/LC_MESSAGES/000077500000000000000000000000001423103303600250365ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/cs_CZ/LC_MESSAGES/django.mo000066400000000000000000000041101423103303600266310ustar00rootroot00000000000000%`ap  Q &+0<7It 0*CYarX  !P)z   DK]l." !&    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object. RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: Report-Msgid-Bugs-To: PO-Revision-Date: 2021-09-20 19:50+0200 Last-Translator: Language-Team: Language: cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3; Historie změnHistorie změn: %sDůvod změnyZměněnoZměnilVyberte datum ze seznamu níže a vraťte se k předchozí verzi tohoto objektu.ZavřítKomentářVytvořenoDatum/časSmazánoHistorieDomůŽádnéObjektChcete-li historii upravit, stiskněte tlačítko 'Změnit historii'Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objektu.Vrátit změnyVrátit %(verbose_name)sVrátit změny: %s%(name)s "%(obj)s" bylo úspěšně změněno.Tento objekt nemá historii změn.Zobrazit %(verbose_name)sZobrazit %sZobrazit historii: %sNíže jej můžete znovu upravitdjango-simple-history-3.1.1/simple_history/locale/cs_CZ/LC_MESSAGES/django.po000066400000000000000000000074341423103303600266500ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-09-18 21:54+0200\n" "PO-Revision-Date: 2021-09-20 19:50+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n " "<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n" #: simple_history/admin.py:102 #, python-format msgid "View history: %s" msgstr "Zobrazit historii: %s" #: simple_history/admin.py:104 #, python-format msgid "Change history: %s" msgstr "Historie změn: %s" #: simple_history/admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" bylo úspěšně změněno." #: simple_history/admin.py:116 msgid "You may edit it again below" msgstr "Níže jej můžete znovu upravit" #: simple_history/admin.py:216 #, python-format msgid "View %s" msgstr "Zobrazit %s" #: simple_history/admin.py:218 #, python-format msgid "Revert %s" msgstr "Vrátit změny: %s" #: simple_history/models.py:433 msgid "Created" msgstr "Vytvořeno" #: simple_history/models.py:433 msgid "Changed" msgstr "Změněno" #: simple_history/models.py:433 msgid "Deleted" msgstr "Smazáno" #: simple_history/templates/simple_history/_object_history_list.html:9 msgid "Object" msgstr "Objekt" #: simple_history/templates/simple_history/_object_history_list.html:13 msgid "Date/time" msgstr "Datum/čas" #: simple_history/templates/simple_history/_object_history_list.html:14 msgid "Comment" msgstr "Komentář" #: simple_history/templates/simple_history/_object_history_list.html:15 msgid "Changed by" msgstr "Změnil" #: simple_history/templates/simple_history/_object_history_list.html:16 msgid "Change reason" msgstr "Důvod změny" #: simple_history/templates/simple_history/_object_history_list.html:37 msgid "None" msgstr "Žádné" #: simple_history/templates/simple_history/object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "Vyberte datum ze seznamu níže a vraťte se k předchozí verzi tohoto " "objektu." #: simple_history/templates/simple_history/object_history.html:16 msgid "This object doesn't have a change history." msgstr "Tento objekt nemá historii změn." #: simple_history/templates/simple_history/object_history_form.html:7 msgid "Home" msgstr "Domů" #: simple_history/templates/simple_history/object_history_form.html:11 msgid "History" msgstr "Historie" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Zobrazit %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Vrátit %(verbose_name)s" #: simple_history/templates/simple_history/object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object. " msgstr "Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objektu." #: simple_history/templates/simple_history/object_history_form.html:25 msgid "Press the 'Change History' button below to edit the history." msgstr "Chcete-li historii upravit, stiskněte tlačítko 'Změnit historii'" #: simple_history/templates/simple_history/submit_line.html:4 msgid "Revert" msgstr "Vrátit změny" #: simple_history/templates/simple_history/submit_line.html:6 msgid "Change History" msgstr "Historie změn" #: simple_history/templates/simple_history/submit_line.html:7 msgid "Close" msgstr "Zavřít" django-simple-history-3.1.1/simple_history/locale/de/000077500000000000000000000000001423103303600226405ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/de/LC_MESSAGES/000077500000000000000000000000001423103303600244255ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/de/LC_MESSAGES/django.mo000066400000000000000000000036471423103303600262360ustar00rootroot00000000000000  +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-3.1.1/simple_history/locale/de/LC_MESSAGES/django.po000066400000000000000000000065451423103303600262410ustar00rootroot00000000000000# 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-3.1.1/simple_history/locale/fr/000077500000000000000000000000001423103303600226575ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/fr/LC_MESSAGES/000077500000000000000000000000001423103303600244445ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/fr/LC_MESSAGES/django.mo000066400000000000000000000041261423103303600262460ustar00rootroot00000000000000%`ap  Q &+0<7It 0*CYar cz   IX is 8 -(    Change HistoryChange history: %sChange reasonChangedChanged byChoose a date from the list below to revert to a previous version of this object.CloseCommentCreatedDate/timeDeletedHistoryHomeNoneObjectPress the 'Change History' button below to edit the history.Press the 'Revert' button below to revert to this version of the object. RevertRevert %(verbose_name)sRevert %sThe %(name)s "%(obj)s" was changed successfully.This object doesn't have a change history.View %(verbose_name)sView %sView history: %sYou may edit it again belowProject-Id-Version: Report-Msgid-Bugs-To: PO-Revision-Date: 2020-04-25 22:50+0200 Last-Translator: Language-Team: Language: fr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=2; plural=(n > 1); X-Generator: Poedit 2.3 Historique des changementsHistorique de changement: %sRaison de la modificationModifiéModifié parChoisissez une date dans la liste ci-dessous pour revenir à une version précédente de cet objet.FermerCommentaireCrééDate/heureEffacéHistoriqueAccueilAucunObjetCliquez sur le bouton 'Historique' ci-dessous pour modifier l'historique.Cliquez sur le bouton 'Rétablir' ci-dessous pour revenir à cette version de l' objet. RétablirRétablir %(verbose_name)sRétablir %sL'objet "%(obj)s" %(name)s a été changé avec succès.Cet objet n'a pas d'historique.Voir %(verbose_name)sVoir %sHistorique de vue: %sVous pouvez le modifier à nouveau ci-dessousdjango-simple-history-3.1.1/simple_history/locale/fr/LC_MESSAGES/django.po000066400000000000000000000075531423103303600262600ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-04-04 01:50+0300\n" "PO-Revision-Date: 2020-04-25 22:50+0200\n" "Last-Translator: \n" "Language-Team: \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Generator: Poedit 2.3\n" #: .\simple_history\admin.py:102 #, python-format msgid "View history: %s" msgstr "Historique de vue: %s" #: .\simple_history\admin.py:104 #, python-format msgid "Change history: %s" msgstr "Historique de changement: %s" #: .\simple_history\admin.py:110 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "L'objet \"%(obj)s\" %(name)s a été changé avec succès." #: .\simple_history\admin.py:116 msgid "You may edit it again below" msgstr "Vous pouvez le modifier à nouveau ci-dessous" #: .\simple_history\admin.py:216 #, python-format msgid "View %s" msgstr "Voir %s" #: .\simple_history\admin.py:218 #, python-format msgid "Revert %s" msgstr "Rétablir %s" #: .\simple_history\models.py:433 msgid "Created" msgstr "Créé" #: .\simple_history\models.py:433 msgid "Changed" msgstr "Modifié" #: .\simple_history\models.py:433 msgid "Deleted" msgstr "Effacé" #: .\simple_history\templates\simple_history\_object_history_list.html:9 msgid "Object" msgstr "Objet" #: .\simple_history\templates\simple_history\_object_history_list.html:13 msgid "Date/time" msgstr "Date/heure" #: .\simple_history\templates\simple_history\_object_history_list.html:14 msgid "Comment" msgstr "Commentaire" #: .\simple_history\templates\simple_history\_object_history_list.html:15 msgid "Changed by" msgstr "Modifié par" #: .\simple_history\templates\simple_history\_object_history_list.html:16 msgid "Change reason" msgstr "Raison de la modification" #: .\simple_history\templates\simple_history\_object_history_list.html:37 msgid "None" msgstr "Aucun" #: .\simple_history\templates\simple_history\object_history.html:11 msgid "" "Choose a date from the list below to revert to a previous version of this " "object." msgstr "" "Choisissez une date dans la liste ci-dessous pour revenir à une version " "précédente de cet objet." #: .\simple_history\templates\simple_history\object_history.html:16 msgid "This object doesn't have a change history." msgstr "Cet objet n'a pas d'historique." #: .\simple_history\templates\simple_history\object_history_form.html:7 msgid "Home" msgstr "Accueil" #: .\simple_history\templates\simple_history\object_history_form.html:11 msgid "History" msgstr "Historique" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "View %(verbose_name)s" msgstr "Voir %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:12 #, python-format msgid "Revert %(verbose_name)s" msgstr "Rétablir %(verbose_name)s" #: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "" "Press the 'Revert' button below to revert to this version of the object. " msgstr "" "Cliquez sur le bouton 'Rétablir' ci-dessous pour revenir à cette version de " "l' objet. " #: .\simple_history\templates\simple_history\object_history_form.html:25 msgid "Press the 'Change History' button below to edit the history." msgstr "" "Cliquez sur le bouton 'Historique' ci-dessous pour modifier l'historique." #: .\simple_history\templates\simple_history\submit_line.html:4 msgid "Revert" msgstr "Rétablir" #: .\simple_history\templates\simple_history\submit_line.html:6 msgid "Change History" msgstr "Historique des changements" #: .\simple_history\templates\simple_history\submit_line.html:7 msgid "Close" msgstr "Fermer" django-simple-history-3.1.1/simple_history/locale/pl/000077500000000000000000000000001423103303600226635ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/pl/LC_MESSAGES/000077500000000000000000000000001423103303600244505ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/pl/LC_MESSAGES/django.mo000066400000000000000000000036041423103303600262520ustar00rootroot00000000000000| 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-3.1.1/simple_history/locale/pl/LC_MESSAGES/django.po000066400000000000000000000056601423103303600262610ustar00rootroot00000000000000# 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-3.1.1/simple_history/locale/pt_BR/000077500000000000000000000000001423103303600232565ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001423103303600250435ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/pt_BR/LC_MESSAGES/django.mo000066400000000000000000000035621423103303600266500ustar00rootroot00000000000000| 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-3.1.1/simple_history/locale/pt_BR/LC_MESSAGES/django.po000066400000000000000000000063341423103303600266530ustar00rootroot00000000000000# 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-3.1.1/simple_history/locale/ru_RU/000077500000000000000000000000001423103303600233045ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/ru_RU/LC_MESSAGES/000077500000000000000000000000001423103303600250715ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/locale/ru_RU/LC_MESSAGES/django.mo000066400000000000000000000044501423103303600266730ustar00rootroot00000000000000  +9 AQL 9H\c {0*lj%! oo):<VHK    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: Report-Msgid-Bugs-To: PO-Revision-Date: 2021-10-14 14:05+0300 Last-Translator: Language-Team: Language: ru_RU MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2); X-Generator: Poedit 3.0 Изменить записьИстория изменений: %sПричина измененияИзмененоИзмененоВыберите дату из списка ниже, чтобы вернуться к предыдущей версии этого объекта.КомментарийСозданоДата/времяУдаленоИсторияГлавнаяNoneОбъектИли нажмите кнопку 'Изменить запись', чтобы изменить историю.Нажмите кнопку 'Восстановить' ниже, чтобы вернуться к этой версии объекта.ВосстановитьВосстановить %(verbose_name)sВосстановить %s%(name)s "%(obj)s" было успешно изменено.Этот объект не имеет истории изменений.Вы можете отредактировать его снова нижеdjango-simple-history-3.1.1/simple_history/locale/ru_RU/LC_MESSAGES/django.po000066400000000000000000000067241423103303600267040ustar00rootroot00000000000000# 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: 2021-10-14 14:05+0300\n" "Last-Translator: \n" "Language-Team: \n" "Language: ru_RU\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" "%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" "X-Generator: Poedit 3.0\n" #: admin.py:77 #, python-format msgid "Change history: %s" msgstr "История изменений: %s" #: admin.py:96 #, python-format msgid "The %(name)s \"%(obj)s\" was changed successfully." msgstr "%(name)s \"%(obj)s\" было успешно изменено." #: admin.py:102 msgid "You may edit it again below" msgstr "Вы можете отредактировать его снова ниже" #: admin.py:160 #, python-format msgid "Revert %s" msgstr "Восстановить %s" #: models.py:304 msgid "Created" msgstr "Создано" #: models.py:305 msgid "Changed" msgstr "Изменено" #: models.py:306 msgid "Deleted" msgstr "Удалено" #: templates/simple_history/_object_history_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 "Изменить запись" #: templates/simple_history/_object_history_list.html:16 msgid "Change reason" msgstr "Причина изменения" django-simple-history-3.1.1/simple_history/management/000077500000000000000000000000001423103303600231255ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/management/__init__.py000066400000000000000000000000001423103303600252240ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/management/commands/000077500000000000000000000000001423103303600247265ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/management/commands/__init__.py000066400000000000000000000000001423103303600270250ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/management/commands/clean_duplicate_history.py000066400000000000000000000103651423103303600322020ustar00rootroot00000000000000from django.db import transaction from django.utils import timezone from ... import models, utils from ...exceptions import NotHistoricalModelError from . import populate_history class Command(populate_history.Command): args = "" help = ( "Scans HistoricalRecords for identical sequencial entries " "(duplicates) in a model and deletes them." ) DONE_CLEANING_FOR_MODEL = "Removed {count} historical records for {model}\n" def add_arguments(self, parser): parser.add_argument("models", nargs="*", type=str) parser.add_argument( "--auto", action="store_true", dest="auto", default=False, help="Automatically search for models with the HistoricalRecords field " "type", ) parser.add_argument( "-d", "--dry", action="store_true", help="Dry (test) run only, no changes" ) parser.add_argument( "-m", "--minutes", type=int, help="Only search the last MINUTES of history" ) parser.add_argument( "--excluded_fields", nargs="+", help="List of fields to be excluded from the diff_against check", ) def handle(self, *args, **options): self.verbosity = options["verbosity"] self.excluded_fields = options.get("excluded_fields") 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(f"{model} has {found} historical entries", 2) if not found: continue # Break apart the query so we can add additional filtering model_query = model.objects.all() # If we're provided a stop date take the initial hit of getting the # filtered records to iterate over if stop_date: model_query = model_query.filter( pk__in=(m_qs.values_list(model._meta.pk.name).distinct()) ) for o in model_query.iterator(): self._process_instance(o, model, stop_date=stop_date, dry_run=dry_run) def _process_instance(self, instance, model, stop_date=None, dry_run=True): entries_deleted = 0 history = utils.get_history_manager_for_model(instance) o_qs = history.all() if stop_date: # to compare last history match extra_one = o_qs.filter(history_date__lte=stop_date).first() o_qs = o_qs.filter(history_date__gte=stop_date) else: extra_one = None with transaction.atomic(): # ordering is ('-history_date', '-history_id') so this is ok f1 = o_qs.first() if not f1: return for f2 in o_qs[1:]: entries_deleted += self._check_and_delete(f1, f2, dry_run) f1 = f2 if extra_one: entries_deleted += self._check_and_delete(f1, extra_one, dry_run) self.log( self.DONE_CLEANING_FOR_MODEL.format(model=model, count=entries_deleted) ) def log(self, message, verbosity_level=1): if self.verbosity >= verbosity_level: self.stdout.write(message) def _check_and_delete(self, entry1, entry2, dry_run=True): delta = entry1.diff_against(entry2, excluded_fields=self.excluded_fields) if not delta.changed_fields: if not dry_run: entry1.delete() return 1 return 0 django-simple-history-3.1.1/simple_history/management/commands/clean_old_history.py000066400000000000000000000046401423103303600310050ustar00rootroot00000000000000from django.db import transaction from django.utils import timezone from ... import models, utils from ...exceptions import NotHistoricalModelError from . import populate_history class Command(populate_history.Command): args = "" help = "Scans HistoricalRecords for old entries " "and deletes them." DONE_CLEANING_FOR_MODEL = "Removed {count} historical records for {model}\n" def add_arguments(self, parser): parser.add_argument("models", nargs="*", type=str) parser.add_argument( "--auto", action="store_true", dest="auto", default=False, help="Automatically search for models with the HistoricalRecords field " "type", ) parser.add_argument( "--days", help="Only Keep the last X Days of history, default is 30", dest="days", type=int, default=30, ) parser.add_argument( "-d", "--dry", action="store_true", help="Dry (test) run only, no changes" ) def handle(self, *args, **options): self.verbosity = options["verbosity"] to_process = set() model_strings = options.get("models", []) or args if model_strings: for model_pair in self._handle_model_list(*model_strings): to_process.add(model_pair) elif options["auto"]: to_process = self._auto_models() else: self.log(self.COMMAND_HINT) self._process(to_process, days_back=options["days"], dry_run=options["dry"]) def _process(self, to_process, days_back=None, dry_run=True): start_date = timezone.now() - timezone.timedelta(days=days_back) for model, history_model in to_process: history_model_manager = history_model.objects history_model_manager = history_model_manager.filter( history_date__lt=start_date ) found = history_model_manager.count() self.log(f"{model} has {found} old historical entries", 2) if not found: continue if not dry_run: history_model_manager.delete() self.log(self.DONE_CLEANING_FOR_MODEL.format(model=model, count=found)) def log(self, message, verbosity_level=1): if self.verbosity >= verbosity_level: self.stdout.write(message) django-simple-history-3.1.1/simple_history/management/commands/populate_history.py000066400000000000000000000140651423103303600307200ustar00rootroot00000000000000from django.apps import apps from django.core.management.base import BaseCommand, CommandError from ... import models, utils from ...exceptions import NotHistoricalModelError get_model = apps.get_model class Command(BaseCommand): args = "" help = ( "Populates the corresponding HistoricalRecords field with " "the current state of all instances in a model" ) COMMAND_HINT = "Please specify a model or use the --auto option" MODEL_NOT_FOUND = "Unable to find model" MODEL_NOT_HISTORICAL = "No history model found" NO_REGISTERED_MODELS = "No registered models were found\n" START_SAVING_FOR_MODEL = "Saving historical records for {model}\n" DONE_SAVING_FOR_MODEL = "Finished saving historical records for {model}\n" EXISTING_HISTORY_FOUND = "Existing history found, skipping model" INVALID_MODEL_ARG = "An invalid model was specified" def add_arguments(self, parser): super().add_arguments(parser) parser.add_argument("models", nargs="*", type=str) parser.add_argument( "--auto", action="store_true", dest="auto", default=False, help="Automatically search for models with the HistoricalRecords field " "type", ) parser.add_argument( "--batchsize", action="store", dest="batchsize", default=200, type=int, help="Set a custom batch size when bulk inserting historical records.", ) def handle(self, *args, **options): self.verbosity = options["verbosity"] to_process = set() model_strings = options.get("models", []) or args if model_strings: for model_pair in self._handle_model_list(*model_strings): to_process.add(model_pair) elif options["auto"]: to_process = self._auto_models() else: if self.verbosity >= 1: self.stdout.write(self.COMMAND_HINT) self._process(to_process, batch_size=options["batchsize"]) def _auto_models(self): to_process = set() for model in models.registered_models.values(): try: # avoid issues with multi-table inheritance history_model = utils.get_history_model_for_model(model) except NotHistoricalModelError: continue to_process.add((model, history_model)) if not to_process: if self.verbosity >= 1: self.stdout.write(self.NO_REGISTERED_MODELS) return to_process def _handle_model_list(self, *args): failing = False for natural_key in args: try: model, history = self._model_from_natural_key(natural_key) except ValueError as e: failing = True self.stderr.write(f"{e}\n") else: if not failing: yield (model, history) if failing: raise CommandError(self.INVALID_MODEL_ARG) def _model_from_natural_key(self, natural_key): try: app_label, model = natural_key.split(".", 1) except ValueError: model = None else: try: model = get_model(app_label, model) except LookupError: model = None if not model: msg = self.MODEL_NOT_FOUND + f" < {natural_key} >\n" raise ValueError(msg) try: history_model = utils.get_history_model_for_model(model) except NotHistoricalModelError: msg = self.MODEL_NOT_HISTORICAL + f" < {natural_key} >\n" raise ValueError(msg) return model, history_model def _bulk_history_create(self, model, batch_size): """Save a copy of all instances to the historical model. :param model: Model you want to bulk create :param batch_size: number of models to create at once. :return: """ instances = [] history = utils.get_history_manager_for_model(model) if self.verbosity >= 1: self.stdout.write( "Starting bulk creating history models for {} instances {}-{}".format( model, 0, batch_size ) ) iterator_kwargs = {"chunk_size": batch_size} for index, instance in enumerate( model._default_manager.iterator(**iterator_kwargs) ): # Can't Just pass batch_size to bulk_create as this can lead to # Out of Memory Errors as we load too many models into memory after # creating them. So we only keep batch_size worth of models in # historical_instances and clear them after we hit batch_size if index % batch_size == 0: history.bulk_history_create(instances, batch_size=batch_size) instances = [] if self.verbosity >= 1: self.stdout.write( "Finished bulk creating history models for {} " "instances {}-{}, starting next {}".format( model, index - batch_size, index, batch_size ) ) instances.append(instance) # create any we didn't get in the last loop if instances: history.bulk_history_create(instances, batch_size=batch_size) def _process(self, to_process, batch_size): for model, history_model in to_process: if history_model.objects.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-3.1.1/simple_history/manager.py000066400000000000000000000237131423103303600230030ustar00rootroot00000000000000from django.conf import settings from django.db import connection, models from django.db.models import OuterRef, QuerySet, Subquery from django.utils import timezone from simple_history.utils import ( get_app_model_primary_key_name, get_change_reason_from_object, ) # when converting a historical record to an instance, this attribute is added # to the instance so that code can reverse the instance to its historical record SIMPLE_HISTORY_REVERSE_ATTR_NAME = "_history" class HistoricalQuerySet(QuerySet): """ Enables additional functionality when working with historical records. For additional history on this topic, see: - https://github.com/jazzband/django-simple-history/pull/229 - https://github.com/jazzband/django-simple-history/issues/354 - https://github.com/jazzband/django-simple-history/issues/397 """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._as_instances = False self._as_of = None self._pk_attr = self.model.instance_type._meta.pk.attname def as_instances(self): """ Return a queryset that generates instances instead of historical records. Queries against the resulting queryset will translate `pk` into the primary key field of the original type. Returns a queryset. """ if not self._as_instances: result = self.exclude(history_type="-") result._as_instances = True else: result = self._clone() return result def filter(self, *args, **kwargs): """ If a `pk` filter arrives and the queryset is returning instances then the caller actually wants to filter based on the original type's primary key, and not the history_id (historical record's primary key); this happens frequently with DRF. """ if self._as_instances and "pk" in kwargs: kwargs[self._pk_attr] = kwargs.pop("pk") return super().filter(*args, **kwargs) def latest_of_each(self): """ Ensures results in the queryset are the latest historical record for each primary key. Deletions are not removed. Returns a queryset. """ # If using MySQL, need to get a list of IDs in memory and then use them for the # second query. # Does mean two loops through the DB to get the full set, but still a speed # improvement. backend = connection.vendor if backend == "mysql": history_ids = {} for item in self.order_by("-history_date", "-pk"): if getattr(item, self._pk_attr) not in history_ids: history_ids[getattr(item, self._pk_attr)] = item.pk latest_historics = self.filter(history_id__in=history_ids.values()) elif backend == "postgresql": latest_pk_attr_historic_ids = ( self.order_by(self._pk_attr, "-history_date", "-pk") .distinct(self._pk_attr) .values_list("pk", flat=True) ) latest_historics = self.filter(history_id__in=latest_pk_attr_historic_ids) else: latest_pk_attr_historic_ids = ( self.filter(**{self._pk_attr: OuterRef(self._pk_attr)}) .order_by("-history_date", "-pk") .values("pk")[:1] ) latest_historics = self.filter( history_id__in=Subquery(latest_pk_attr_historic_ids) ) return latest_historics def _clone(self): c = super()._clone() c._as_instances = self._as_instances c._as_of = self._as_of c._pk_attr = self._pk_attr return c def _fetch_all(self): super()._fetch_all() self._instanceize() def _instanceize(self): """ Convert the result cache to instances if possible and it has not already been done. If a query extracts `.values(...)` then the result cache will not contain historical objects to be converted. """ if ( self._result_cache and self._as_instances and isinstance(self._result_cache[0], self.model) ): self._result_cache = [item.instance for item in self._result_cache] for item in self._result_cache: historic = getattr(item, SIMPLE_HISTORY_REVERSE_ATTR_NAME) setattr(historic, "_as_of", self._as_of) class HistoryDescriptor: def __init__(self, model): self.model = model def __get__(self, instance, owner): return HistoryManager.from_queryset(HistoricalQuerySet)(self.model, instance) class HistoryManager(models.Manager): def __init__(self, model, instance=None): super().__init__() self.model = model self.instance = instance def get_super_queryset(self): return super().get_queryset() def get_queryset(self): qs = self.get_super_queryset() if self.instance is None: return qs key_name = get_app_model_primary_key_name(self.instance) return self.get_super_queryset().filter(**{key_name: self.instance.pk}) def most_recent(self): """ Returns the most recent copy of the instance available in the history. """ if not self.instance: raise TypeError( "Can't use most_recent() without a {} instance.".format( self.model._meta.object_name ) ) tmp = [] 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(*fields)[0] except IndexError: raise self.instance.DoesNotExist( "%s has no historical record." % self.instance._meta.object_name ) return self.instance.__class__(**values) def as_of(self, date): """ Get a snapshot as of a specific date. When this is used on an instance, it will return the instance based on the specific date. If the instance did not exist yet, or had been deleted, then a DoesNotExist error is railed. When this is used on a model's history manager, the resulting queryset will locate the most recent historical record before the specified date for each primary key, generating instances. If the most recent historical record is a deletion, that instance is dropped from the result. A common usage pattern for querying is to accept an optional time point `date` and then use: `qs = .history.as_of(date) if date else .objects` after which point one can add filters, values - anything a normal queryset would support. To retrieve historical records, query the model's history directly; for example: `qs = .history.filter(history_date__lte=date, pk=...)` To retrieve the most recent historical record, including deletions, you could then use: `qs = qs.latest_of_each()` """ queryset = self.get_queryset().filter(history_date__lte=date) if not self.instance: if isinstance(queryset, HistoricalQuerySet): queryset._as_of = date queryset = queryset.latest_of_each().as_instances() return queryset try: # historical records are sorted in reverse chronological order history_obj = queryset[0] except IndexError: raise self.instance.DoesNotExist( "%s had not yet been created." % self.instance._meta.object_name ) if history_obj.history_type == "-": raise self.instance.DoesNotExist( "%s had already been deleted." % self.instance._meta.object_name ) result = history_obj.instance historic = getattr(result, SIMPLE_HISTORY_REVERSE_ATTR_NAME) setattr(historic, "_as_of", date) return result def bulk_history_create( self, objs, batch_size=None, update=False, default_user=None, default_change_reason="", default_date=None, ): """ Bulk create the history for the objects specified by objs. If called by bulk_update_with_history, use the update boolean and save the history_type accordingly. """ if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): return history_type = "+" if update: history_type = "~" historical_instances = [] for instance in objs: history_user = getattr( instance, "_history_user", default_user or self.model.get_default_history_user(instance), ) row = self.model( history_date=getattr( instance, "_history_date", default_date or timezone.now() ), history_user=history_user, history_change_reason=get_change_reason_from_object(instance) or default_change_reason, history_type=history_type, **{ field.attname: getattr(instance, field.attname) for field in instance._meta.fields if field.name not in self.model._history_excluded_fields }, ) if hasattr(self.model, "history_relation"): row.history_relation_id = instance.pk historical_instances.append(row) return self.model.objects.bulk_create( historical_instances, batch_size=batch_size ) django-simple-history-3.1.1/simple_history/middleware.py000066400000000000000000000012241423103303600234770ustar00rootroot00000000000000from django.utils.deprecation import MiddlewareMixin from .models import HistoricalRecords class HistoryRequestMiddleware(MiddlewareMixin): """Expose request to HistoricalRecords. This middleware sets request as a local context/thread variable, making it available to the model-level utilities to allow tracking of the authenticated user making a change. """ def process_request(self, request): HistoricalRecords.context.request = request def process_response(self, request, response): if hasattr(HistoricalRecords.context, "request"): del HistoricalRecords.context.request return response django-simple-history-3.1.1/simple_history/models.py000066400000000000000000000737631423103303600226660ustar00rootroot00000000000000import copy import importlib 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.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.db import models from django.db.models import ManyToManyField from django.db.models.fields.proxy import OrderWrt from django.db.models.fields.related import ForeignKey from django.db.models.fields.related_descriptors import ( ForwardManyToOneDescriptor, ReverseManyToOneDescriptor, create_reverse_many_to_one_manager, ) from django.db.models.query import QuerySet from django.forms.models import model_to_dict from django.urls import reverse from django.utils import timezone from django.utils.encoding import smart_str from django.utils.functional import cached_property from django.utils.text import format_lazy from django.utils.translation import gettext_lazy as _ from simple_history import utils from . import exceptions from .manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoryDescriptor from .signals import post_create_historical_record, pre_create_historical_record from .utils import get_change_reason_from_object try: from asgiref.local import Local as LocalContext except ImportError: from threading import local as LocalContext registered_models = {} def _default_get_user(request, **kwargs): try: return request.user except AttributeError: return None def _history_user_getter(historical_instance): if historical_instance.history_user_id is None: return None User = get_user_model() try: return User.objects.get(pk=historical_instance.history_user_id) except User.DoesNotExist: return None def _history_user_setter(historical_instance, user): if user is not None: historical_instance.history_user_id = user.pk class HistoricalRecords: thread = context = LocalContext() # retain thread for backwards compatibility def __init__( self, verbose_name=None, verbose_name_plural=None, bases=(models.Model,), user_related_name="+", table_name=None, inherit=False, excluded_fields=None, history_id_field=None, history_change_reason_field=None, user_model=None, get_user=_default_get_user, cascade_delete_history=False, custom_model_name=None, app=None, history_user_id_field=None, history_user_getter=_history_user_getter, history_user_setter=_history_user_setter, related_name=None, use_base_model_db=False, user_db_constraint=True, no_db_index=list(), excluded_field_kwargs=None, ): self.user_set_verbose_name = verbose_name self.user_set_verbose_name_plural = verbose_name_plural self.user_related_name = user_related_name self.user_db_constraint = user_db_constraint self.table_name = table_name self.inherit = inherit self.history_id_field = history_id_field self.history_change_reason_field = history_change_reason_field self.user_model = user_model self.get_user = get_user self.cascade_delete_history = cascade_delete_history self.custom_model_name = custom_model_name self.app = app self.user_id_field = history_user_id_field self.user_getter = history_user_getter self.user_setter = history_user_setter self.related_name = related_name self.use_base_model_db = use_base_model_db if isinstance(no_db_index, str): no_db_index = [no_db_index] self.no_db_index = no_db_index if excluded_fields is None: excluded_fields = [] self.excluded_fields = excluded_fields if excluded_field_kwargs is None: excluded_field_kwargs = {} self.excluded_field_kwargs = excluded_field_kwargs try: if isinstance(bases, str): raise TypeError self.bases = (HistoricalChanges,) + tuple(bases) except TypeError: raise TypeError("The `bases` option must be a list or a tuple.") 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 get_history_model_name(self, model): if not self.custom_model_name: return f"Historical{model._meta.object_name}" # Must be trying to use a custom history model name if callable(self.custom_model_name): name = self.custom_model_name(model._meta.object_name) else: # simple string name = self.custom_model_name # Desired class name cannot be same as the model it is tracking if not ( name.lower() == model._meta.object_name.lower() and model.__module__ == self.module ): return name raise ValueError( "The 'custom_model_name' option '{}' evaluates to a name that is the same " "as the model it is tracking. This is not permitted.".format( self.custom_model_name ) ) def create_history_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("Meta", (), self.get_meta_options(model))) if self.table_name is not None: attrs["Meta"].db_table = self.table_name # Set as the default then check for overrides name = self.get_history_model_name(model) registered_models[model._meta.db_table] = model history_model = type(str(name), self.bases, attrs) return history_model def fields_included(self, model): fields = [] for field in model._meta.fields: if field.name not in self.excluded_fields: fields.append(field) return fields def field_excluded_kwargs(self, field): """ Find the excluded kwargs for a given field. """ return self.excluded_field_kwargs.get(field.name, set()) def copy_fields(self, model): """ Creates copies of the model's original fields, returning a dictionary mapping field name to copied field object. """ fields = {} for field in self.fields_included(model): field = copy.copy(field) field.remote_field = copy.copy(field.remote_field) if isinstance(field, OrderWrt): # OrderWrt is a proxy field, switch to a plain IntegerField field.__class__ = models.IntegerField if isinstance(field, models.ForeignKey): old_field = field old_swappable = old_field.swappable old_field.swappable = False try: _name, _path, args, field_args = old_field.deconstruct() finally: old_field.swappable = old_swappable if getattr(old_field, "one_to_one", False) or isinstance( old_field, models.OneToOneField ): FieldType = models.ForeignKey else: FieldType = type(old_field) # Remove any excluded kwargs for the field. # This is useful when a custom OneToOneField is being used that # has a different set of arguments than ForeignKey for exclude_arg in self.field_excluded_kwargs(old_field): field_args.pop(exclude_arg, None) # If field_args['to'] is 'self' then we have a case where the object # has a foreign key to itself. If we pass the historical record's # field to = 'self', the foreign key will point to an historical # record rather than the base record. We can use old_field.model here. if field_args.get("to", None) == "self": field_args["to"] = old_field.model # Override certain arguments passed when creating the field # so that they work for the historical field. field_args.update( db_constraint=False, related_name="+", null=True, blank=True, primary_key=False, db_index=True, serialize=True, unique=False, on_delete=models.DO_NOTHING, ) field = FieldType(*args, **field_args) field.name = old_field.name else: transform_field(field) # drop db index if field.name in self.no_db_index: field.db_index = False fields[field.name] = field return fields def _get_history_change_reason_field(self): if self.history_change_reason_field: # User specific field from init history_change_reason_field = self.history_change_reason_field elif getattr( settings, "SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD", False ): # Use text field with no max length, not enforced by DB anyways history_change_reason_field = models.TextField(null=True) else: # Current default, with max length history_change_reason_field = models.CharField(max_length=100, null=True) return history_change_reason_field def _get_history_id_field(self): if self.history_id_field: history_id_field = self.history_id_field history_id_field.primary_key = True history_id_field.editable = False elif getattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", False): history_id_field = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False ) else: history_id_field = models.AutoField(primary_key=True) return history_id_field def _get_history_user_fields(self): if self.user_id_field is not None: # Tracking user using explicit id rather than Django ForeignKey history_user_fields = { "history_user": property(self.user_getter, self.user_setter), "history_user_id": self.user_id_field, } else: user_model = self.user_model or getattr( settings, "AUTH_USER_MODEL", "auth.User" ) history_user_fields = { "history_user": models.ForeignKey( user_model, null=True, related_name=self.user_related_name, on_delete=models.SET_NULL, db_constraint=self.user_db_constraint, ) } return history_user_fields def _get_history_related_field(self, model): if self.related_name: if self.manager_name == self.related_name: raise exceptions.RelatedNameConflictError( "The related name must not be called like the history manager." ) return { "history_relation": models.ForeignKey( model, on_delete=models.DO_NOTHING, related_name=self.related_name, db_constraint=False, ) } else: return {} def get_extra_fields(self, model, fields): """Return dict of extra fields added to the historical record model""" def revert_url(self): """URL for this change in the default admin site.""" opts = model._meta app_label, model_name = opts.app_label, opts.model_name return reverse( f"{admin.site.name}:{app_label}_{model_name}_simple_history", args=[getattr(self, opts.pk.attname), self.history_id], ) def get_instance(self): attrs = { field.attname: getattr(self, field.attname) for field in fields.values() } if self._history_excluded_fields: # We don't add ManyToManyFields to this list because they may cause # the subsequent `.get()` call to fail. See #706 for context. excluded_attnames = [ model._meta.get_field(field).attname for field in self._history_excluded_fields if not isinstance(model._meta.get_field(field), ManyToManyField) ] try: values = ( model.objects.filter(pk=getattr(self, model._meta.pk.attname)) .values(*excluded_attnames) .get() ) except ObjectDoesNotExist: pass else: attrs.update(values) result = model(**attrs) # this is the only way external code could know an instance is historical setattr(result, SIMPLE_HISTORY_REVERSE_ATTR_NAME, self) return result def get_next_record(self): """ Get the next history record for the instance. `None` if last. """ history = utils.get_history_manager_from_history(self) return ( history.filter(history_date__gt=self.history_date) .order_by("history_date") .first() ) def get_prev_record(self): """ Get the previous history record for the instance. `None` if first. """ history = utils.get_history_manager_from_history(self) return ( history.filter(history_date__lt=self.history_date) .order_by("history_date") .last() ) def get_default_history_user(instance): """ Returns the user specified by `get_user` method for manually creating historical objects """ return self.get_history_user(instance) extra_fields = { "history_id": self._get_history_id_field(), "history_date": models.DateTimeField(db_index=self._date_indexing is True), "history_change_reason": self._get_history_change_reason_field(), "history_type": models.CharField( max_length=1, choices=(("+", _("Created")), ("~", _("Changed")), ("-", _("Deleted"))), ), "history_object": HistoricalObjectDescriptor( model, self.fields_included(model) ), "instance": property(get_instance), "instance_type": model, "next_record": property(get_next_record), "prev_record": property(get_prev_record), "revert_url": revert_url, "__str__": lambda self: "{} as of {}".format( self.history_object, self.history_date ), "get_default_history_user": staticmethod(get_default_history_user), } extra_fields.update(self._get_history_related_field(model)) extra_fields.update(self._get_history_user_fields()) return extra_fields @property def _date_indexing(self): """False, True, or 'composite'; default is True""" result = getattr(settings, "SIMPLE_HISTORY_DATE_INDEX", True) valid = True if isinstance(result, str): result = result.lower() if result not in ("composite",): valid = False elif not isinstance(result, bool): valid = False if not valid: raise ImproperlyConfigured( "SIMPLE_HISTORY_DATE_INDEX must be one of (False, True, 'Composite')" ) return result def get_meta_options(self, model): """ Returns a dictionary of fields that will be added to the Meta inner class of the historical record model. """ meta_fields = { "ordering": ("-history_date", "-history_id"), "get_latest_by": ("history_date", "history_id"), } if self.user_set_verbose_name: name = self.user_set_verbose_name else: name = format_lazy("historical {}", smart_str(model._meta.verbose_name)) if self.user_set_verbose_name_plural: plural_name = self.user_set_verbose_name_plural else: plural_name = format_lazy( "historical {}", smart_str(model._meta.verbose_name_plural) ) meta_fields["verbose_name"] = name meta_fields["verbose_name_plural"] = plural_name if self.app: meta_fields["app_label"] = self.app if self._date_indexing == "composite": meta_fields["index_together"] = (("history_date", model._meta.pk.attname),) return meta_fields def post_save(self, instance, created, using=None, **kwargs): if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): return if not created and hasattr(instance, "skip_history_when_saving"): return if not kwargs.get("raw", False): self.create_historical_record(instance, created and "+" or "~", using=using) def post_delete(self, instance, using=None, **kwargs): if not getattr(settings, "SIMPLE_HISTORY_ENABLED", True): return if self.cascade_delete_history: manager = getattr(instance, self.manager_name) manager.using(using).all().delete() else: self.create_historical_record(instance, "-", using=using) def get_change_reason_for_object(self, instance, history_type, using): """ Get change reason for object. Customize this method to automatically fill change reason from context. """ return get_change_reason_from_object(instance) def create_historical_record(self, instance, history_type, using=None): using = using if self.use_base_model_db else None history_date = getattr(instance, "_history_date", timezone.now()) history_user = self.get_history_user(instance) history_change_reason = self.get_change_reason_for_object( instance, history_type, using ) manager = getattr(instance, self.manager_name) attrs = {} for field in self.fields_included(instance): attrs[field.attname] = getattr(instance, field.attname) relation_field = getattr(manager.model, "history_relation", None) if relation_field is not None: attrs["history_relation"] = instance history_instance = manager.model( history_date=history_date, history_type=history_type, history_user=history_user, history_change_reason=history_change_reason, **attrs, ) pre_create_historical_record.send( sender=manager.model, instance=instance, history_date=history_date, history_user=history_user, history_change_reason=history_change_reason, history_instance=history_instance, using=using, ) history_instance.save(using=using) post_create_historical_record.send( sender=manager.model, instance=instance, history_instance=history_instance, history_date=history_date, history_user=history_user, history_change_reason=history_change_reason, using=using, ) def get_history_user(self, instance): """Get the modifying user from instance or middleware.""" try: return instance._history_user except AttributeError: request = None try: if self.context.request.user.is_authenticated: request = self.context.request except AttributeError: pass return self.get_user(instance=instance, request=request) def transform_field(field): """Customize field appropriately for use in historical model""" field.name = field.attname if isinstance(field, models.BigAutoField): field.__class__ = models.BigIntegerField elif isinstance(field, models.AutoField): field.__class__ = models.IntegerField elif isinstance(field, models.FileField): # Don't copy file, just path. if getattr(settings, "SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD", False): field.__class__ = models.CharField else: field.__class__ = models.TextField # Historical instance shouldn't change create/update timestamps field.auto_now = False field.auto_now_add = False # Just setting db_collation explicitly since we're not using # field.deconstruct() here field.db_collation = None if field.primary_key or field.unique: # Unique fields can no longer be guaranteed unique, # but they should still be indexed for faster lookups. field.primary_key = False field._unique = False field.db_index = True field.serialize = True class HistoricForwardManyToOneDescriptor(ForwardManyToOneDescriptor): """ Overrides get_queryset to provide historic query support, should the instance be historic (and therefore was generated by a timepoint query) and the other side of the relation also uses a history manager. """ def get_queryset(self, **hints) -> QuerySet: instance = hints.get("instance") if instance: history = getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None) histmgr = getattr( self.field.remote_field.model, getattr( self.field.remote_field.model._meta, "simple_history_manager_attribute", "_notthere", ), None, ) if history and histmgr: return histmgr.as_of(history._as_of) return super().get_queryset(**hints) class HistoricReverseManyToOneDescriptor(ReverseManyToOneDescriptor): """ Overrides get_queryset to provide historic query support, should the instance be historic (and therefore was generated by a timepoint query) and the other side of the relation also uses a history manager. """ @cached_property def related_manager_cls(self): related_model = self.rel.related_model class HistoricRelationModelManager(related_model._default_manager.__class__): def get_queryset(self): try: return self.instance._prefetched_objects_cache[ self.field.remote_field.get_cache_name() ] except (AttributeError, KeyError): history = getattr( self.instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None ) histmgr = getattr( self.model, getattr( self.model._meta, "simple_history_manager_attribute", "_notthere", ), None, ) if history and histmgr: queryset = histmgr.as_of(history._as_of) else: queryset = super().get_queryset() return self._apply_rel_filters(queryset) return create_reverse_many_to_one_manager( HistoricRelationModelManager, self.rel ) class HistoricForeignKey(ForeignKey): """ Allows foreign keys to work properly from a historic instance. If you use as_of queries to extract historical instances from a model, and you have other models that are related by foreign key and also historic, changing them to a HistoricForeignKey field type will allow you to naturally cross the relationship boundary at the same point in time as the origin instance. A historic instance maintains an attribute ("_historic") when it is historic, holding the historic record instance and the timepoint used to query it ("_as_of"). HistoricForeignKey looks for this and uses an as_of query against the related object so the relationship is assessed at the same timepoint. """ forward_related_accessor_class = HistoricForwardManyToOneDescriptor related_accessor_class = HistoricReverseManyToOneDescriptor def is_historic(instance): """ Returns True if the instance was acquired with an as_of timepoint. """ return to_historic(instance) is not None def to_historic(instance): """ Returns a historic model instance if the instance was acquired with an as_of timepoint, or None. """ return getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME, None) class HistoricalObjectDescriptor: def __init__(self, model, fields_included): self.model = model self.fields_included = fields_included def __get__(self, instance, owner): if instance is None: return self values = {f.attname: getattr(instance, f.attname) for f in self.fields_included} return self.model(**values) class HistoricalChanges: def diff_against(self, old_history, excluded_fields=None, included_fields=None): if not isinstance(old_history, type(self)): raise TypeError( ("unsupported type(s) for diffing: " "'{}' and '{}'").format( type(self), type(old_history) ) ) if excluded_fields is None: excluded_fields = set() if included_fields is None: included_fields = { f.name for f in old_history.instance_type._meta.fields if f.editable } fields = set(included_fields).difference(excluded_fields) changes = [] changed_fields = [] old_values = model_to_dict(old_history, fields=fields) current_values = model_to_dict(self, fields=fields) for field in fields: old_value = old_values[field] current_value = current_values[field] if old_value != current_value: changes.append(ModelChange(field, old_value, current_value)) changed_fields.append(field) return ModelDelta(changes, changed_fields, old_history, self) class ModelChange: def __init__(self, field_name, old_value, new_value): self.field = field_name self.old = old_value self.new = new_value class ModelDelta: def __init__(self, changes, changed_fields, old_record, new_record): self.changes = changes self.changed_fields = changed_fields self.old_record = old_record self.new_record = new_record django-simple-history-3.1.1/simple_history/registry_tests/000077500000000000000000000000001423103303600241035ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/__init__.py000066400000000000000000000000001423103303600262020ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/000077500000000000000000000000001423103303600277735ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/__init__.py000066400000000000000000000000001423103303600320720ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations/000077500000000000000000000000001423103303600321475ustar00rootroot000000000000000001_initial.py000066400000000000000000000072141423103303600345370ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 1.9.12 on 2017-01-18 21:58 import django.db.models.deletion from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] operations = [ migrations.CreateModel( name="DoYouKnow", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ) ], ), migrations.CreateModel( name="HistoricalYar", fields=[ ( "id", models.IntegerField( auto_created=True, blank=True, db_index=True, verbose_name="ID" ), ), ("history_id", models.AutoField(primary_key=True, serialize=False)), ("history_date", models.DateTimeField()), ( "history_type", models.CharField( choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1, ), ), ("history_change_reason", models.CharField(max_length=100, null=True)), ( "history_user", models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL, ), ), ], options={ "verbose_name": "historical yar", "ordering": ("-history_date", "-history_id"), "get_latest_by": "history_date", }, ), migrations.CreateModel( name="Yar", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ) ], ), migrations.CreateModel( name="WhatIMean", fields=[ ( "doyouknow_ptr", models.OneToOneField( auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to="migration_test_app.DoYouKnow", ), ) ], bases=("migration_test_app.doyouknow",), ), migrations.AddField( model_name="yar", name="what", field=models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, to="migration_test_app.WhatIMean", ), ), migrations.AddField( model_name="historicalyar", name="what", field=models.ForeignKey( blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="migration_test_app.WhatIMean", ), ), ] 0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py000066400000000000000000000061331423103303600516620ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 2.1 on 2018-10-19 21:53 import django.db.models.deletion from django.conf import settings from django.db import migrations, models import simple_history.models from .. import models as my_models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("migration_test_app", "0001_initial"), ] operations = [ migrations.CreateModel( name="HistoricalModelWithCustomAttrForeignKey", fields=[ ( "id", models.IntegerField( auto_created=True, blank=True, db_index=True, verbose_name="ID" ), ), ("history_id", models.AutoField(primary_key=True, serialize=False)), ("history_change_reason", models.CharField(max_length=100, null=True)), ("history_date", models.DateTimeField()), ( "history_type", models.CharField( choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1, ), ), ( "history_user", models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL, ), ), ( "what_i_mean", my_models.CustomAttrNameForeignKey( attr_name="custom_attr_name", blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="migration_test_app.WhatIMean", ), ), ], options={ "verbose_name": "historical model with custom attr foreign key", "ordering": ("-history_date", "-history_id"), "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), migrations.CreateModel( name="ModelWithCustomAttrForeignKey", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "what_i_mean", my_models.CustomAttrNameForeignKey( attr_name="custom_attr_name", on_delete=django.db.models.deletion.CASCADE, to="migration_test_app.WhatIMean", ), ), ], ), ] 0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py000066400000000000000000000017351423103303600502070ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.0.dev20210802171549 on 2021-08-11 11:05 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey", ), ] operations = [ migrations.AlterModelOptions( name="historicalmodelwithcustomattrforeignkey", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr foreign key", }, ), migrations.AlterModelOptions( name="historicalyar", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical yar", }, ), ] 0004_history_date_indexing.py000066400000000000000000000013011423103303600374630ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.0.dev20210811195242 on 2021-08-13 10:07 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more", ), ] operations = [ migrations.AlterField( model_name="historicalmodelwithcustomattrforeignkey", name="history_date", field=models.DateTimeField(db_index=True), ), migrations.AlterField( model_name="historicalyar", name="history_date", field=models.DateTimeField(db_index=True), ), ] 0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py000066400000000000000000000062361423103303600530510ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 3.2.6 on 2021-08-24 10:36 import django.db.models.deletion from django.conf import settings from django.db import migrations, models import simple_history.models import simple_history.registry_tests.migration_test_app.models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ( "migration_test_app", "0004_history_date_indexing", ), ] operations = [ migrations.CreateModel( name="ModelWithCustomAttrOneToOneField", fields=[ ( "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, verbose_name="ID", ), ), ( "what_i_mean", simple_history.registry_tests.migration_test_app.models.CustomAttrNameOneToOneField( attr_name="custom_attr_name", on_delete=django.db.models.deletion.CASCADE, to="migration_test_app.whatimean", ), ), ], ), migrations.CreateModel( name="HistoricalModelWithCustomAttrOneToOneField", fields=[ ( "id", models.IntegerField( auto_created=True, blank=True, db_index=True, verbose_name="ID" ), ), ("history_id", models.AutoField(primary_key=True, serialize=False)), ("history_date", models.DateTimeField()), ("history_change_reason", models.CharField(max_length=100, null=True)), ( "history_type", models.CharField( choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1, ), ), ( "history_user", models.ForeignKey( null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="+", to=settings.AUTH_USER_MODEL, ), ), ( "what_i_mean", models.ForeignKey( blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name="+", to="migration_test_app.whatimean", ), ), ], options={ "verbose_name": "historical model with custom attr one to one field", "ordering": ("-history_date", "-history_id"), "get_latest_by": "history_date", }, bases=(simple_history.models.HistoricalChanges, models.Model), ), ] 0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py000066400000000000000000000016211423103303600506740ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.1.dev20211006030854 on 2021-10-07 13:51 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield", ), ] operations = [ migrations.AlterModelOptions( name="historicalmodelwithcustomattronetoonefield", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr one to one field", }, ), migrations.AlterField( model_name="historicalmodelwithcustomattronetoonefield", name="history_date", field=models.DateTimeField(db_index=True), ), ] 0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py000066400000000000000000000030441423103303600502060ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations# Generated by Django 4.0.1 on 2022-01-28 11:26 from django.db import migrations class Migration(migrations.Migration): dependencies = [ ( "migration_test_app", "0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more", ), ] operations = [ migrations.AlterModelOptions( name="historicalmodelwithcustomattrforeignkey", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr foreign key", "verbose_name_plural": "historical model with custom attr foreign keys", }, ), migrations.AlterModelOptions( name="historicalmodelwithcustomattronetoonefield", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical model with custom attr one to one field", "verbose_name_plural": "historical model with custom attr one to one fields", }, ), migrations.AlterModelOptions( name="historicalyar", options={ "get_latest_by": ("history_date", "history_id"), "ordering": ("-history_date", "-history_id"), "verbose_name": "historical yar", "verbose_name_plural": "historical yars", }, ), ] django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/migrations/__init__.py000066400000000000000000000000001423103303600342460ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/migration_test_app/models.py000066400000000000000000000032521423103303600316320ustar00rootroot00000000000000from django.db import models from simple_history.models import HistoricalRecords class DoYouKnow(models.Model): pass class WhatIMean(DoYouKnow): pass class Yar(models.Model): what = models.ForeignKey(WhatIMean, on_delete=models.CASCADE) history = HistoricalRecords() class CustomAttrNameForeignKey(models.ForeignKey): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrForeignKey(models.Model): what_i_mean = CustomAttrNameForeignKey( WhatIMean, models.CASCADE, attr_name="custom_attr_name" ) history = HistoricalRecords() class CustomAttrNameOneToOneField(models.OneToOneField): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrOneToOneField(models.Model): what_i_mean = CustomAttrNameOneToOneField( WhatIMean, models.CASCADE, attr_name="custom_attr_name" ) history = HistoricalRecords(excluded_field_kwargs={"what_i_mean": {"attr_name"}}) django-simple-history-3.1.1/simple_history/registry_tests/models.py000066400000000000000000000000001423103303600257260ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/registry_tests/tests.py000066400000000000000000000172041423103303600256230ustar00rootroot00000000000000import unittest import uuid from datetime import datetime, timedelta from io import StringIO from django.apps import apps from django.contrib.auth import get_user_model from django.core import management from django.test import TestCase, TransactionTestCase, override_settings from simple_history import exceptions, register from ..tests.models import ( Choice, InheritTracking1, InheritTracking2, InheritTracking3, InheritTracking4, ModelWithCustomAttrForeignKey, ModelWithCustomAttrOneToOneField, ModelWithHistoryInDifferentApp, Poll, Restaurant, TrackedAbstractBaseA, TrackedAbstractBaseB, TrackedWithAbstractBase, TrackedWithConcreteBase, UserAccessorDefault, UserAccessorOverride, UUIDRegisterModel, Voter, ) get_model = apps.get_model User = get_user_model() today = datetime(2021, 1, 1, 10, 0) tomorrow = today + timedelta(days=1) yesterday = today - timedelta(days=1) class RegisterTest(TestCase): def test_register_no_args(self): self.assertEqual(len(Choice.history.all()), 0) poll = Poll.objects.create(pub_date=today) choice = Choice.objects.create(poll=poll, votes=0) self.assertEqual(len(choice.history.all()), 1) def test_register_separate_app(self): def get_history(model): return model.history self.assertRaises(AttributeError, get_history, User) self.assertEqual(len(User.histories.all()), 0) user = User.objects.create(username="bob", password="pass") # nosec self.assertEqual(len(User.histories.all()), 1) self.assertEqual(len(user.histories.all()), 1) def test_reregister(self): with self.assertRaises(exceptions.MultipleRegistrationsError): register(Restaurant, manager_name="again") def test_register_custome_records(self): self.assertEqual(len(Voter.history.all()), 0) poll = Poll.objects.create(pub_date=today) choice = Choice.objects.create(poll=poll, votes=0) user = User.objects.create(username="voter") voter = Voter.objects.create(choice=choice, user=user) self.assertEqual(len(voter.history.all()), 1) expected = "Voter object changed by None as of " self.assertEqual(expected, str(voter.history.all()[0])[: len(expected)]) def test_register_history_id_field(self): self.assertEqual(len(UUIDRegisterModel.history.all()), 0) entry = UUIDRegisterModel.objects.create() self.assertEqual(len(entry.history.all()), 1) history = entry.history.all()[0] self.assertTrue(isinstance(history.history_id, uuid.UUID)) class TestUserAccessor(unittest.TestCase): def test_accessor_default(self): register(UserAccessorDefault) self.assertFalse(hasattr(User, "historicaluseraccessordefault_set")) def test_accessor_override(self): register(UserAccessorOverride, user_related_name="my_history_model_accessor") self.assertTrue(hasattr(User, "my_history_model_accessor")) class TestInheritedModule(TestCase): def test_using_app_label(self): try: from ..tests.models import HistoricalConcreteExternal except ImportError: self.fail("HistoricalConcreteExternal is in wrong module") def test_default(self): try: from ..tests.models import HistoricalConcreteExternal2 except ImportError: self.fail("HistoricalConcreteExternal2 is in wrong module") class TestTrackingInheritance(TestCase): def test_tracked_abstract_base(self): self.assertEqual( sorted( f.attname for f in TrackedWithAbstractBase.history.model._meta.fields ), sorted( [ "id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_tracked_concrete_base(self): self.assertEqual( sorted( f.attname for f in TrackedWithConcreteBase.history.model._meta.fields ), sorted( [ "id", "trackedconcretebase_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_multiple_tracked_bases(self): with self.assertRaises(exceptions.MultipleRegistrationsError): class TrackedWithMultipleAbstractBases( TrackedAbstractBaseA, TrackedAbstractBaseB ): pass def test_tracked_abstract_and_untracked_concrete_base(self): self.assertEqual( sorted(f.attname for f in InheritTracking1.history.model._meta.fields), sorted( [ "id", "untrackedconcretebase_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_indirect_tracked_abstract_base(self): self.assertEqual( sorted(f.attname for f in InheritTracking2.history.model._meta.fields), sorted( [ "id", "baseinherittracking2_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_indirect_tracked_concrete_base(self): self.assertEqual( sorted(f.attname for f in InheritTracking3.history.model._meta.fields), sorted( [ "id", "baseinherittracking3_ptr_id", "history_id", "history_change_reason", "history_date", "history_user_id", "history_type", ] ), ) def test_registering_with_tracked_abstract_base(self): with self.assertRaises(exceptions.MultipleRegistrationsError): register(InheritTracking4) class TestCustomAttrForeignKey(TestCase): """https://github.com/jazzband/django-simple-history/issues/431""" def test_custom_attr(self): field = ModelWithCustomAttrForeignKey.history.model._meta.get_field("poll") self.assertEqual(field.attr_name, "custom_poll") class TestCustomAttrOneToOneField(TestCase): """https://github.com/jazzband/django-simple-history/issues/870""" def test_custom_attr(self): field = ModelWithCustomAttrOneToOneField.history.model._meta.get_field("poll") self.assertFalse(hasattr(field, "attr_name")) @override_settings(MIGRATION_MODULES={}) class TestMigrate(TransactionTestCase): def test_makemigration_command(self): management.call_command( "makemigrations", "migration_test_app", stdout=StringIO() ) def test_migrate_command(self): management.call_command( "migrate", "migration_test_app", fake=True, stdout=StringIO() ) class TestModelWithHistoryInDifferentApp(TestCase): """https://github.com/jazzband/django-simple-history/issues/485""" def test__different_app(self): appLabel = ModelWithHistoryInDifferentApp.history.model._meta.app_label self.assertEqual(appLabel, "external") django-simple-history-3.1.1/simple_history/signals.py000066400000000000000000000006021423103303600230210ustar00rootroot00000000000000import django.dispatch # Arguments: "instance", "history_instance", "history_date", # "history_user", "history_change_reason", "using" pre_create_historical_record = django.dispatch.Signal() # Arguments: "instance", "history_instance", "history_date", # "history_user", "history_change_reason", "using" post_create_historical_record = django.dispatch.Signal() django-simple-history-3.1.1/simple_history/templates/000077500000000000000000000000001423103303600230075ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/templates/simple_history/000077500000000000000000000000001423103303600260615ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/templates/simple_history/_object_history_list.html000066400000000000000000000030241423103303600331670ustar00rootroot00000000000000{% 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-3.1.1/simple_history/templates/simple_history/object_history.html000066400000000000000000000011541423103303600317770ustar00rootroot00000000000000{% 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 %}

{% if not revert_disabled %}

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

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

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

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

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

{% endblock %} django-simple-history-3.1.1/simple_history/templates/simple_history/submit_line.html000066400000000000000000000006551423103303600312670ustar00rootroot00000000000000{% load i18n %}
{% if not revert_disabled %} {% endif %} {% if change_history %} {% endif %} {% trans 'Close' %}
django-simple-history-3.1.1/simple_history/templatetags/000077500000000000000000000000001423103303600235035ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/templatetags/__init__.py000066400000000000000000000000001423103303600256020ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/templatetags/getattributes.py000066400000000000000000000003711423103303600267440ustar00rootroot00000000000000from 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-3.1.1/simple_history/templatetags/simple_history_admin_list.py000066400000000000000000000003031423103303600313260ustar00rootroot00000000000000from 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-3.1.1/simple_history/templatetags/simple_history_compat.py000066400000000000000000000001721423103303600304720ustar00rootroot00000000000000from django import template from django.template.defaulttags import url register = template.Library() register.tag(url) django-simple-history-3.1.1/simple_history/tests/000077500000000000000000000000001423103303600221535ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/__init__.py000066400000000000000000000000001423103303600242520ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/admin.py000066400000000000000000000025611423103303600236210ustar00rootroot00000000000000from 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, Planet, 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"] class PlanetAdmin(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) admin.site.register(Planet, PlanetAdmin) django-simple-history-3.1.1/simple_history/tests/custom_user/000077500000000000000000000000001423103303600245235ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/custom_user/__init__.py000066400000000000000000000000001423103303600266220ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/custom_user/admin.py000066400000000000000000000002351423103303600261650ustar00rootroot00000000000000from django.contrib import admin from django.contrib.auth.admin import UserAdmin from .models import CustomUser admin.site.register(CustomUser, UserAdmin) django-simple-history-3.1.1/simple_history/tests/custom_user/models.py000066400000000000000000000001371423103303600263610ustar00rootroot00000000000000from django.contrib.auth.models import AbstractUser class CustomUser(AbstractUser): pass django-simple-history-3.1.1/simple_history/tests/external/000077500000000000000000000000001423103303600237755ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/external/__init__.py000066400000000000000000000000001423103303600260740ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/external/models.py000066400000000000000000000026471423103303600256430ustar00rootroot00000000000000from django.db import models from simple_history import register from simple_history.models import HistoricalRecords from simple_history.tests.custom_user.models import CustomUser class AbstractExternal(models.Model): history = HistoricalRecords(inherit=True) class Meta: abstract = True class AbstractExternal2(models.Model): history = HistoricalRecords(inherit=True, custom_model_name=lambda x: f"Audit{x}") class Meta: abstract = True app_label = "external" class AbstractExternal3(models.Model): history = HistoricalRecords( inherit=True, app="external", custom_model_name=lambda x: f"Audit{x}" ) class Meta: abstract = True app_label = "external" class ExternalModel(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class ExternalModelRegistered(models.Model): name = models.CharField(max_length=100) register(ExternalModelRegistered, app="simple_history.tests", manager_name="histories") class ExternalModelWithCustomUserIdField(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords(history_user_id_field=models.IntegerField(null=True)) class Poll(models.Model): """Test model for same-named historical models This model intentionally conflicts with the 'Polls' model in 'tests.models'. """ history = HistoricalRecords(user_related_name="+") django-simple-history-3.1.1/simple_history/tests/models.py000066400000000000000000000540761423103303600240240ustar00rootroot00000000000000import datetime import uuid from django.apps import apps from django.conf import settings from django.db import models from django.db.models.deletion import CASCADE from django.db.models.fields.related import ForeignKey from django.urls import reverse from simple_history import register from simple_history.models import HistoricalRecords, HistoricForeignKey from .custom_user.models import CustomUser as User from .external.models import AbstractExternal, AbstractExternal2, AbstractExternal3 get_model = apps.get_model class Poll(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords() def get_absolute_url(self): return reverse("poll-detail", kwargs={"pk": self.pk}) class PollWithNonEditableField(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") modified = models.DateTimeField(auto_now=True, editable=False) history = HistoricalRecords() class PollWithUniqueQuestion(models.Model): question = models.CharField(max_length=200, unique=True) pub_date = models.DateTimeField("date published") history = HistoricalRecords() class PollWithExcludeFields(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") place = models.TextField(null=True) history = HistoricalRecords(excluded_fields=["pub_date"]) class PollWithExcludedFieldsWithDefaults(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") expiration_time = models.DateField(default=datetime.date(2030, 12, 12)) place = models.TextField(null=True) min_questions = models.PositiveIntegerField(default=1) max_questions = models.PositiveIntegerField() history = HistoricalRecords( excluded_fields=[ "pub_date", "expiration_time", "place", "min_questions", "max_questions", ] ) class PollWithExcludedFKField(models.Model): question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") place = models.ForeignKey("Place", on_delete=models.CASCADE) history = HistoricalRecords(excluded_fields=["place"]) class AlternativePollManager(models.Manager): def get_queryset(self): return super().get_queryset().exclude(id=1) class PollWithAlternativeManager(models.Model): some_objects = AlternativePollManager() all_objects = models.Manager() question = models.CharField(max_length=200) pub_date = models.DateTimeField("date published") history = HistoricalRecords() class 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().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrForeignKey(models.Model): poll = CustomAttrNameForeignKey(Poll, models.CASCADE, attr_name="custom_poll") history = HistoricalRecords() class CustomAttrNameOneToOneField(models.OneToOneField): def __init__(self, *args, **kwargs): self.attr_name = kwargs.pop("attr_name", None) super().__init__(*args, **kwargs) def get_attname(self): return self.attr_name or super().get_attname() def deconstruct(self): name, path, args, kwargs = super().deconstruct() if self.attr_name: kwargs["attr_name"] = self.attr_name return name, path, args, kwargs class ModelWithCustomAttrOneToOneField(models.Model): poll = CustomAttrNameOneToOneField(Poll, models.CASCADE, attr_name="custom_poll") history = HistoricalRecords(excluded_field_kwargs={"poll": {"attr_name"}}) class Temperature(models.Model): location = models.CharField(max_length=200) temperature = models.IntegerField() history = HistoricalRecords() __history_date = None @property def _history_date(self): return self.__history_date @_history_date.setter def _history_date(self, value): self.__history_date = value class WaterLevel(models.Model): waters = models.CharField(max_length=200) level = models.IntegerField() date = models.DateTimeField() history = HistoricalRecords(cascade_delete_history=True) @property def _history_date(self): return self.date class Choice(models.Model): poll = models.ForeignKey(Poll, on_delete=models.CASCADE) choice = models.CharField(max_length=200) votes = models.IntegerField() register(Choice) class Voter(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name="voters") def __str__(self): return "Voter object" class HistoricalRecordsVerbose(HistoricalRecords): def get_extra_fields(self, model, fields): def verbose_str(self): return "{} changed by {} as of {}".format( self.history_object, self.history_user, self.history_date, ) extra_fields = super().get_extra_fields(model, fields) extra_fields["__str__"] = verbose_str return extra_fields register(Voter, records_class=HistoricalRecordsVerbose) class Place(models.Model): name = models.CharField(max_length=100) class Restaurant(Place): rating = models.IntegerField() updates = HistoricalRecords() class Person(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() def save(self, *args, **kwargs): if hasattr(self, "skip_history_when_saving"): raise RuntimeError("error while saving") else: super().save(*args, **kwargs) class FileModel(models.Model): title = models.CharField(max_length=100) file = models.FileField(upload_to="files") history = HistoricalRecords() # Set SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD setattr(settings, "SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD", True) class CharFieldFileModel(models.Model): title = models.CharField(max_length=100) file = models.FileField(upload_to="files") history = HistoricalRecords() # Clear SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD delattr(settings, "SIMPLE_HISTORY_FILEFIELD_TO_CHARFIELD") class Document(models.Model): changed_by = models.ForeignKey( User, on_delete=models.CASCADE, null=True, blank=True ) history = HistoricalRecords() @property def _history_user(self): try: return self.changed_by except User.DoesNotExist: return None class Paper(Document): history = HistoricalRecords() @Document._history_user.setter def _history_user(self, value): self.changed_by = value class RankedDocument(Document): rank = models.IntegerField(default=50) history = HistoricalRecords() class Profile(User): date_of_birth = models.DateField() class AdminProfile(models.Model): profile = models.ForeignKey(Profile, on_delete=models.CASCADE) class State(models.Model): library = models.ForeignKey("Library", on_delete=models.CASCADE, null=True) history = HistoricalRecords() class Book(models.Model): isbn = models.CharField(max_length=15, primary_key=True) history = HistoricalRecords( verbose_name="dead trees", verbose_name_plural="dead trees plural" ) class HardbackBook(Book): price = models.FloatField() class Bookcase(models.Model): books = models.ForeignKey(HardbackBook, on_delete=models.CASCADE) class Library(models.Model): book = models.ForeignKey(Book, on_delete=models.CASCADE, null=True) history = HistoricalRecords() class Meta: verbose_name = "quiet please" verbose_name_plural = "quiet please plural" class BaseModel(models.Model): pass class FirstLevelInheritedModel(BaseModel): pass class SecondLevelInheritedModel(FirstLevelInheritedModel): pass class AbstractBase(models.Model): class Meta: abstract = True class ConcreteAttr(AbstractBase): history = HistoricalRecords(bases=[AbstractBase]) class ConcreteUtil(AbstractBase): pass register(ConcreteUtil, bases=[AbstractBase]) class MultiOneToOne(models.Model): fk = models.ForeignKey(SecondLevelInheritedModel, on_delete=models.CASCADE) class SelfFK(models.Model): fk = models.ForeignKey("self", on_delete=models.CASCADE, null=True) history = HistoricalRecords() register(User, app="simple_history.tests", manager_name="histories") class ExternalModelWithAppLabel(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class Meta: app_label = "external" class ExternalModelSpecifiedWithAppParam(models.Model): name = models.CharField(max_length=100) register( ExternalModelSpecifiedWithAppParam, app="simple_history.tests.external", manager_name="histories", ) class UnicodeVerboseName(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class Meta: verbose_name = "\u570b" class UnicodeVerboseNamePlural(models.Model): name = models.CharField(max_length=100) history = HistoricalRecords() class Meta: verbose_name_plural = "\u570b" class CustomFKError(models.Model): fk = models.ForeignKey(SecondLevelInheritedModel, on_delete=models.CASCADE) history = HistoricalRecords() class Series(models.Model): """A series of works, like a trilogy of books.""" name = models.CharField(max_length=100) author = models.CharField(max_length=100) class SeriesWork(models.Model): series = models.ForeignKey("Series", on_delete=models.CASCADE, related_name="works") title = models.CharField(max_length=100) history = HistoricalRecords() class Meta: order_with_respect_to = "series" class PollInfo(models.Model): poll = models.OneToOneField(Poll, on_delete=models.CASCADE, primary_key=True) history = HistoricalRecords() class UserAccessorDefault(models.Model): pass class UserAccessorOverride(models.Model): pass class Employee(models.Model): manager = models.OneToOneField("Employee", null=True, on_delete=models.CASCADE) history = HistoricalRecords() class Country(models.Model): code = models.CharField(max_length=15, unique=True) class Province(models.Model): country = models.ForeignKey(Country, on_delete=models.CASCADE, to_field="code") history = HistoricalRecords() class City(models.Model): country = models.ForeignKey( Country, on_delete=models.CASCADE, db_column="countryCode" ) history = HistoricalRecords() class Planet(models.Model): star = models.CharField(max_length=30) history = HistoricalRecords() def __str__(self): return self.star class Meta: verbose_name = "Planet" class Contact(models.Model): name = models.CharField(max_length=30) email = models.EmailField(max_length=255, unique=True) history = HistoricalRecords(table_name="contacts_history") class ContactRegister(models.Model): name = models.CharField(max_length=30) email = models.EmailField(max_length=255, unique=True) register(ContactRegister, table_name="contacts_register_history") class ModelWithHistoryInDifferentApp(models.Model): name = models.CharField(max_length=30) history = HistoricalRecords(app="external") class ModelWithHistoryInDifferentDb(models.Model): name = models.CharField(max_length=30) history = HistoricalRecords() class ModelWithHistoryUsingBaseModelDb(models.Model): name = models.CharField(max_length=30) history = HistoricalRecords(use_base_model_db=True) class ModelWithFkToModelWithHistoryUsingBaseModelDb(models.Model): fk = models.ForeignKey( ModelWithHistoryUsingBaseModelDb, on_delete=models.CASCADE, null=True ) history = HistoricalRecords(use_base_model_db=True) ############################################################################### # # Inheritance examples # ############################################################################### class TrackedAbstractBaseA(models.Model): history = HistoricalRecords(inherit=True) class Meta: abstract = True class TrackedAbstractBaseB(models.Model): history_b = HistoricalRecords(inherit=True) class Meta: abstract = True class UntrackedAbstractBase(models.Model): class Meta: abstract = True class TrackedConcreteBase(models.Model): history = HistoricalRecords(inherit=True) class UntrackedConcreteBase(models.Model): pass class ConcreteExternal(AbstractExternal): name = models.CharField(max_length=50) class Meta: app_label = "tests" class ConcreteExternal2(AbstractExternal): name = models.CharField(max_length=50) class Meta: pass # Don't set app_label to test inherited module path class TrackedWithAbstractBase(TrackedAbstractBaseA): pass class TrackedWithConcreteBase(TrackedConcreteBase): pass class InheritTracking1(TrackedAbstractBaseA, UntrackedConcreteBase): pass class BaseInheritTracking2(TrackedAbstractBaseA): pass class InheritTracking2(BaseInheritTracking2): pass class BaseInheritTracking3(TrackedAbstractBaseA): pass class InheritTracking3(BaseInheritTracking3): pass class InheritTracking4(TrackedAbstractBaseA): pass class BasePlace(models.Model): name = models.CharField(max_length=50) history = HistoricalRecords(inherit=True) class InheritedRestaurant(BasePlace): serves_hot_dogs = models.BooleanField(default=False) class BucketMember(models.Model): name = models.CharField(max_length=30) user = models.OneToOneField( User, related_name="bucket_member", on_delete=models.CASCADE ) class BucketData(models.Model): changed_by = models.ForeignKey( BucketMember, on_delete=models.SET_NULL, null=True, blank=True ) history = HistoricalRecords(user_model=BucketMember) @property def _history_user(self): return self.changed_by def get_bucket_member_changed_by(instance, **kwargs): try: return instance.changed_by except AttributeError: return None class BucketDataRegisterChangedBy(models.Model): changed_by = models.ForeignKey( BucketMember, on_delete=models.SET_NULL, null=True, blank=True ) register( BucketDataRegisterChangedBy, user_model=BucketMember, get_user=get_bucket_member_changed_by, ) def get_bucket_member_request_user(request, **kwargs): try: return request.user.bucket_member except AttributeError: return None class BucketDataRegisterRequestUser(models.Model): data = models.CharField(max_length=30) def get_absolute_url(self): return reverse("bucket_data-detail", kwargs={"pk": self.pk}) register( BucketDataRegisterRequestUser, user_model=BucketMember, get_user=get_bucket_member_request_user, ) class UUIDModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) history = HistoricalRecords(history_id_field=models.UUIDField(default=uuid.uuid4)) class UUIDRegisterModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) register(UUIDRegisterModel, history_id_field=models.UUIDField(default=uuid.uuid4)) # Set the SIMPLE_HISTORY_HISTORY_ID_USE_UUID setattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID", True) class UUIDDefaultModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) history = HistoricalRecords() # Clear the SIMPLE_HISTORY_HISTORY_ID_USE_UUID delattr(settings, "SIMPLE_HISTORY_HISTORY_ID_USE_UUID") # Set the SIMPLE_HISTORY_HISTORY_CHANGE_REASON_FIELD setattr(settings, "SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD", True) class DefaultTextFieldChangeReasonModel(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords() # Clear the SIMPLE_HISTORY_HISTORY_CHANGE_REASON_FIELD delattr(settings, "SIMPLE_HISTORY_HISTORY_CHANGE_REASON_USE_TEXT_FIELD") class UserTextFieldChangeReasonModel(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords(history_change_reason_field=models.TextField(null=True)) class CharFieldChangeReasonModel(models.Model): greeting = models.CharField(max_length=100) history = HistoricalRecords() class CustomManagerNameModel(models.Model): name = models.CharField(max_length=15) log = HistoricalRecords() # # Following classes test the "custom_model_name" option # class OverrideModelNameAsString(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords(custom_model_name="MyHistoricalCustomNameModel") class OverrideModelNameAsCallable(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords(custom_model_name=lambda x: f"Audit{x}") class AbstractModelCallable1(models.Model): history = HistoricalRecords(inherit=True, custom_model_name=lambda x: f"Audit{x}") class Meta: abstract = True class OverrideModelNameUsingBaseModel1(AbstractModelCallable1): name = models.CharField(max_length=15, unique=True) class OverrideModelNameUsingExternalModel1(AbstractExternal2): name = models.CharField(max_length=15, unique=True) class OverrideModelNameUsingExternalModel2(AbstractExternal3): name = models.CharField(max_length=15, unique=True) class OverrideModelNameRegisterMethod1(models.Model): name = models.CharField(max_length=15, unique=True) register( OverrideModelNameRegisterMethod1, custom_model_name="MyOverrideModelNameRegisterMethod1", ) class OverrideModelNameRegisterMethod2(models.Model): name = models.CharField(max_length=15, unique=True) class ForeignKeyToSelfModel(models.Model): fk_to_self = models.ForeignKey( "ForeignKeyToSelfModel", null=True, related_name="+", on_delete=models.CASCADE ) fk_to_self_using_str = models.ForeignKey( "self", null=True, related_name="+", on_delete=models.CASCADE ) history = HistoricalRecords() class Street(models.Model): name = models.CharField(max_length=150) log = HistoricalRecords(related_name="history") class ManyToManyModelOther(models.Model): name = models.CharField(max_length=15, unique=True) class BulkCreateManyToManyModel(models.Model): name = models.CharField(max_length=15, unique=True) other = models.ManyToManyField(ManyToManyModelOther) history = HistoricalRecords() class ModelWithExcludedManyToMany(models.Model): name = models.CharField(max_length=15, unique=True) other = models.ManyToManyField(ManyToManyModelOther) history = HistoricalRecords(excluded_fields=["other"]) class ModelWithSingleNoDBIndexUnique(models.Model): name = models.CharField(max_length=15, unique=True, db_index=True) name_keeps_index = models.CharField(max_length=15, unique=True, db_index=True) history = HistoricalRecords(no_db_index="name") class ModelWithMultipleNoDBIndex(models.Model): name = models.CharField(max_length=15, db_index=True) name_keeps_index = models.CharField(max_length=15, db_index=True) fk = models.ForeignKey( "Library", on_delete=models.CASCADE, null=True, related_name="+" ) fk_keeps_index = models.ForeignKey( "Library", on_delete=models.CASCADE, null=True, related_name="+" ) history = HistoricalRecords(no_db_index=["name", "fk", "other"]) class TestOrganization(models.Model): name = models.CharField(max_length=15, unique=True) class TestOrganizationWithHistory(models.Model): name = models.CharField(max_length=15, unique=True) history = HistoricalRecords() class TestParticipantToHistoricOrganization(models.Model): """ Non-historic table foreign key to historic table. In this case it should simply behave like ForeignKey because the origin model (this one) cannot be historic, so foreign key lookups are always "current". """ name = models.CharField(max_length=15, unique=True) organization = HistoricForeignKey( TestOrganizationWithHistory, on_delete=CASCADE, related_name="participants" ) class TestHistoricParticipantToOrganization(models.Model): """ Historic table foreign key to non-historic table. In this case it should simply behave like ForeignKey because the origin model (this one) can be historic but the target model is not, so foreign key lookups are always "current". """ name = models.CharField(max_length=15, unique=True) organization = HistoricForeignKey( TestOrganization, on_delete=CASCADE, related_name="participants" ) history = HistoricalRecords() class TestHistoricParticipanToHistoricOrganization(models.Model): """ Historic table foreign key to historic table. In this case as_of queries on the origin model (this one) or on the target model (the other one) will traverse the foreign key relationship honoring the timepoint of the original query. This only happens when both tables involved are historic. NOTE: related_name has to be different than the one used in TestParticipantToHistoricOrganization as they are sharing the same target table. """ name = models.CharField(max_length=15, unique=True) organization = HistoricForeignKey( TestOrganizationWithHistory, on_delete=CASCADE, related_name="historic_participants", ) history = HistoricalRecords() django-simple-history-3.1.1/simple_history/tests/other_admin.py000066400000000000000000000003211423103303600250120ustar00rootroot00000000000000from 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-3.1.1/simple_history/tests/tests/000077500000000000000000000000001423103303600233155ustar00rootroot00000000000000django-simple-history-3.1.1/simple_history/tests/tests/__init__.py000066400000000000000000000001561423103303600254300ustar00rootroot00000000000000from .test_admin import * from .test_commands import * from .test_manager import * from .test_models import * django-simple-history-3.1.1/simple_history/tests/tests/test_admin.py000066400000000000000000000707461423103303600260340ustar00rootroot00000000000000from datetime import datetime, timedelta from unittest.mock import ANY, patch import django from django.contrib.admin import AdminSite from django.contrib.admin.utils import quote from django.contrib.auth import get_user_model from django.contrib.messages.storage.fallback import FallbackStorage from django.test import TestCase from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse from django.utils.encoding import force_str 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, Planet, 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( f"{site}:{app}_{model}_history", args=[quote(obj.pk)], ) class AdminSiteTest(TestCase): def setUp(self): self.user = User.objects.create_superuser("user_login", "u@example.com", "pass") def tearDown(self): try: del HistoricalRecords.context.request except AttributeError: pass def login(self, user=None, superuser=None): user = user or self.user if superuser is not None: user.is_superuser = True if superuser is None else superuser user.is_active = True user.save() self.client.force_login(user) def test_history_list(self): model_name = self.user._meta.model_name self.assertEqual(model_name, "customuser") self.login() poll = Poll(question="why?", pub_date=today) poll._change_reason = "A random test reason" poll._history_user = self.user poll.save() response = self.client.get(get_history_url(poll)) self.assertContains(response, get_history_url(poll, 0)) self.assertContains(response, "Poll object") self.assertContains(response, "Created") self.assertContains(response, "Changed by") self.assertContains(response, "Change reason") self.assertContains(response, "A random test reason") self.assertContains(response, self.user.username) def test_history_list_custom_fields(self): model_name = self.user._meta.model_name self.assertEqual(model_name, "customuser") self.login() poll = Poll(question="why?", pub_date=today) poll._history_user = self.user poll.save() choice = Choice(poll=poll, choice="because", votes=12) choice._history_user = self.user choice.save() choice.votes = 15 choice.save() response = self.client.get(get_history_url(choice)) self.assertContains(response, get_history_url(choice, 0)) self.assertContains(response, "Choice object") self.assertContains(response, "Created") self.assertContains(response, self.user.username) self.assertContains(response, "votes") self.assertContains(response, "12") self.assertContains(response, "15") def test_history_list_custom_admin_methods(self): model_name = self.user._meta.model_name self.assertEqual(model_name, "customuser") self.login() file_model = FileModel(title="Title 1") file_model._history_user = self.user file_model.save() file_model.title = "Title 2" file_model.save() response = self.client.get(get_history_url(file_model)) self.assertContains(response, get_history_url(file_model, 0)) self.assertContains(response, "FileModel object") self.assertContains(response, "Created") self.assertContains(response, self.user.username) self.assertContains(response, "test_method_value") self.assertContains(response, "Title 1") self.assertContains(response, "Title 2") def test_history_list_custom_user_id_field(self): instance = ExternalModelWithCustomUserIdField(name="random_name") instance._history_user = self.user instance.save() self.login() resp = self.client.get(get_history_url(instance)) self.assertEqual(200, resp.status_code) def test_history_view_permission(self): self.login() person = Person.objects.create(name="Sandra Hale") resp = self.client.get(get_history_url(person)) self.assertEqual(403, resp.status_code) def test_history_form_permission(self): self.login(self.user) person = Person.objects.create(name="Sandra Hale") resp = self.client.get(get_history_url(person, 0)) self.assertEqual(403, resp.status_code) def test_invalid_history_form(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) response = self.client.post(get_history_url(poll, 0), data={"question": ""}) self.assertEqual(response.status_code, 200) self.assertContains(response, "This field is required") def test_history_form(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() # Make sure form for initial version is correct response = self.client.get(get_history_url(poll, 0)) form = response.context.get("adminform").form self.assertEqual(form["question"].value(), "why?") self.assertEqual(form["pub_date"].value(), datetime(2021, 1, 1, 10, 0)) # Create new version based on original version new_data = { "pub_date_0": "2021-01-02", "pub_date_1": "10:00:00", "question": "what?", } response = self.client.post(get_history_url(poll, 0), data=new_data) self.assertEqual(response.status_code, 302) self.assertTrue(response.url.endswith(reverse("admin:tests_poll_changelist"))) # Ensure form for second version is correct response = self.client.get(get_history_url(poll, 1)) form = response.context.get("adminform").form self.assertEqual(form["question"].value(), "how?") self.assertEqual(form["pub_date"].value(), datetime(2021, 1, 1, 10, 0)) # Ensure form for new third version is correct response = self.client.get(get_history_url(poll, 2)) form = response.context["adminform"].form self.assertEqual(form["question"].value(), "what?") self.assertEqual(form["pub_date"].value(), datetime(2021, 1, 2, 10, 0)) # Ensure current version of poll is correct poll = Poll.objects.get() self.assertEqual(poll.question, "what?") self.assertEqual(poll.pub_date, tomorrow) self.assertEqual( [p.history_user for p in Poll.history.all()], [self.user, None, None] ) def test_history_user_on_save_in_admin(self): self.login() # Ensure polls created via admin interface save correct user poll_data = { "question": "new poll?", "pub_date_0": "2012-01-01", "pub_date_1": "10:00:00", } self.client.post(reverse("admin:tests_poll_add"), data=poll_data) self.assertEqual(Poll.history.get().history_user, self.user) # Ensure polls saved on edit page in admin interface save correct user self.client.post(reverse("admin:tests_poll_add"), data=poll_data) self.assertEqual( [p.history_user for p in Poll.history.all()], [self.user, self.user] ) def test_underscore_in_pk(self): self.login() book = Book(isbn="9780147_513731") book._history_user = self.user book.save() response = self.client.get(get_history_url(book)) self.assertContains(response, book.history.all()[0].revert_url()) def test_historical_user_no_setter(self): """Demonstrate admin error without `_historical_user` setter. (Issue #43) """ self.login() with self.assertRaises(AttributeError): self.client.post(reverse("admin:tests_document_add")) def test_historical_user_with_setter(self): """Documented work-around for #43""" self.login() self.client.post(reverse("admin:tests_paper_add")) def test_history_user_not_saved(self): self.login() poll = Poll.objects.create(question="why?", pub_date=today) historical_poll = poll.history.all()[0] self.assertIsNone( historical_poll.history_user, "No way to know of request, history_user should be unset.", ) @override_settings(**middleware_override_settings) def test_middleware_saves_user(self): self.login() self.client.post( reverse("admin:tests_book_add"), data={"isbn": "9780147_513731"} ) book = Book.objects.get() historical_book = book.history.all()[0] self.assertEqual( historical_book.history_user, self.user, "Middleware should make the request available to " "retrieve history_user.", ) @override_settings(**middleware_override_settings) def test_middleware_unsets_request(self): self.login() self.client.get(reverse("admin:tests_book_add")) self.assertFalse(hasattr(HistoricalRecords.context, "request")) @override_settings(**middleware_override_settings) def test_rolled_back_user_does_not_lead_to_foreign_key_error(self): # This test simulates the rollback of a user after a request (which # happens, e.g. in test cases), and verifies that subsequently # creating a new entry does not fail with a foreign key error. self.login() self.assertEqual( self.client.get(reverse("admin:tests_book_add")).status_code, 200 ) book = Book.objects.create(isbn="9780147_513731") historical_book = book.history.all()[0] self.assertIsNone( historical_book.history_user, "No way to know of request, history_user should be unset.", ) @override_settings(**middleware_override_settings) def test_middleware_anonymous_user(self): self.client.get(reverse("admin:index")) poll = Poll.objects.create(question="why?", pub_date=today) historical_poll = poll.history.all()[0] self.assertEqual( historical_poll.history_user, None, "Middleware request user should be able to " "be anonymous.", ) def test_other_admin(self): """Test non-default admin instances. Make sure non-default admin instances can resolve urls and render pages. """ self.login() state = State.objects.create() history_url = get_history_url(state, site="other_admin") self.client.get(history_url) change_url = get_history_url(state, 0, site="other_admin") self.client.get(change_url) def test_deleting_user(self): """Test deletes of a user does not cascade delete the history""" self.login() poll = Poll(question="why?", pub_date=today) poll._history_user = self.user poll.save() historical_poll = poll.history.all()[0] self.assertEqual(historical_poll.history_user, self.user) self.user.delete() historical_poll = poll.history.all()[0] self.assertEqual(historical_poll.history_user, None) def test_deleteting_member(self): """Test deletes of a BucketMember doesn't cascade delete the history""" self.login() member = BucketMember.objects.create(name="member1", user=self.user) bucket_data = BucketData(changed_by=member) bucket_data.save() historical_poll = bucket_data.history.all()[0] self.assertEqual(historical_poll.history_user, member) member.delete() historical_poll = bucket_data.history.all()[0] self.assertEqual(historical_poll.history_user, None) def test_missing_one_to_one(self): """A relation to a missing one-to-one model should still show history""" self.login() manager = Employee.objects.create() employee = Employee.objects.create(manager=manager) employee.manager = None employee.save() manager.delete() response = self.client.get(get_history_url(employee, 0)) self.assertEqual(response.status_code, 200) def test_history_deleted_instance(self): """Ensure history page can be retrieved even for deleted instances""" self.login() employee = Employee.objects.create() employee_pk = employee.pk employee.delete() employee.pk = employee_pk response = self.client.get(get_history_url(employee)) self.assertEqual(response.status_code, 200) def test_response_change(self): """ Test the response_change method that it works with a _change_history in the POST and settings.SIMPLE_HISTORY_EDIT set to True """ request = RequestFactory().post("/") request.POST = {"_change_history": True} request.session = "session" request._messages = FallbackStorage(request) request.path = "/awesome/url/" poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) with patch("simple_history.admin.SIMPLE_HISTORY_EDIT", True): response = admin.response_change(request, poll) self.assertEqual(response["Location"], "/awesome/url/") def test_response_change_change_history_setting_off(self): """ Test the response_change method that it works with a _change_history in the POST and settings.SIMPLE_HISTORY_EDIT set to False """ request = RequestFactory().post("/") request.POST = {"_change_history": True} request.session = "session" request._messages = FallbackStorage(request) request.path = "/awesome/url/" request.user = self.user poll = Poll.objects.create(question="why?", pub_date=today) poll.question = "how?" poll.save() admin_site = AdminSite() admin = SimpleHistoryAdmin(Poll, admin_site) 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_str(poll), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.id}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled, "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_str(history.instance), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.pk}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled, "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_str(poll), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.id}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled, "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_str(history.instance), "adminform": ANY, "object_id": obj.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/concreteexternal/", "change_url": ANY, "history_url": "/admin/tests/concreteexternal/{pk}/history/".format( pk=obj.pk ), "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, obj), "has_delete_permission": admin.has_delete_permission(request, obj), "revert_disabled": admin.revert_disabled, "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_str(poll), "adminform": ANY, "object_id": poll.id, "is_popup": False, "media": ANY, "errors": ANY, "app_label": "tests", "original_opts": ANY, "changelist_url": "/admin/tests/poll/", "change_url": ANY, "history_url": f"/admin/tests/poll/{poll.id}/history/", "add": False, "change": True, "has_add_permission": admin.has_add_permission(request), "has_change_permission": admin.has_change_permission(request, poll), "has_delete_permission": admin.has_delete_permission(request, poll), "revert_disabled": admin.revert_disabled, "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_view__title_suggests_revert_by_default(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet)) self.assertContains(response, "Change history: Sun") @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=False) def test_history_view__title_suggests_revert(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet)) self.assertContains(response, "Change history: Sun") self.assertContains(response, "Choose a date") @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=True) def test_history_view__title_suggests_view_only(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet)) self.assertContains(response, "View history: Sun") self.assertNotContains(response, "Choose a date") def test_history_form_view__shows_revert_button_by_default(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertContains(response, "Revert Planet") self.assertContains(response, "Revert Sun") self.assertContains(response, "Press the 'Revert' button") @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=False) def test_history_form_view__shows_revert_button(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertContains(response, "Revert Planet") self.assertContains(response, "Revert Sun") self.assertContains(response, "Press the 'Revert' button") @override_settings(SIMPLE_HISTORY_REVERT_DISABLED=True) def test_history_form_view__does_not_show_revert_button(self): self.login() planet = Planet.objects.create(star="Sun") response = self.client.get(get_history_url(planet, 0)) self.assertContains(response, "View Planet") self.assertContains(response, "View Sun") self.assertNotContains(response, "Revert") django-simple-history-3.1.1/simple_history/tests/tests/test_commands.py000066400000000000000000000507321423103303600265360ustar00rootroot00000000000000from contextlib import contextmanager from datetime import datetime, timedelta from io import StringIO from django.core import management from django.test import TestCase from simple_history import models as sh_models from simple_history.management.commands import ( clean_duplicate_history, clean_old_history, populate_history, ) from ..models import ( Book, CustomManagerNameModel, Place, Poll, PollWithExcludeFields, Restaurant, ) @contextmanager def replace_registry(new_value=None): hidden_registry = sh_models.registered_models sh_models.registered_models = new_value or {} try: yield except Exception: raise finally: sh_models.registered_models = hidden_registry class TestPopulateHistory(TestCase): command_name = "populate_history" command_error = (management.CommandError, SystemExit) def test_no_args(self): out = StringIO() management.call_command(self.command_name, stdout=out, stderr=StringIO()) self.assertIn(populate_history.Command.COMMAND_HINT, out.getvalue()) def test_bad_args(self): test_data = ( (populate_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)), (populate_history.Command.MODEL_NOT_FOUND, ("invalid.model",)), (populate_history.Command.MODEL_NOT_FOUND, ("bad_key",)), ) for msg, args in test_data: out = StringIO() self.assertRaises( self.command_error, management.call_command, self.command_name, *args, stdout=StringIO(), stderr=out, ) self.assertIn(msg, out.getvalue()) def test_auto_populate(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() management.call_command( self.command_name, auto=True, stdout=StringIO(), stderr=StringIO() ) self.assertEqual(Poll.history.all().count(), 1) def test_populate_with_custom_batch_size(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() management.call_command( self.command_name, auto=True, batchsize=500, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 1) def test_specific_populate(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() Book.objects.create(isbn="9780007117116") Book.history.all().delete() management.call_command( self.command_name, "tests.book", stdout=StringIO(), stderr=StringIO() ) self.assertEqual(Book.history.all().count(), 1) self.assertEqual(Poll.history.all().count(), 0) def test_failing_wont_save(self): Poll.objects.create(question="Will this populate?", pub_date=datetime.now()) Poll.history.all().delete() self.assertRaises( self.command_error, management.call_command, self.command_name, "tests.poll", "tests.invalid_model", stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 0) def test_multi_table(self): data = {"rating": 5, "name": "Tea 'N More"} Restaurant.objects.create(**data) Restaurant.updates.all().delete() management.call_command( self.command_name, "tests.restaurant", stdout=StringIO(), stderr=StringIO() ) update_record = Restaurant.updates.all()[0] for attr, value in data.items(): self.assertEqual(getattr(update_record, attr), value) def test_existing_objects(self): data = {"rating": 5, "name": "Tea 'N More"} out = StringIO() Restaurant.objects.create(**data) pre_call_count = Restaurant.updates.count() management.call_command( self.command_name, "tests.restaurant", stdout=StringIO(), stderr=out ) self.assertEqual(Restaurant.updates.count(), pre_call_count) self.assertIn(populate_history.Command.EXISTING_HISTORY_FOUND, out.getvalue()) def test_no_historical(self): out = StringIO() with replace_registry({"test_place": Place}): management.call_command(self.command_name, auto=True, stdout=out) self.assertIn(populate_history.Command.NO_REGISTERED_MODELS, out.getvalue()) def test_batch_processing_with_batch_size_less_than_total(self): data = [ Poll(id=1, question="Question 1", pub_date=datetime.now()), Poll(id=2, question="Question 2", pub_date=datetime.now()), Poll(id=3, question="Question 3", pub_date=datetime.now()), Poll(id=4, question="Question 4", pub_date=datetime.now()), ] Poll.objects.bulk_create(data) management.call_command( self.command_name, auto=True, batchsize=3, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.count(), 4) def test_stdout_not_printed_when_verbosity_is_0(self): out = StringIO() Poll.objects.create(question="Question 1", pub_date=datetime.now()) management.call_command( self.command_name, auto=True, batchsize=3, stdout=out, stderr=StringIO(), verbosity=0, ) self.assertEqual(out.getvalue(), "") def test_stdout_printed_when_verbosity_is_not_specified(self): out = StringIO() Poll.objects.create(question="Question 1", pub_date=datetime.now()) management.call_command( self.command_name, auto=True, batchsize=3, stdout=out, stderr=StringIO() ) self.assertNotEqual(out.getvalue(), "") def test_excluded_fields(self): poll = PollWithExcludeFields.objects.create( question="Will this work?", pub_date=datetime.now() ) PollWithExcludeFields.history.all().delete() management.call_command( self.command_name, "tests.pollwithexcludefields", auto=True, stdout=StringIO(), stderr=StringIO(), ) initial_history_record = PollWithExcludeFields.history.all()[0] self.assertEqual(initial_history_record.question, poll.question) class TestCleanDuplicateHistory(TestCase): command_name = "clean_duplicate_history" command_error = (management.CommandError, SystemExit) def test_no_args(self): out = StringIO() management.call_command(self.command_name, stdout=out, stderr=StringIO()) self.assertIn(clean_duplicate_history.Command.COMMAND_HINT, out.getvalue()) def test_bad_args(self): test_data = ( (clean_duplicate_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)), (clean_duplicate_history.Command.MODEL_NOT_FOUND, ("invalid.model",)), (clean_duplicate_history.Command.MODEL_NOT_FOUND, ("bad_key",)), ) for msg, args in test_data: out = StringIO() self.assertRaises( self.command_error, management.call_command, self.command_name, *args, stdout=StringIO(), stderr=out, ) self.assertIn(msg, out.getvalue()) def test_no_historical(self): out = StringIO() with replace_registry({"test_place": Place}): management.call_command(self.command_name, auto=True, stdout=out) self.assertIn( clean_duplicate_history.Command.NO_REGISTERED_MODELS, out.getvalue() ) def test_auto_dry_run(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) p.save() # not related to dry_run test, just for increasing coverage :) # create instance with single-entry history older than "minutes" # so it is skipped p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) h = p.history.first() h.history_date -= timedelta(hours=1) h.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, minutes=50, dry=True, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 3) def test_auto_cleanup(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() self.assertEqual(Poll.history.all().count(), 2) p.question = "Maybe this one won't...?" p.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_verbose(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.question = "Maybe this one won't...?" p.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, "tests.poll", auto=True, verbosity=2, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), " has 3 historical entries\n" "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_dated(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() self.assertEqual(Poll.history.all().count(), 5) for h in Poll.history.all()[2:]: h.history_date -= timedelta(hours=1) h.save() management.call_command( self.command_name, auto=True, minutes=50, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 4) def test_auto_cleanup_dated_extra_one(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() p.save() p.save() self.assertEqual(Poll.history.all().count(), 7) for h in Poll.history.all()[2:]: h.history_date -= timedelta(hours=1) h.save() management.call_command( self.command_name, auto=True, minutes=50, stdout=StringIO(), stderr=StringIO(), ) # even though only the last 2 entries match the date range # the "extra_one" (the record before the oldest match) # is identical to the oldest match, so oldest match is deleted self.assertEqual(Poll.history.all().count(), 5) def test_auto_cleanup_custom_history_field(self): m = CustomManagerNameModel.objects.create(name="John") self.assertEqual(CustomManagerNameModel.log.all().count(), 1) m.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 2) m.name = "Ivan" m.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(CustomManagerNameModel.log.all().count(), 2) def test_auto_cleanup_with_excluded_fields(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.pub_date = p.pub_date + timedelta(days=1) p.save() self.assertEqual(Poll.history.all().count(), 2) out = StringIO() management.call_command( self.command_name, auto=True, excluded_fields=("pub_date",), stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 1) class TestCleanOldHistory(TestCase): command_name = "clean_old_history" command_error = (management.CommandError, SystemExit) def test_no_args(self): out = StringIO() management.call_command(self.command_name, stdout=out, stderr=StringIO()) self.assertIn(clean_old_history.Command.COMMAND_HINT, out.getvalue()) def test_bad_args(self): test_data = ( (clean_old_history.Command.MODEL_NOT_HISTORICAL, ("tests.place",)), (clean_old_history.Command.MODEL_NOT_FOUND, ("invalid.model",)), (clean_old_history.Command.MODEL_NOT_FOUND, ("bad_key",)), ) for msg, args in test_data: out = StringIO() self.assertRaises( self.command_error, management.call_command, self.command_name, *args, stdout=StringIO(), stderr=out, ) self.assertIn(msg, out.getvalue()) def test_no_historical(self): out = StringIO() with replace_registry({"test_place": Place}): management.call_command(self.command_name, auto=True, stdout=out) self.assertIn(clean_old_history.Command.NO_REGISTERED_MODELS, out.getvalue()) def test_auto_dry_run(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) p.save() # not related to dry_run test, just for increasing coverage :) # create instance with single-entry history older than "minutes" # so it is skipped p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) h = p.history.first() h.history_date -= timedelta(days=31) h.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, days=20, dry=True, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 3) def test_auto_cleanup(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() self.assertEqual(Poll.history.all().count(), 2) p.question = "Maybe this one won't...?" p.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() h = p.history.first() h.history_date -= timedelta(days=40) h.save() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_verbose(self): p = Poll.objects.create( question="Will this be deleted?", pub_date=datetime.now() ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.question = "Maybe this one won't...?" p.save() h = p.history.first() h.history_date -= timedelta(days=40) h.save() self.assertEqual(Poll.history.all().count(), 3) out = StringIO() management.call_command( self.command_name, "tests.poll", auto=True, verbosity=2, stdout=out, stderr=StringIO(), ) self.assertEqual( out.getvalue(), " has 1 old historical entries\n" "Removed 1 historical records for " "\n", ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_dated(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() self.assertEqual(Poll.history.all().count(), 5) for h in Poll.history.all()[2:]: h.history_date -= timedelta(days=30) h.save() management.call_command( self.command_name, auto=True, days=20, stdout=StringIO(), stderr=StringIO(), ) self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_dated_extra_one(self): the_time_is_now = datetime.now() p = Poll.objects.create( question="Will this be deleted?", pub_date=the_time_is_now ) self.assertEqual(Poll.history.all().count(), 1) p.save() p.save() self.assertEqual(Poll.history.all().count(), 3) p.question = "Or this one...?" p.save() p.save() p.save() p.save() self.assertEqual(Poll.history.all().count(), 7) for h in Poll.history.all()[2:]: h.history_date -= timedelta(days=30) h.save() management.call_command( self.command_name, auto=True, days=20, stdout=StringIO(), stderr=StringIO(), ) # We will remove the 3 ones that we are marking as old self.assertEqual(Poll.history.all().count(), 2) def test_auto_cleanup_custom_history_field(self): m = CustomManagerNameModel.objects.create(name="John") self.assertEqual(CustomManagerNameModel.log.all().count(), 1) m.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 2) m.name = "Ivan" m.save() h = m.log.first() h.history_date -= timedelta(days=40) h.save() self.assertEqual(CustomManagerNameModel.log.all().count(), 3) out = StringIO() management.call_command( self.command_name, auto=True, stdout=out, stderr=StringIO() ) self.assertEqual( out.getvalue(), "Removed 1 historical records for " "\n", ) self.assertEqual(CustomManagerNameModel.log.all().count(), 2) django-simple-history-3.1.1/simple_history/tests/tests/test_index.py000066400000000000000000000010711423103303600260340ustar00rootroot00000000000000from django.conf import settings from django.db import models from django.test import TestCase, override_settings from simple_history.models import HistoricalRecords @override_settings(SIMPLE_HISTORY_DATE_INDEX="Composite") class HistoricalIndexTest(TestCase): def test_has_composite_index(self): self.assertEqual(settings.SIMPLE_HISTORY_DATE_INDEX, "Composite") class Foo(models.Model): history = HistoricalRecords() self.assertEqual( ("history_date", "id"), Foo.history.model._meta.index_together[0] ) django-simple-history-3.1.1/simple_history/tests/tests/test_manager.py000066400000000000000000000314331423103303600263440ustar00rootroot00000000000000from datetime import datetime, timedelta from operator import attrgetter from django.contrib.auth import get_user_model from django.db import IntegrityError from django.test import TestCase, override_settings, skipUnlessDBFeature from simple_history.manager import SIMPLE_HISTORY_REVERSE_ATTR_NAME from ..models import Document, Poll, RankedDocument User = get_user_model() class AsOfTest(TestCase): model = Document def setUp(self): user = User.objects.create_user("tester", "tester@example.com") self.now = datetime.now() self.yesterday = self.now - timedelta(days=1) self.obj = self.model.objects.create() self.obj.changed_by = user self.obj.save() self.model.objects.all().delete() # allows us to leave PK on instance ( self.delete_history, self.change_history, self.create_history, ) = self.model.history.all() self.create_history.history_date = self.now - timedelta(days=2) self.create_history.save() self.change_history.history_date = self.now - timedelta(days=1) self.change_history.save() self.delete_history.history_date = self.now self.delete_history.save() def test_created_after(self): """An object created after the 'as of' date should not be included. """ as_of_list = list(self.model.history.as_of(self.now - timedelta(days=5))) self.assertFalse(as_of_list) def test_deleted_before(self): """An object deleted before the 'as of' date should not be included. """ as_of_list = list(self.model.history.as_of(self.now + timedelta(days=1))) self.assertFalse(as_of_list) def test_deleted_after(self): """An object created before, but deleted after the 'as of' date should be included. """ as_of_list = list(self.model.history.as_of(self.now - timedelta(days=1))) self.assertEqual(len(as_of_list), 1) self.assertEqual(as_of_list[0].pk, self.obj.pk) def test_modified(self): """An object modified before the 'as of' date should reflect the last version. """ as_of_list = list(self.model.history.as_of(self.now - timedelta(days=1))) self.assertEqual(as_of_list[0].changed_by, self.obj.changed_by) class AsOfAdditionalTestCase(TestCase): def test_create_and_delete(self): document = Document.objects.create() now = datetime.now() document.delete() docs_as_of_now = Document.history.as_of(now) doc = docs_as_of_now[0] # as_of queries inject a property allowing callers # to go from instance to historical instance historic = getattr(doc, SIMPLE_HISTORY_REVERSE_ATTR_NAME) self.assertIsNotNone(historic) # as_of queries inject the time point of the original # query into the historic record so callers can do magical # things like chase historic foreign key relationships # by patching forward and reverse one-to-one relationship # processing (see issue 880) self.assertEqual(historic._as_of, now) docs_as_of_tmw = Document.history.as_of(now + timedelta(days=1)) with self.assertNumQueries(1): self.assertFalse(list(docs_as_of_tmw)) def test_multiple(self): document1 = Document.objects.create() document2 = Document.objects.create() historical = Document.history.as_of(datetime.now() + timedelta(days=1)) # history, even converted to objects, is kept in reverse chronological order # because sorting it based on the original table's meta ordering is not possible # when the ordering leverages foreign key relationships with self.assertNumQueries(1): self.assertEqual(list(historical), [document2, document1]) def test_filter_pk_as_instance(self): # when a queryset is returning historical documents, `pk` queries # reference the history_id; however when a queryset is returning # instances, `pk' queries reference the original table's primary key document1 = RankedDocument.objects.create(id=101, rank=42) RankedDocument.objects.create(id=102, rank=84) self.assertFalse(RankedDocument.history.filter(pk=document1.id)) self.assertTrue( RankedDocument.history.all().as_instances().filter(pk=document1.id) ) def test_as_of(self): """Demonstrates how as_of works now that it returns a QuerySet.""" t0 = datetime.now() document1 = RankedDocument.objects.create(rank=42) document2 = RankedDocument.objects.create(rank=84) t1 = datetime.now() document2.rank = 51 document2.save() document1.delete() t2 = datetime.now() # nothing exists at t0 queryset = RankedDocument.history.as_of(t0) self.assertEqual(queryset.count(), 0) # at t1, two records exist queryset = RankedDocument.history.as_of(t1) self.assertEqual(queryset.count(), 2) self.assertEqual(queryset.filter(rank__gte=75).count(), 1) ids = {item["id"] for item in queryset.values("id")} self.assertEqual(ids, {document1.id, document2.id}) # these records are historic record = queryset[0] historic = getattr(record, SIMPLE_HISTORY_REVERSE_ATTR_NAME) self.assertIsInstance(historic, RankedDocument.history.model) self.assertEqual(historic._as_of, t1) # at t2 we have one record left queryset = RankedDocument.history.as_of(t2) self.assertEqual(queryset.count(), 1) self.assertEqual(queryset.filter(rank__gte=75).count(), 0) def test_historical_query_set(self): """ Demonstrates how the HistoricalQuerySet works to provide as_of functionality. """ document1 = RankedDocument.objects.create(rank=42) document2 = RankedDocument.objects.create(rank=84) document2.rank = 51 document2.save() document1.delete() t2 = datetime.now() # look for historical records, get back a queryset with self.assertNumQueries(1): queryset = RankedDocument.history.filter(history_date__lte=t2) self.assertEqual(queryset.count(), 4) # only want the most recend records (provided by HistoricalQuerySet) self.assertEqual(queryset.latest_of_each().count(), 2) # want to see the instances as of that time? self.assertEqual(queryset.latest_of_each().as_instances().count(), 1) # these new methods are idempotent self.assertEqual( queryset.latest_of_each() .latest_of_each() .as_instances() .as_instances() .count(), 1, ) # that was all the same as calling as_of! self.assertEqual( set( RankedDocument.history.filter(history_date__lte=t2) .latest_of_each() .as_instances() ), set(RankedDocument.history.as_of(t2)), ) class BulkHistoryCreateTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=datetime.now()), Poll(id=2, question="Question 2", pub_date=datetime.now()), Poll(id=3, question="Question 3", pub_date=datetime.now()), Poll(id=4, question="Question 4", pub_date=datetime.now()), ] def test_simple_bulk_history_create(self): created = Poll.history.bulk_history_create(self.data) self.assertEqual(len(created), 4) self.assertQuerysetEqual( Poll.history.order_by("question"), ["Question 1", "Question 2", "Question 3", "Question 4"], attrgetter("question"), ) self.assertTrue( all([history.history_type == "+" for history in Poll.history.all()]) ) created = Poll.history.bulk_create([]) self.assertEqual(created, []) self.assertEqual(Poll.history.count(), 4) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_simple_bulk_history_create_without_history_enabled(self): Poll.history.bulk_history_create(self.data) self.assertEqual(Poll.history.count(), 0) def test_bulk_history_create_with_change_reason(self): for poll in self.data: poll._change_reason = "reason" Poll.history.bulk_history_create(self.data) self.assertTrue( all( [ history.history_change_reason == "reason" for history in Poll.history.all() ] ) ) def test_bulk_history_create_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") Poll.history.bulk_history_create(self.data, default_user=user) self.assertTrue( all([history.history_user == user for history in Poll.history.all()]) ) def test_bulk_history_create_with_default_change_reason(self): Poll.history.bulk_history_create(self.data, default_change_reason="test") self.assertTrue( all( [ history.history_change_reason == "test" for history in Poll.history.all() ] ) ) def test_bulk_history_create_history_user_overrides_default(self): user1 = User.objects.create_user("tester1", "tester1@example.com") user2 = User.objects.create_user("tester2", "tester2@example.com") for data in self.data: data._history_user = user1 Poll.history.bulk_history_create(self.data, default_user=user2) self.assertTrue( all([history.history_user == user1 for history in Poll.history.all()]) ) def test_bulk_history_create_change_reason_overrides_default(self): for data in self.data: data._change_reason = "my_reason" Poll.history.bulk_history_create(self.data, default_change_reason="test") self.assertTrue( all( [ history.history_change_reason == "my_reason" for history in Poll.history.all() ] ) ) def test_bulk_history_create_on_objs_without_ids(self): self.data = [ Poll(question="Question 1", pub_date=datetime.now()), Poll(question="Question 2", pub_date=datetime.now()), Poll(question="Question 3", pub_date=datetime.now()), Poll(question="Question 4", pub_date=datetime.now()), ] with self.assertRaises(IntegrityError): Poll.history.bulk_history_create(self.data) def test_set_custom_history_date_on_first_obj(self): self.data[0]._history_date = datetime(2000, 1, 1) Poll.history.bulk_history_create(self.data) self.assertEqual( Poll.history.order_by("question")[0].history_date, datetime(2000, 1, 1) ) def test_set_custom_history_user_on_first_obj(self): user = User.objects.create_user("tester", "tester@example.com") self.data[0]._history_user = user Poll.history.bulk_history_create(self.data) self.assertEqual(Poll.history.order_by("question")[0].history_user, user) @skipUnlessDBFeature("has_bulk_insert") def test_efficiency(self): with self.assertNumQueries(1): Poll.history.bulk_history_create(self.data) class BulkHistoryUpdateTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=datetime.now()), Poll(id=2, question="Question 2", pub_date=datetime.now()), Poll(id=3, question="Question 3", pub_date=datetime.now()), Poll(id=4, question="Question 4", pub_date=datetime.now()), ] def test_simple_bulk_history_create(self): created = Poll.history.bulk_history_create(self.data, update=True) self.assertEqual(len(created), 4) self.assertQuerysetEqual( Poll.history.order_by("question"), ["Question 1", "Question 2", "Question 3", "Question 4"], attrgetter("question"), ) self.assertTrue( all([history.history_type == "~" for history in Poll.history.all()]) ) created = Poll.history.bulk_create([]) self.assertEqual(created, []) self.assertEqual(Poll.history.count(), 4) def test_bulk_history_create_with_change_reason(self): for poll in self.data: poll._change_reason = "reason" Poll.history.bulk_history_create(self.data) self.assertTrue( all( [ history.history_change_reason == "reason" for history in Poll.history.all() ] ) ) django-simple-history-3.1.1/simple_history/tests/tests/test_middleware.py000066400000000000000000000207541423103303600270530ustar00rootroot00000000000000from 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]) @override_settings(**middleware_override_settings) class MiddlewareBulkOpsTest(TestCase): def setUp(self): self.user = CustomUser.objects.create_superuser( "user_login", "u@example.com", "pass" ) def test_user_is_set_on_bulk_create_view_when_logged_in(self): self.client.force_login(self.user) self.client.post(reverse("poll-bulk-create"), data={}) polls = Poll.objects.all() self.assertEqual(len(polls), 2) poll_history = Poll.history.all() self.assertCountEqual( [ph.history_user_id for ph in poll_history], [self.user.id, self.user.id] ) def test_user_is_not_set_on_bulk_create_view_not_logged_in(self): self.client.post(reverse("poll-bulk-create"), data={}) polls = Poll.objects.all() self.assertEqual(polls.count(), 2) poll_history = Poll.history.all() self.assertListEqual([ph.history_user_id for ph in poll_history], [None, None]) def test_request_user_is_overwritten_by_default_user_on_bulk_create_view( self, ): self.client.force_login(self.user) self.client.post(reverse("poll-bulk-create-with-default-user"), data={}) polls = Poll.objects.all() self.assertEqual(len(polls), 2) poll_history = Poll.history.all() self.assertFalse(any(ph.history_user_id == self.user.id for ph in poll_history)) self.assertFalse(any(ph.history_user_id is None for ph in poll_history)) def test_user_is_set_on_bulk_update_view_when_logged_in(self): self.client.force_login(self.user) poll_1 = Poll.objects.create(question="Test question 1", pub_date=date.today()) poll_2 = Poll.objects.create( question="Test question 2", pub_date=date(2020, 1, 1) ) self.client.post(reverse("poll-bulk-update"), data={}) polls = Poll.objects.all() self.assertEqual(2, len(polls)) self.assertEqual("1", poll_1.history.latest("history_date").question) self.assertEqual("0", poll_2.history.latest("history_date").question) self.assertEqual( self.user.id, poll_1.history.latest("history_date").history_user_id ) self.assertEqual( self.user.id, poll_2.history.latest("history_date").history_user_id ) def test_user_is_not_set_on_bulk_update_view_when_not_logged_in(self): poll_1 = Poll.objects.create(question="Test question 1", pub_date=date.today()) poll_2 = Poll.objects.create( question="Test question 2", pub_date=date(2020, 1, 1) ) self.client.post(reverse("poll-bulk-update"), data={}) self.assertIsNone(poll_1.history.latest("history_date").history_user_id) self.assertIsNone(poll_2.history.latest("history_date").history_user_id) def test_request_user_is_overwritten_by_default_user_on_bulk_update(self): self.client.force_login(self.user) poll = Poll.objects.create(pub_date=date(2020, 1, 1), question="123") self.client.post(reverse("poll-bulk-update-with-default-user"), data={}) self.assertIsNotNone(poll.history.latest("history_date").history_user_id) self.assertNotEqual( self.user.id, poll.history.latest("history_date").history_user_id ) django-simple-history-3.1.1/simple_history/tests/tests/test_models.py000066400000000000000000002251241423103303600262170ustar00rootroot00000000000000import unittest import uuid import warnings from datetime import datetime, timedelta import django from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.files.base import ContentFile from django.db import IntegrityError, models from django.db.models.fields.proxy import OrderWrt from django.test import TestCase, override_settings from django.urls import reverse from django.utils import timezone from simple_history import register from simple_history.exceptions import RelatedNameConflictError from simple_history.models import ( SIMPLE_HISTORY_REVERSE_ATTR_NAME, HistoricalRecords, ModelChange, is_historic, to_historic, ) 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, database_router_override_settings_history_in_diff_db, middleware_override_settings, ) from simple_history.utils import get_history_model_for_model, update_change_reason from ..external.models import ( ExternalModel, ExternalModelRegistered, ExternalModelWithCustomUserIdField, ) from ..models import ( AbstractBase, AdminProfile, BasePlace, Book, Bookcase, BucketData, BucketDataRegisterChangedBy, BucketMember, CharFieldChangeReasonModel, CharFieldFileModel, Choice, City, ConcreteAttr, ConcreteExternal, ConcreteUtil, Contact, ContactRegister, Country, CustomManagerNameModel, DefaultTextFieldChangeReasonModel, Document, Employee, ExternalModelSpecifiedWithAppParam, ExternalModelWithAppLabel, FileModel, ForeignKeyToSelfModel, HistoricalChoice, HistoricalCustomFKError, HistoricalPoll, HistoricalPollWithHistoricalIPAddress, HistoricalState, InheritedRestaurant, Library, ManyToManyModelOther, ModelWithExcludedManyToMany, ModelWithFkToModelWithHistoryUsingBaseModelDb, ModelWithHistoryInDifferentDb, ModelWithHistoryUsingBaseModelDb, ModelWithMultipleNoDBIndex, ModelWithSingleNoDBIndexUnique, MultiOneToOne, MyOverrideModelNameRegisterMethod1, OverrideModelNameAsCallable, OverrideModelNameUsingBaseModel1, Person, Place, Poll, PollInfo, PollWithExcludedFieldsWithDefaults, PollWithExcludedFKField, PollWithExcludeFields, PollWithHistoricalIPAddress, PollWithNonEditableField, Province, Restaurant, SelfFK, Series, SeriesWork, State, Street, Temperature, TestHistoricParticipanToHistoricOrganization, TestHistoricParticipantToOrganization, TestOrganization, TestOrganizationWithHistory, TestParticipantToHistoricOrganization, UnicodeVerboseName, UnicodeVerboseNamePlural, UserTextFieldChangeReasonModel, UUIDDefaultModel, UUIDModel, WaterLevel, ) get_model = apps.get_model User = get_user_model() today = datetime(3021, 1, 1, 10, 0) tomorrow = today + timedelta(days=1) yesterday = today - timedelta(days=1) def get_fake_file(filename): fake_file = ContentFile("file data") fake_file.name = filename return fake_file class HistoricalRecordsTest(TestCase): def assertDatetimesEqual(self, time1, time2): self.assertAlmostEqual(time1, time2, delta=timedelta(seconds=2)) def assertRecordValues(self, record, klass, values_dict): for key, value in values_dict.items(): self.assertEqual(getattr(record, key), value) self.assertEqual(record.history_object.__class__, klass) for key, value in values_dict.items(): if key not in ["history_type", "history_change_reason"]: self.assertEqual(getattr(record.history_object, key), value) def test_create(self): p = Poll(question="what's up?", pub_date=today) p.save() (record,) = p.history.all() self.assertRecordValues( record, Poll, { "question": "what's up?", "pub_date": today, "id": p.id, "history_type": "+", }, ) self.assertDatetimesEqual(record.history_date, datetime.now()) def test_update(self): Poll.objects.create(question="what's up?", pub_date=today) p = Poll.objects.get() p.pub_date = tomorrow p.save() update_change_reason(p, "future poll") update_record, create_record = p.history.all() self.assertRecordValues( create_record, Poll, { "question": "what's up?", "pub_date": today, "id": p.id, "history_change_reason": None, "history_type": "+", }, ) self.assertRecordValues( update_record, Poll, { "question": "what's up?", "pub_date": tomorrow, "id": p.id, "history_change_reason": "future poll", "history_type": "~", }, ) self.assertDatetimesEqual(update_record.history_date, datetime.now()) def test_delete_verify_change_reason_implicitly(self): p = Poll.objects.create(question="what's up?", pub_date=today) poll_id = p.id p._change_reason = "wrongEntry" p.delete() delete_record, create_record = Poll.history.all() self.assertRecordValues( create_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": None, "history_type": "+", }, ) self.assertRecordValues( delete_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": "wrongEntry", "history_type": "-", }, ) def test_delete_verify_change_reason_explicity(self): p = Poll.objects.create(question="what's up?", pub_date=today) poll_id = p.id p.delete() update_change_reason(p, "wrongEntry") delete_record, create_record = Poll.history.all() self.assertRecordValues( create_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": None, "history_type": "+", }, ) self.assertRecordValues( delete_record, Poll, { "question": "what's up?", "pub_date": today, "id": poll_id, "history_change_reason": "wrongEntry", "history_type": "-", }, ) def test_cascade_delete_history(self): thames = WaterLevel.objects.create(waters="Thames", level=2.5, date=today) nile = WaterLevel.objects.create(waters="Nile", level=2.5, date=today) self.assertEqual(len(thames.history.all()), 1) self.assertEqual(len(nile.history.all()), 1) nile.delete() self.assertEqual(len(thames.history.all()), 1) self.assertEqual(len(nile.history.all()), 0) def test_save_without_historical_record(self): pizza_place = Restaurant.objects.create(name="Pizza Place", rating=3) pizza_place.rating = 4 pizza_place.save_without_historical_record() pizza_place.rating = 6 pizza_place.save() update_record, create_record = Restaurant.updates.all() self.assertRecordValues( create_record, Restaurant, { "name": "Pizza Place", "rating": 3, "id": pizza_place.id, "history_type": "+", }, ) self.assertRecordValues( update_record, Restaurant, { "name": "Pizza Place", "rating": 6, "id": pizza_place.id, "history_type": "~", }, ) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_save_with_disabled_history(self): anthony = Person.objects.create(name="Anthony Gillard") anthony.name = "something else" anthony.save() self.assertEqual(Person.history.count(), 0) anthony.delete() self.assertEqual(Person.history.count(), 0) def test_save_without_historical_record_for_registered_model(self): model = ExternalModelSpecifiedWithAppParam.objects.create( name="registered model" ) self.assertTrue(hasattr(model, "save_without_historical_record")) def test_save_raises_exception(self): anthony = Person(name="Anthony Gillard") with self.assertRaises(RuntimeError): anthony.save_without_historical_record() self.assertFalse(hasattr(anthony, "skip_history_when_saving")) self.assertEqual(Person.history.count(), 0) anthony.save() self.assertEqual(Person.history.count(), 1) def test_foreignkey_field(self): why_poll = Poll.objects.create(question="why?", pub_date=today) how_poll = Poll.objects.create(question="how?", pub_date=today) choice = Choice.objects.create(poll=why_poll, votes=0) choice.poll = how_poll choice.save() update_record, create_record = Choice.history.all() self.assertRecordValues( create_record, Choice, {"poll_id": why_poll.id, "votes": 0, "id": choice.id, "history_type": "+"}, ) self.assertRecordValues( update_record, Choice, {"poll_id": how_poll.id, "votes": 0, "id": choice.id, "history_type": "~"}, ) def test_foreignkey_still_allows_reverse_lookup_via_set_attribute(self): lib = Library.objects.create() state = State.objects.create(library=lib) self.assertTrue(hasattr(lib, "state_set")) self.assertIsNone( state._meta.get_field("library").remote_field.related_name, "the '+' shouldn't leak through to the original " "model's field related_name", ) def test_file_field(self): filename = str(uuid.uuid4()) model = FileModel.objects.create(file=get_fake_file(filename)) self.assertEqual(model.file.name, f"files/{filename}") model.file.delete() update_record, create_record = model.history.all() self.assertEqual(create_record.file, f"files/{filename}") self.assertEqual(update_record.file, "") def test_file_field_with_char_field_setting(self): # setting means history table's file field is a CharField file_field = CharFieldFileModel.history.model._meta.get_field("file") self.assertIs(type(file_field), models.CharField) self.assertEqual(file_field.max_length, 100) # file field works the same as test_file_field() filename = str(uuid.uuid4()) model = CharFieldFileModel.objects.create(file=get_fake_file(filename)) self.assertEqual(model.file.name, f"files/{filename}") model.file.delete() update_record, create_record = model.history.all() self.assertEqual(create_record.file, f"files/{filename}") self.assertEqual(update_record.file, "") def test_inheritance(self): pizza_place = Restaurant.objects.create(name="Pizza Place", rating=3) pizza_place.rating = 4 pizza_place.save() update_record, create_record = Restaurant.updates.all() self.assertRecordValues( create_record, Restaurant, { "name": "Pizza Place", "rating": 3, "id": pizza_place.id, "history_type": "+", }, ) self.assertRecordValues( update_record, Restaurant, { "name": "Pizza Place", "rating": 4, "id": pizza_place.id, "history_type": "~", }, ) def test_reverse_historical(self): """Tests how we can go from instance to historical record.""" document = Document.objects.create() historic = document.history.all()[0] instance = historic.instance self.assertEqual( getattr(instance, SIMPLE_HISTORY_REVERSE_ATTR_NAME).history_date, historic.history_date, ) def test_specify_history_user(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") document = Document.objects.create(changed_by=user1) document.changed_by = user2 document.save() document.changed_by = None document.save() self.assertEqual( [d.history_user for d in document.history.all()], [None, user2, user1] ) def test_specify_history_user_self_reference_delete(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") document = Document.objects.create(changed_by=user1) document.changed_by = user2 document.save() document.changed_by = None document.save() self.assertEqual( [d.history_user for d in document.history.all()], [None, user2, user1] ) # Change back to user1 document.changed_by = user1 document.save() # Deleting user1 will cascade delete the document, # but fails when it tries to make the historical # record for the deleted user1. # This test performs differently on Postgres vs. SQLite # because of how the two database handle database constraints try: user1.delete() except IntegrityError as e: self.fail(e) def test_specify_history_date_1(self): temperature = Temperature.objects.create( location="London", temperature=14, _history_date=today ) temperature.temperature = 16 temperature._history_date = yesterday temperature.save() self.assertEqual( [t.history_date for t in temperature.history.all()], [today, yesterday] ) def test_specify_history_date_2(self): river = WaterLevel.objects.create(waters="Thames", level=2.5, date=today) river.level = 2.6 river.date = yesterday river.save() for t in river.history.all(): self.assertEqual(t.date, t.history_date) def test_non_default_primary_key_save(self): book1 = Book.objects.create(isbn="1-84356-028-1") book2 = Book.objects.create(isbn="1-84356-028-2") library = Library.objects.create(book=book1) library.book = book2 library.save() library.book = None library.save() self.assertEqual( [lib.book_id for lib in library.history.all()], [None, book2.pk, book1.pk] ) def test_string_defined_foreign_key_save(self): library1 = Library.objects.create() library2 = Library.objects.create() state = State.objects.create(library=library1) state.library = library2 state.save() state.library = None state.save() self.assertEqual( [s.library_id for s in state.history.all()], [None, library2.pk, library1.pk], ) def test_self_referential_foreign_key(self): model = SelfFK.objects.create() other = SelfFK.objects.create() model.fk = model model.save() model.fk = other model.save() self.assertEqual( [m.fk_id for m in model.history.all()], [other.id, model.id, None] ) def test_to_field_foreign_key_save(self): country = Country.objects.create(code="US") country2 = Country.objects.create(code="CA") province = Province.objects.create(country=country) province.country = country2 province.save() self.assertEqual( [c.country_id for c in province.history.all()], [country2.code, country.code], ) def test_db_column_foreign_key_save(self): country = Country.objects.create(code="US") city = City.objects.create(country=country) country_field = City._meta.get_field("country") self.assertIn( getattr(country_field, "db_column"), str(city.history.all().query) ) def test_raw_save(self): document = Document() document.save_base(raw=True) self.assertEqual(document.history.count(), 0) document.save() self.assertRecordValues( document.history.get(), Document, {"changed_by_id": None, "id": document.id, "history_type": "~"}, ) def test_unicode_verbose_name(self): instance = UnicodeVerboseName() instance.save() self.assertEqual( "historical \u570b", instance.history.all()[0]._meta.verbose_name ) def test_user_can_set_verbose_name(self): b = Book(isbn="54321") b.save() self.assertEqual("dead trees", b.history.all()[0]._meta.verbose_name) def test_historical_verbose_name_follows_model_verbose_name(self): library = Library() library.save() self.assertEqual( "historical quiet please", library.history.get()._meta.verbose_name ) def test_unicode_verbose_name_plural(self): instance = UnicodeVerboseNamePlural() instance.save() self.assertEqual( "historical \u570b", instance.history.all()[0]._meta.verbose_name_plural ) def test_user_can_set_verbose_name_plural(self): b = Book(isbn="54321") b.save() self.assertEqual( "dead trees plural", b.history.all()[0]._meta.verbose_name_plural ) def test_historical_verbose_name_plural_follows_model_verbose_name_plural(self): library = Library() library.save() self.assertEqual( "historical quiet please plural", library.history.get()._meta.verbose_name_plural, ) def test_foreignkey_primarykey(self): """Test saving a tracked model with a `ForeignKey` primary key.""" poll = Poll(pub_date=today) poll.save() poll_info = PollInfo(poll=poll) poll_info.save() def test_model_with_excluded_fields(self): p = PollWithExcludeFields( question="what's up?", pub_date=today, place="The Pub" ) p.save() history = PollWithExcludeFields.history.all()[0] all_fields_names = [f.name for f in history._meta.fields] self.assertIn("question", all_fields_names) self.assertNotIn("pub_date", all_fields_names) self.assertEqual(history.question, p.question) self.assertEqual(history.place, p.place) most_recent = p.history.most_recent() self.assertIn("question", all_fields_names) self.assertNotIn("pub_date", all_fields_names) self.assertEqual(most_recent.__class__, PollWithExcludeFields) self.assertIn("pub_date", history._history_excluded_fields) self.assertEqual(most_recent.question, p.question) self.assertEqual(most_recent.place, p.place) def test_user_model_override(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") member1 = BucketMember.objects.create(name="member1", user=user1) member2 = BucketMember.objects.create(name="member2", user=user2) bucket_data = BucketData.objects.create(changed_by=member1) bucket_data.changed_by = member2 bucket_data.save() bucket_data.changed_by = None bucket_data.save() self.assertEqual( [d.history_user for d in bucket_data.history.all()], [None, member2, member1], ) def test_user_model_override_registered(self): user1 = User.objects.create_user("user1", "1@example.com") user2 = User.objects.create_user("user2", "1@example.com") member1 = BucketMember.objects.create(name="member1", user=user1) member2 = BucketMember.objects.create(name="member2", user=user2) bucket_data = BucketDataRegisterChangedBy.objects.create(changed_by=member1) bucket_data.changed_by = member2 bucket_data.save() bucket_data.changed_by = None bucket_data.save() self.assertEqual( [d.history_user for d in bucket_data.history.all()], [None, member2, member1], ) def test_uuid_history_id(self): entry = UUIDModel.objects.create() history = entry.history.all()[0] self.assertTrue(isinstance(history.history_id, uuid.UUID)) def test_uuid_default_history_id(self): entry = UUIDDefaultModel.objects.create() history = entry.history.all()[0] self.assertTrue(isinstance(history.history_id, uuid.UUID)) def test_default_history_change_reason(self): entry = CharFieldChangeReasonModel.objects.create(greeting="what's up?") history = entry.history.get() self.assertEqual(history.history_change_reason, None) def test_charfield_history_change_reason(self): # Default CharField and length entry = CharFieldChangeReasonModel.objects.create(greeting="what's up?") entry.greeting = "what is happening?" entry.save() update_change_reason(entry, "Change greeting.") history = entry.history.all()[0] field = history._meta.get_field("history_change_reason") self.assertTrue(isinstance(field, models.CharField)) self.assertTrue(field.max_length, 100) def test_default_textfield_history_change_reason(self): # TextField usage is determined by settings entry = DefaultTextFieldChangeReasonModel.objects.create(greeting="what's up?") entry.greeting = "what is happening?" entry.save() reason = "Change greeting" update_change_reason(entry, reason) history = entry.history.all()[0] field = history._meta.get_field("history_change_reason") self.assertTrue(isinstance(field, models.TextField)) self.assertEqual(history.history_change_reason, reason) def test_user_textfield_history_change_reason(self): # TextField instance is passed in init entry = UserTextFieldChangeReasonModel.objects.create(greeting="what's up?") entry.greeting = "what is happening?" entry.save() reason = "Change greeting" update_change_reason(entry, reason) history = entry.history.all()[0] field = history._meta.get_field("history_change_reason") self.assertTrue(isinstance(field, models.TextField)) self.assertEqual(history.history_change_reason, reason) def test_history_diff_includes_changed_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) expected_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() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) self.assertNotIn("pub_date", delta.changed_fields) def test_history_diff_includes_changed_fields_of_base_model(self): r = InheritedRestaurant.objects.create(name="McDonna", serves_hot_dogs=False) # change base model field r.name = "DonnutsKing" r.save() new_record, old_record = r.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) expected_change = ModelChange("name", "McDonna", "DonnutsKing") self.assertEqual(delta.changed_fields, ["name"]) self.assertEqual(delta.old_record, old_record) self.assertEqual(delta.new_record, new_record) self.assertEqual(expected_change.field, delta.changes[0].field) def test_history_diff_with_incorrect_type(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertRaises(TypeError): new_record.diff_against("something") def test_history_diff_with_excluded_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record, excluded_fields=("question",)) self.assertEqual(delta.changed_fields, []) self.assertEqual(delta.changes, []) def test_history_diff_with_included_fields(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record, included_fields=[]) self.assertEqual(delta.changed_fields, []) self.assertEqual(delta.changes, []) with self.assertNumQueries(0): delta = new_record.diff_against(old_record, included_fields=["question"]) self.assertEqual(delta.changed_fields, ["question"]) self.assertEqual(len(delta.changes), 1) def test_history_diff_with_non_editable_field(self): p = PollWithNonEditableField.objects.create( question="what's up?", pub_date=today ) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertNumQueries(0): delta = new_record.diff_against(old_record) self.assertEqual(delta.changed_fields, ["question"]) self.assertEqual(len(delta.changes), 1) def test_history_with_unknown_field(self): p = Poll.objects.create(question="what's up?", pub_date=today) p.question = "what's up, man?" p.save() new_record, old_record = p.history.all() with self.assertRaises(KeyError): with self.assertNumQueries(0): new_record.diff_against(old_record, included_fields=["unknown_field"]) with self.assertNumQueries(0): new_record.diff_against(old_record, excluded_fields=["unknown_field"]) class GetPrevRecordAndNextRecordTestCase(TestCase): def assertRecordsMatch(self, record_a, record_b): self.assertEqual(record_a, record_b) self.assertEqual(record_a.question, record_b.question) def setUp(self): self.poll = Poll(question="what's up?", pub_date=today) self.poll.save() def test_get_prev_record(self): self.poll.question = "ask questions?" self.poll.save() self.poll.question = "eh?" self.poll.save() self.poll.question = "one more?" self.poll.save() first_record = self.poll.history.filter(question="what's up?").get() second_record = self.poll.history.filter(question="ask questions?").get() third_record = self.poll.history.filter(question="eh?").get() fourth_record = self.poll.history.filter(question="one more?").get() with self.assertNumQueries(1): self.assertRecordsMatch(second_record.prev_record, first_record) with self.assertNumQueries(1): self.assertRecordsMatch(third_record.prev_record, second_record) with self.assertNumQueries(1): self.assertRecordsMatch(fourth_record.prev_record, third_record) def test_get_prev_record_none_if_only(self): self.assertEqual(self.poll.history.count(), 1) record = self.poll.history.get() self.assertIsNone(record.prev_record) def test_get_prev_record_none_if_earliest(self): self.poll.question = "ask questions?" self.poll.save() first_record = self.poll.history.filter(question="what's up?").get() self.assertIsNone(first_record.prev_record) def test_get_prev_record_with_custom_manager_name(self): instance = CustomManagerNameModel.objects.create(name="Test name 1") instance.name = "Test name 2" instance.save() first_record = instance.log.filter(name="Test name 1").get() second_record = instance.log.filter(name="Test name 2").get() self.assertEqual(second_record.prev_record, first_record) def test_get_prev_record_with_excluded_field(self): instance = PollWithExcludeFields.objects.create( question="what's up?", pub_date=today ) instance.question = "ask questions?" instance.save() first_record = instance.history.filter(question="what's up?").get() second_record = instance.history.filter(question="ask questions?").get() with self.assertNumQueries(1): self.assertRecordsMatch(second_record.prev_record, first_record) def test_get_next_record(self): self.poll.question = "ask questions?" self.poll.save() self.poll.question = "eh?" self.poll.save() self.poll.question = "one more?" self.poll.save() first_record = self.poll.history.filter(question="what's up?").get() second_record = self.poll.history.filter(question="ask questions?").get() third_record = self.poll.history.filter(question="eh?").get() fourth_record = self.poll.history.filter(question="one more?").get() self.assertIsNone(fourth_record.next_record) with self.assertNumQueries(1): self.assertRecordsMatch(first_record.next_record, second_record) with self.assertNumQueries(1): self.assertRecordsMatch(second_record.next_record, third_record) with self.assertNumQueries(1): self.assertRecordsMatch(third_record.next_record, fourth_record) def test_get_next_record_none_if_only(self): self.assertEqual(self.poll.history.count(), 1) record = self.poll.history.get() self.assertIsNone(record.next_record) def test_get_next_record_none_if_most_recent(self): self.poll.question = "ask questions?" self.poll.save() recent_record = self.poll.history.filter(question="ask questions?").get() self.assertIsNone(recent_record.next_record) def test_get_next_record_with_custom_manager_name(self): instance = CustomManagerNameModel.objects.create(name="Test name 1") instance.name = "Test name 2" instance.save() first_record = instance.log.filter(name="Test name 1").get() second_record = instance.log.filter(name="Test name 2").get() self.assertEqual(first_record.next_record, second_record) def test_get_next_record_with_excluded_field(self): instance = PollWithExcludeFields.objects.create( question="what's up?", pub_date=today ) instance.question = "ask questions?" instance.save() first_record = instance.history.filter(question="what's up?").get() second_record = instance.history.filter(question="ask questions?").get() with self.assertNumQueries(1): self.assertRecordsMatch(first_record.next_record, second_record) class CreateHistoryModelTests(unittest.TestCase): 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." ) class CustomModelNameTests(unittest.TestCase): def verify_custom_model_name_feature( self, model, expected_class_name, expected_table_name ): history_model = model.history.model self.assertEqual(history_model.__name__, expected_class_name) self.assertEqual(history_model._meta.db_table, expected_table_name) def test_instantiate_history_model_with_custom_model_name_as_string(self): try: from ..models import OverrideModelNameAsString except ImportError: self.fail("{}OverrideModelNameAsString is in wrong module") expected_cls_name = "MyHistoricalCustomNameModel" self.verify_custom_model_name_feature( OverrideModelNameAsString(), expected_cls_name, f"tests_{expected_cls_name.lower()}", ) def test_register_history_model_with_custom_model_name_override(self): try: from ..models import OverrideModelNameRegisterMethod1 except ImportError: self.fail("OverrideModelNameRegisterMethod1 is in wrong module") cls = OverrideModelNameRegisterMethod1() expected_cls_name = "MyOverrideModelNameRegisterMethod1" self.verify_custom_model_name_feature( cls, expected_cls_name, f"tests_{expected_cls_name.lower()}" ) from simple_history import register from ..models import OverrideModelNameRegisterMethod2 try: register( OverrideModelNameRegisterMethod2, custom_model_name=lambda x: f"{x}", ) except ValueError: self.assertRaises(ValueError) def test_register_history_model_with_custom_model_name_from_abstract_model(self): cls = OverrideModelNameUsingBaseModel1 expected_cls_name = f"Audit{cls.__name__}" self.verify_custom_model_name_feature( cls, expected_cls_name, "tests_" + expected_cls_name.lower() ) def test_register_history_model_with_custom_model_name_from_external_model(self): from ..models import OverrideModelNameUsingExternalModel1 cls = OverrideModelNameUsingExternalModel1 expected_cls_name = f"Audit{cls.__name__}" self.verify_custom_model_name_feature( cls, expected_cls_name, "tests_" + expected_cls_name.lower() ) from ..models import OverrideModelNameUsingExternalModel2 cls = OverrideModelNameUsingExternalModel2 expected_cls_name = f"Audit{cls.__name__}" self.verify_custom_model_name_feature( cls, expected_cls_name, "external_" + expected_cls_name.lower() ) class AppLabelTest(TestCase): def get_table_name(self, manager): return manager.model._meta.db_table def test_explicit_app_label(self): self.assertEqual( self.get_table_name(ExternalModelWithAppLabel.objects), "external_externalmodelwithapplabel", ) self.assertEqual( self.get_table_name(ExternalModelWithAppLabel.history), "external_historicalexternalmodelwithapplabel", ) def test_default_app_label(self): self.assertEqual( self.get_table_name(ExternalModel.objects), "external_externalmodel" ) self.assertEqual( self.get_table_name(ExternalModel.history), "external_historicalexternalmodel", ) def test_register_app_label(self): self.assertEqual( self.get_table_name(ExternalModelSpecifiedWithAppParam.objects), "tests_externalmodelspecifiedwithappparam", ) self.assertEqual( self.get_table_name(ExternalModelSpecifiedWithAppParam.histories), "external_historicalexternalmodelspecifiedwithappparam", ) self.assertEqual( self.get_table_name(ExternalModelRegistered.objects), "external_externalmodelregistered", ) self.assertEqual( self.get_table_name(ExternalModelRegistered.histories), "tests_historicalexternalmodelregistered", ) self.assertEqual( self.get_table_name(ConcreteExternal.objects), "tests_concreteexternal" ) self.assertEqual( self.get_table_name(ConcreteExternal.history), "tests_historicalconcreteexternal", ) def test_get_model(self): self.assertEqual( get_model("external", "ExternalModelWithAppLabel"), ExternalModelWithAppLabel, ) self.assertEqual( get_model("external", "HistoricalExternalModelWithAppLabel"), ExternalModelWithAppLabel.history.model, ) self.assertEqual(get_model("external", "ExternalModel"), ExternalModel) self.assertEqual( get_model("external", "HistoricalExternalModel"), ExternalModel.history.model, ) self.assertEqual( get_model("tests", "ExternalModelSpecifiedWithAppParam"), ExternalModelSpecifiedWithAppParam, ) self.assertEqual( get_model("external", "HistoricalExternalModelSpecifiedWithAppParam"), ExternalModelSpecifiedWithAppParam.histories.model, ) self.assertEqual( get_model("external", "ExternalModelRegistered"), ExternalModelRegistered ) self.assertEqual( get_model("tests", "HistoricalExternalModelRegistered"), ExternalModelRegistered.histories.model, ) # Test that historical model is defined within app of concrete # model rather than abstract base model self.assertEqual(get_model("tests", "ConcreteExternal"), ConcreteExternal) self.assertEqual( get_model("tests", "HistoricalConcreteExternal"), ConcreteExternal.history.model, ) class HistoryManagerTest(TestCase): def test_most_recent(self): poll = Poll.objects.create(question="what's up?", pub_date=today) poll.question = "how's it going?" poll.save() poll.question = "why?" poll.save() poll.question = "how?" most_recent = poll.history.most_recent() self.assertEqual(most_recent.__class__, Poll) self.assertEqual(most_recent.question, "why?") def test_get_model(self): self.assertEqual(get_model("tests", "poll"), Poll) self.assertEqual(get_model("tests", "historicalpoll"), HistoricalPoll) def test_most_recent_on_model_class(self): Poll.objects.create(question="what's up?", pub_date=today) self.assertRaises(TypeError, Poll.history.most_recent) def test_most_recent_nonexistant(self): # Unsaved poll poll = Poll(question="what's up?", pub_date=today) self.assertRaises(Poll.DoesNotExist, poll.history.most_recent) # Deleted poll poll.save() poll.delete() self.assertRaises(Poll.DoesNotExist, poll.history.most_recent) def test_date_indexing_options(self): records = HistoricalRecords() delattr(settings, "SIMPLE_HISTORY_DATE_INDEX") self.assertTrue(records._date_indexing) settings.SIMPLE_HISTORY_DATE_INDEX = False self.assertFalse(records._date_indexing) settings.SIMPLE_HISTORY_DATE_INDEX = "Composite" self.assertEqual(records._date_indexing, "composite") settings.SIMPLE_HISTORY_DATE_INDEX = "foo" with self.assertRaises(ImproperlyConfigured): records._date_indexing settings.SIMPLE_HISTORY_DATE_INDEX = 42 with self.assertRaises(ImproperlyConfigured): records._date_indexing settings.SIMPLE_HISTORY_DATE_INDEX = None with self.assertRaises(ImproperlyConfigured): records._date_indexing delattr(settings, "SIMPLE_HISTORY_DATE_INDEX") def test_as_of(self): poll = Poll.objects.create(question="what's up?", pub_date=today) poll.question = "how's it going?" poll.save() poll.question = "why?" poll.save() poll.question = "how?" most_recent = poll.history.most_recent() self.assertEqual(most_recent.question, "why?") times = [r.history_date for r in poll.history.all()] def question_as_of(time): return poll.history.as_of(time).question self.assertEqual(question_as_of(times[0]), "why?") self.assertEqual(question_as_of(times[1]), "how's it going?") self.assertEqual(question_as_of(times[2]), "what's up?") def test_as_of_nonexistant(self): # Unsaved poll poll = Poll(question="what's up?", pub_date=today) time = datetime.now() self.assertRaises(Poll.DoesNotExist, poll.history.as_of, time) # Deleted poll poll.save() poll.delete() self.assertRaises(Poll.DoesNotExist, poll.history.as_of, time) def test_as_of_excluded_many_to_many_succeeds(self): other1 = ManyToManyModelOther.objects.create(name="test1") other2 = ManyToManyModelOther.objects.create(name="test2") m = ModelWithExcludedManyToMany.objects.create(name="test") m.other.add(other1, other2) # This will fail if the ManyToMany field is not excluded. self.assertEqual(m.history.as_of(datetime.now()), m) def test_foreignkey_field(self): why_poll = Poll.objects.create(question="why?", pub_date=today) how_poll = Poll.objects.create(question="how?", pub_date=today) choice = Choice.objects.create(poll=why_poll, votes=0) choice.poll = how_poll choice.save() most_recent = choice.history.most_recent() self.assertEqual(most_recent.poll.pk, how_poll.pk) times = [r.history_date for r in choice.history.all()] def poll_as_of(time): return choice.history.as_of(time).poll self.assertEqual(poll_as_of(times[0]).pk, how_poll.pk) self.assertEqual(poll_as_of(times[1]).pk, why_poll.pk) def test_abstract_inheritance(self): for klass in (ConcreteAttr, ConcreteUtil): obj = klass.objects.create() obj.save() update_record, create_record = klass.history.all() self.assertTrue(isinstance(update_record, AbstractBase)) self.assertTrue(isinstance(create_record, AbstractBase)) def test_invalid_bases(self): invalid_bases = (AbstractBase, "InvalidBases") for bases in invalid_bases: self.assertRaises(TypeError, HistoricalRecords, bases=bases) def test_import_related(self): field_object = HistoricalChoice._meta.get_field("poll") related_model = field_object.remote_field.related_model self.assertEqual(related_model, HistoricalChoice) def test_string_related(self): field_object = HistoricalState._meta.get_field("library") related_model = field_object.remote_field.related_model self.assertEqual(related_model, HistoricalState) def test_state_serialization_of_customfk(self): from django.db.migrations import state state.ModelState.from_model(HistoricalCustomFKError) class TestOrderWrtField(TestCase): """Check behaviour of _order field added by Meta.order_with_respect_to. The Meta.order_with_respect_to option adds an OrderWrt field named "_order", where OrderWrt is a proxy class for an IntegerField that sets some default options. The simple_history strategy is: - Convert to a plain IntegerField in the historical record - When restoring a historical instance, add the old value. This may result in duplicate ordering values and non-deterministic ordering. """ def setUp(self): """Create works in published order.""" s = self.series = Series.objects.create( name="The Chronicles of Narnia", author="C.S. Lewis" ) self.w_lion = s.works.create(title="The Lion, the Witch and the Wardrobe") self.w_caspian = s.works.create(title="Prince Caspian") self.w_voyage = s.works.create(title="The Voyage of the Dawn Treader") self.w_chair = s.works.create(title="The Silver Chair") self.w_horse = s.works.create(title="The Horse and His Boy") self.w_nephew = s.works.create(title="The Magician's Nephew") self.w_battle = s.works.create(title="The Last Battle") def test_order(self): """Confirm that works are ordered by creation.""" order = self.series.get_serieswork_order() expected = [ self.w_lion.pk, self.w_caspian.pk, self.w_voyage.pk, self.w_chair.pk, self.w_horse.pk, self.w_nephew.pk, self.w_battle.pk, ] self.assertSequenceEqual(order, expected) self.assertEqual(0, self.w_lion._order) self.assertEqual(1, self.w_caspian._order) self.assertEqual(2, self.w_voyage._order) self.assertEqual(3, self.w_chair._order) self.assertEqual(4, self.w_horse._order) self.assertEqual(5, self.w_nephew._order) self.assertEqual(6, self.w_battle._order) def test_order_field_in_historical_model(self): work_order_field = self.w_lion._meta.get_field("_order") self.assertEqual(type(work_order_field), OrderWrt) history = self.w_lion.history.all()[0] history_order_field = history._meta.get_field("_order") self.assertEqual(type(history_order_field), models.IntegerField) def test_history_object_has_order(self): history = self.w_lion.history.all()[0] self.assertEqual(self.w_lion._order, history.history_object._order) def test_restore_object_with_changed_order(self): # Change a title self.w_caspian.title = "Prince Caspian: The Return to Narnia" self.w_caspian.save() self.assertEqual(2, len(self.w_caspian.history.all())) self.assertEqual(1, self.w_caspian._order) # Switch to internal chronological order chronological = [ self.w_nephew.pk, self.w_lion.pk, self.w_horse.pk, self.w_caspian.pk, self.w_voyage.pk, self.w_chair.pk, self.w_battle.pk, ] self.series.set_serieswork_order(chronological) self.assertSequenceEqual(self.series.get_serieswork_order(), chronological) # This uses an update, not a save, so no new history is created w_caspian = SeriesWork.objects.get(id=self.w_caspian.id) self.assertEqual(2, len(w_caspian.history.all())) self.assertEqual(1, w_caspian.history.all()[0]._order) self.assertEqual(1, w_caspian.history.all()[1]._order) self.assertEqual(3, w_caspian._order) # Revert to first title, old order old = w_caspian.history.all()[1].history_object old.save() w_caspian = SeriesWork.objects.get(id=self.w_caspian.id) self.assertEqual(3, len(w_caspian.history.all())) self.assertEqual(1, w_caspian.history.all()[0]._order) self.assertEqual(1, w_caspian.history.all()[1]._order) self.assertEqual(1, w_caspian.history.all()[2]._order) self.assertEqual(1, w_caspian._order) # The order changed w_lion = SeriesWork.objects.get(id=self.w_lion.id) self.assertEqual(1, w_lion._order) # and is identical to another order # New order is non-deterministic around identical IDs series = Series.objects.get(id=self.series.id) order = series.get_serieswork_order() self.assertEqual(order[0], self.w_nephew.pk) self.assertTrue(order[1] in (self.w_lion.pk, self.w_caspian.pk)) self.assertTrue(order[2] in (self.w_lion.pk, self.w_caspian.pk)) self.assertEqual(order[3], self.w_horse.pk) self.assertEqual(order[4], self.w_voyage.pk) self.assertEqual(order[5], self.w_chair.pk) self.assertEqual(order[6], self.w_battle.pk) def test_migrations_include_order(self): from django.db.migrations import state model_state = state.ModelState.from_model(SeriesWork.history.model) found = False # `fields` is a dict in Django 3.1 fields = None if isinstance(model_state.fields, dict): fields = model_state.fields.items() else: fields = model_state.fields for name, field in fields: if name == "_order": found = True self.assertEqual(type(field), models.IntegerField) self.assertTrue(found, "_order not in fields " + repr(model_state.fields)) class TestLatest(TestCase): """Test behavior of `latest()` without any field parameters""" def setUp(self): poll = Poll.objects.create(question="Does `latest()` work?", pub_date=yesterday) poll.pub_date = today poll.save() def write_history(self, new_attributes): poll_history = HistoricalPoll.objects.all() for historical_poll, new_values in zip(poll_history, new_attributes): for fieldname, value in new_values.items(): setattr(historical_poll, fieldname, value) historical_poll.save() def test_ordered(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": today}] ) self.assertEqual(HistoricalPoll.objects.latest().pk, 2) def test_jumbled(self): self.write_history( [{"pk": 1, "history_date": today}, {"pk": 2, "history_date": yesterday}] ) self.assertEqual(HistoricalPoll.objects.latest().pk, 1) def test_sameinstant(self): self.write_history( [{"pk": 1, "history_date": yesterday}, {"pk": 2, "history_date": yesterday}] ) self.assertEqual(HistoricalPoll.objects.latest().pk, 2) class TestMissingOneToOne(TestCase): def setUp(self): self.manager1 = Employee.objects.create() self.manager2 = Employee.objects.create() self.employee = Employee.objects.create(manager=self.manager1) self.employee.manager = self.manager2 self.employee.save() self.manager1_id = self.manager1.id self.manager1.delete() def test_history_is_complete(self): historical_manager_ids = list( self.employee.history.order_by("pk").values_list("manager_id", flat=True) ) self.assertEqual(historical_manager_ids, [self.manager1_id, self.manager2.id]) def test_restore_employee(self): historical = self.employee.history.order_by("pk")[0] original = historical.instance self.assertEqual(original.manager_id, self.manager1_id) with self.assertRaises(Employee.DoesNotExist): original.manager class CustomTableNameTest1(TestCase): @staticmethod def get_table_name(manager): return manager.model._meta.db_table def test_custom_table_name(self): self.assertEqual(self.get_table_name(Contact.history), "contacts_history") def test_custom_table_name_from_register(self): self.assertEqual( self.get_table_name(ContactRegister.history), "contacts_register_history" ) class ExcludeFieldsTest(TestCase): def test_restore_pollwithexclude(self): poll = PollWithExcludeFields.objects.create( question="what's up?", pub_date=today ) historical = poll.history.order_by("pk")[0] with self.assertRaises(AttributeError): historical.pub_date original = historical.instance self.assertEqual(original.pub_date, poll.pub_date) class ExcludeFieldsForDeletedObjectTest(TestCase): def setUp(self): self.poll = PollWithExcludedFieldsWithDefaults.objects.create( question="what's up?", pub_date=today, max_questions=12 ) self.historical = self.poll.history.order_by("pk")[0] self.poll.delete() def test_restore_deleted_poll_exclude_fields(self): original = self.historical.instance # pub_date don't have default value so it will be None self.assertIsNone(original.pub_date) # same for max_questions self.assertIsNone(original.max_questions) def test_restore_deleted_poll_exclude_fields_with_defaults(self): poll = self.poll original = self.historical.instance self.assertEqual(original.expiration_time, poll.expiration_time) self.assertEqual(original.place, poll.place) self.assertEqual(original.min_questions, poll.min_questions) class ExcludeForeignKeyTest(TestCase): def setUp(self): self.poll = PollWithExcludedFKField.objects.create( question="Is it?", pub_date=today, place=Place.objects.create(name="Somewhere"), ) def get_first_historical(self): """ Retrieve the idx'th HistoricalPoll, ordered by time. """ return self.poll.history.order_by("history_date")[0] def test_instance_fk_value(self): historical = self.get_first_historical() original = historical.instance self.assertEqual(original.place, self.poll.place) def test_history_lacks_fk(self): historical = self.get_first_historical() with self.assertRaises(AttributeError): historical.place def test_nb_queries(self): with self.assertNumQueries(2): historical = self.get_first_historical() historical.instance def test_changed_value_lost(self): new_place = Place.objects.create(name="More precise") self.poll.place = new_place self.poll.save() historical = self.get_first_historical() instance = historical.instance self.assertEqual(instance.place, new_place) def add_static_history_ip_address(sender, **kwargs): history_instance = kwargs["history_instance"] history_instance.ip_address = "192.168.0.1" class ExtraFieldsStaticIPAddressTestCase(TestCase): def setUp(self): pre_create_historical_record.connect( add_static_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_static_history_ip_address", ) def tearDown(self): pre_create_historical_record.disconnect( add_static_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_static_history_ip_address", ) def test_extra_ip_address_field_populated_on_save(self): poll = PollWithHistoricalIPAddress.objects.create( question="Will it blend?", pub_date=today ) poll_history = poll.history.first() self.assertEqual("192.168.0.1", poll_history.ip_address) def test_extra_ip_address_field_not_present_on_poll(self): poll = PollWithHistoricalIPAddress.objects.create( question="Will it blend?", pub_date=today ) with self.assertRaises(AttributeError): poll.ip_address def add_dynamic_history_ip_address(sender, **kwargs): history_instance = kwargs["history_instance"] history_instance.ip_address = HistoricalRecords.context.request.META["REMOTE_ADDR"] @override_settings(**middleware_override_settings) class ExtraFieldsDynamicIPAddressTestCase(TestCase): def setUp(self): pre_create_historical_record.connect( add_dynamic_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_dynamic_history_ip_address", ) def tearDown(self): pre_create_historical_record.disconnect( add_dynamic_history_ip_address, sender=HistoricalPollWithHistoricalIPAddress, dispatch_uid="add_dynamic_history_ip_address", ) def test_signal_is_able_to_retrieve_request_from_context(self): data = {"question": "Will it blend?", "pub_date": "2018-10-30"} self.client.post(reverse("pollip-add"), data=data) polls = PollWithHistoricalIPAddress.objects.all() self.assertEqual(1, polls.count()) poll_history = polls[0].history.first() self.assertEqual("127.0.0.1", poll_history.ip_address) class WarningOnAbstractModelWithInheritFalseTest(TestCase): def test_warning_on_abstract_model_with_inherit_false(self): with warnings.catch_warnings(record=True) as w: class AbstractModelWithInheritFalse(models.Model): string = models.CharField() history = HistoricalRecords() class Meta: abstract = True self.assertEqual(len(w), 1) self.assertTrue(issubclass(w[0].category, UserWarning)) self.assertEqual( str(w[0].message), "HistoricalRecords added to abstract model " "(AbstractModelWithInheritFalse) without " "inherit=True", ) class MultiDBWithUsingTest(TestCase): """Asserts historical manager respects `using()` and the `using` keyword argument in `save()`. """ databases = {"default", "other"} db_name = "other" def test_multidb_with_using_not_on_default(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) self.assertRaises(ObjectDoesNotExist, model.history.get, name="1-84356-028-1") def test_multidb_with_using_is_on_dbtwo(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) try: model.history.using(self.db_name).get(name="1-84356-028-1") except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_and_fk_not_on_default(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) parent_model = ModelWithFkToModelWithHistoryUsingBaseModelDb.objects.using( self.db_name ).create(fk=model) self.assertRaises(ObjectDoesNotExist, parent_model.history.get, fk=model) def test_multidb_with_using_and_fk_on_dbtwo(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) parent_model = ModelWithFkToModelWithHistoryUsingBaseModelDb.objects.using( self.db_name ).create(fk=model) try: parent_model.history.using(self.db_name).get(fk=model) except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_not_on_default(self): model = ModelWithHistoryUsingBaseModelDb(name="1-84356-028-1") model.save(using=self.db_name) self.assertRaises(ObjectDoesNotExist, model.history.get, name="1-84356-028-1") def test_multidb_with_using_keyword_in_save_on_dbtwo(self): model = ModelWithHistoryUsingBaseModelDb(name="1-84356-028-1") model.save(using=self.db_name) try: model.history.using(self.db_name).get(name="1-84356-028-1") except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_with_fk(self): model = ModelWithHistoryUsingBaseModelDb(name="1-84356-028-1") model.save(using=self.db_name) parent_model = ModelWithFkToModelWithHistoryUsingBaseModelDb(fk=model) parent_model.save(using=self.db_name) # assert not created on default self.assertRaises(ObjectDoesNotExist, parent_model.history.get, fk=model) # assert created on dbtwo try: parent_model.history.using(self.db_name).get(fk=model) except ObjectDoesNotExist: self.fail("ObjectDoesNotExist unexpectedly raised.") def test_multidb_with_using_keyword_in_save_and_update(self): model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) model.save(using=self.db_name) self.assertEqual( ["+", "~"], [ obj.history_type for obj in model.history.using(self.db_name) .all() .order_by("history_date") ], ) def test_multidb_with_using_keyword_in_save_and_delete(self): HistoricalModelWithHistoryUseBaseModelDb = get_history_model_for_model( ModelWithHistoryUsingBaseModelDb ) model = ModelWithHistoryUsingBaseModelDb.objects.using(self.db_name).create( name="1-84356-028-1" ) model.save(using=self.db_name) model.delete(using=self.db_name) self.assertEqual( ["+", "~", "-"], [ obj.history_type for obj in HistoricalModelWithHistoryUseBaseModelDb.objects.using( self.db_name ) .all() .order_by("history_date") ], ) class ForeignKeyToSelfTest(TestCase): def setUp(self): self.model = ForeignKeyToSelfModel self.history_model = self.model.history.model def test_foreign_key_to_self_using_model_str(self): self.assertEqual( self.model, self.history_model.fk_to_self.field.remote_field.model ) def test_foreign_key_to_self_using_self_str(self): self.assertEqual( self.model, self.history_model.fk_to_self_using_str.field.remote_field.model ) @override_settings(**database_router_override_settings) class MultiDBExplicitHistoryUserIDTest(TestCase): databases = {"default", "other"} def setUp(self): self.user = get_user_model().objects.create( # nosec username="username", email="username@test.com", password="top_secret" ) def test_history_user_with_fk_in_different_db_raises_value_error(self): instance = ExternalModel(name="random_name") instance._history_user = self.user with self.assertRaises(ValueError): instance.save() def test_history_user_with_integer_field(self): instance = ExternalModelWithCustomUserIdField(name="random_name") instance._history_user = self.user instance.save() self.assertEqual(self.user.id, instance.history.first().history_user_id) self.assertEqual(self.user, instance.history.first().history_user) def test_history_user_is_none(self): instance = ExternalModelWithCustomUserIdField.objects.create(name="random_name") self.assertIsNone(instance.history.first().history_user_id) self.assertIsNone(instance.history.first().history_user) def test_history_user_does_not_exist(self): instance = ExternalModelWithCustomUserIdField(name="random_name") instance._history_user = self.user instance.save() self.assertEqual(self.user.id, instance.history.first().history_user_id) self.assertEqual(self.user, instance.history.first().history_user) user_id = self.user.id self.user.delete() self.assertEqual(user_id, instance.history.first().history_user_id) self.assertIsNone(instance.history.first().history_user) class RelatedNameTest(TestCase): def setUp(self): self.user_one = get_user_model().objects.create( # nosec username="username_one", email="first@user.com", password="top_secret" ) self.user_two = get_user_model().objects.create( # nosec username="username_two", email="second@user.com", password="top_secret" ) self.one = Street(name="Test Street") self.one._history_user = self.user_one self.one.save() self.two = Street(name="Sesame Street") self.two._history_user = self.user_two self.two.save() self.one.name = "ABC Street" self.one._history_user = self.user_two self.one.save() def test_relation(self): self.assertEqual(self.one.history.count(), 2) self.assertEqual(self.two.history.count(), 1) def test_filter(self): self.assertEqual( Street.objects.filter(history__history_user=self.user_one.pk).count(), 1 ) self.assertEqual( Street.objects.filter(history__history_user=self.user_two.pk).count(), 2 ) def test_name_equals_manager(self): with self.assertRaises(RelatedNameConflictError): register(Place, manager_name="history", related_name="history") def test_deletion(self): self.two.delete() self.assertEqual(Street.log.filter(history_relation=2).count(), 2) self.assertEqual(Street.log.count(), 4) def test_revert(self): id = self.one.pk self.one.delete() self.assertEqual( Street.objects.filter(history__history_user=self.user_one.pk).count(), 0 ) self.assertEqual(Street.objects.filter(pk=id).count(), 0) old = Street.log.filter(id=id).first() old.history_object.save() self.assertEqual( Street.objects.filter(history__history_user=self.user_one.pk).count(), 1 ) self.one = Street.objects.get(pk=id) self.assertEqual(self.one.history.count(), 4) @override_settings(**database_router_override_settings_history_in_diff_db) class SaveHistoryInSeparateDatabaseTestCase(TestCase): databases = {"default", "other"} def setUp(self): self.model = ModelWithHistoryInDifferentDb.objects.create(name="test") def test_history_model_saved_in_separate_db(self): self.assertEqual(0, self.model.history.using("default").count()) self.assertEqual(1, self.model.history.count()) self.assertEqual(1, self.model.history.using("other").count()) self.assertEqual( 1, ModelWithHistoryInDifferentDb.objects.using("default").count() ) self.assertEqual(1, ModelWithHistoryInDifferentDb.objects.count()) self.assertEqual( 0, ModelWithHistoryInDifferentDb.objects.using("other").count() ) def test_history_model_saved_in_separate_db_on_delete(self): id = self.model.id self.model.delete() self.assertEqual( 0, ModelWithHistoryInDifferentDb.history.using("default") .filter(id=id) .count(), ) self.assertEqual(2, ModelWithHistoryInDifferentDb.history.filter(id=id).count()) self.assertEqual( 2, ModelWithHistoryInDifferentDb.history.using("other").filter(id=id).count(), ) self.assertEqual( 0, ModelWithHistoryInDifferentDb.objects.using("default").count() ) self.assertEqual(0, ModelWithHistoryInDifferentDb.objects.count()) self.assertEqual( 0, ModelWithHistoryInDifferentDb.objects.using("other").count() ) class ModelWithMultipleNoDBIndexTest(TestCase): def setUp(self): self.model = ModelWithMultipleNoDBIndex self.history_model = self.model.history.model def test_field_indices(self): for field in ["name", "fk"]: # dropped index self.assertTrue(self.model._meta.get_field(field).db_index) self.assertFalse(self.history_model._meta.get_field(field).db_index) # keeps index keeps_index = "%s_keeps_index" % field self.assertTrue(self.model._meta.get_field(keeps_index).db_index) self.assertTrue(self.history_model._meta.get_field(keeps_index).db_index) class ModelWithSingleNoDBIndexUniqueTest(TestCase): def setUp(self): self.model = ModelWithSingleNoDBIndexUnique self.history_model = self.model.history.model def test_unique_field_index(self): # Ending up with deferred fields (dont know why), using work around self.assertTrue(self.model._meta.get_field("name").db_index) self.assertFalse(self.history_model._meta.get_field("name").db_index) # keeps index self.assertTrue(self.model._meta.get_field("name_keeps_index").db_index) self.assertTrue(self.history_model._meta.get_field("name_keeps_index").db_index) class HistoricForeignKeyTest(TestCase): """ Tests chasing foreign keys across time points naturally with HistoricForeignKey. """ def test_non_historic_to_historic(self): """ Non-historic table foreign key to historic table. In this case it should simply behave like ForeignKey because the origin model (this one) cannot be historic, so foreign key lookups are always "current". """ org = TestOrganizationWithHistory.objects.create(name="original") part = TestParticipantToHistoricOrganization.objects.create( name="part", organization=org ) before_mod = timezone.now() self.assertEqual(part.organization.id, org.id) self.assertEqual(org.participants.count(), 1) self.assertEqual(org.participants.all()[0], part) historg = TestOrganizationWithHistory.history.as_of(before_mod).get( name="original" ) self.assertEqual(historg.participants.count(), 1) self.assertEqual(historg.participants.all()[0], part) self.assertEqual(org.history.count(), 1) org.name = "modified" org.save() self.assertEqual(org.history.count(), 2) # drop internal caches, re-select part = TestParticipantToHistoricOrganization.objects.get(name="part") self.assertEqual(part.organization.name, "modified") def test_historic_to_non_historic(self): """ Historic table foreign key to non-historic table. In this case it should simply behave like ForeignKey because the origin model (this one) can be historic but the target model is not, so foreign key lookups are always "current". """ org = TestOrganization.objects.create(name="org") part = TestHistoricParticipantToOrganization.objects.create( name="original", organization=org ) self.assertEqual(part.organization.id, org.id) self.assertEqual(org.participants.count(), 1) self.assertEqual(org.participants.all()[0], part) histpart = TestHistoricParticipantToOrganization.objects.get(name="original") self.assertEqual(histpart.organization.id, org.id) def test_historic_to_historic(self): """ Historic table foreign key to historic table. In this case as_of queries on the origin model (this one) or on the target model (the other one) will traverse the foreign key relationship honoring the timepoint of the original query. This only happens when both tables involved are historic. At t1 we have one org, one participant. At t2 we have one org, two participants, however the org's name has changed. At t3 we have one org, and one participant has left. """ org = TestOrganizationWithHistory.objects.create(name="original") p1 = TestHistoricParticipanToHistoricOrganization.objects.create( name="p1", organization=org ) t1_one_participant = timezone.now() p2 = TestHistoricParticipanToHistoricOrganization.objects.create( name="p2", organization=org ) org.name = "modified" org.save() t2_two_participants = timezone.now() p1.delete() t3_one_participant = timezone.now() # forward relationships - see how natural chasing timepoint relations is p1t1 = TestHistoricParticipanToHistoricOrganization.history.as_of( t1_one_participant ).get(name="p1") self.assertEqual(p1t1.organization, org) self.assertEqual(p1t1.organization.name, "original") p1t2 = TestHistoricParticipanToHistoricOrganization.history.as_of( t2_two_participants ).get(name="p1") self.assertEqual(p1t2.organization, org) self.assertEqual(p1t2.organization.name, "modified") p2t2 = TestHistoricParticipanToHistoricOrganization.history.as_of( t2_two_participants ).get(name="p2") self.assertEqual(p2t2.organization, org) self.assertEqual(p2t2.organization.name, "modified") p2t3 = TestHistoricParticipanToHistoricOrganization.history.as_of( t3_one_participant ).get(name="p2") self.assertEqual(p2t3.organization, org) self.assertEqual(p2t3.organization.name, "modified") # reverse relationships # at t1 ot1 = TestOrganizationWithHistory.history.as_of(t1_one_participant).all()[0] self.assertEqual(ot1.historic_participants.count(), 1) self.assertEqual(ot1.historic_participants.all()[0].name, p1.name) # at t2 ot2 = TestOrganizationWithHistory.history.as_of(t2_two_participants).all()[0] self.assertEqual(ot2.historic_participants.count(), 2) self.assertIn(p1.name, [item.name for item in ot2.historic_participants.all()]) self.assertIn(p2.name, [item.name for item in ot2.historic_participants.all()]) # at t3 ot3 = TestOrganizationWithHistory.history.as_of(t3_one_participant).all()[0] self.assertEqual(ot3.historic_participants.count(), 1) self.assertEqual(ot3.historic_participants.all()[0].name, p2.name) # current self.assertEqual(org.historic_participants.count(), 1) self.assertEqual(org.historic_participants.all()[0].name, p2.name) self.assertTrue(is_historic(ot1)) self.assertFalse(is_historic(org)) self.assertIsInstance( to_historic(ot1), TestOrganizationWithHistory.history.model ) self.assertIsNone(to_historic(org)) django-simple-history-3.1.1/simple_history/tests/tests/test_signals.py000066400000000000000000000034161423103303600263720ustar00rootroot00000000000000from datetime import datetime from django.test import TestCase from simple_history.signals import ( post_create_historical_record, pre_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-3.1.1/simple_history/tests/tests/test_templatetags.py000066400000000000000000000006071423103303600274230ustar00rootroot00000000000000from django.test import TestCase from simple_history.templatetags.getattributes import getattribute class Foo: bar = "bar" class TestGetAttributes(TestCase): def test_get_existing_attributes_return_it(self): self.assertEqual(getattribute(Foo(), "bar"), "bar") def test_get_missing_attributes_return_None(self): self.assertIsNone(getattribute(Foo(), "baz")) django-simple-history-3.1.1/simple_history/tests/tests/test_utils.py000066400000000000000000000420031423103303600260650ustar00rootroot00000000000000from datetime import datetime from unittest.mock import Mock, patch from django.contrib.auth import get_user_model from django.db import IntegrityError, transaction from django.test import TestCase, TransactionTestCase, override_settings from django.utils import timezone from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError from simple_history.tests.models import ( BulkCreateManyToManyModel, Document, Place, Poll, PollWithAlternativeManager, PollWithExcludeFields, PollWithUniqueQuestion, Street, ) from simple_history.utils import ( bulk_create_with_history, bulk_update_with_history, update_change_reason, ) User = get_user_model() class BulkCreateWithHistoryTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=timezone.now()), Poll(id=2, question="Question 2", pub_date=timezone.now()), Poll(id=3, question="Question 3", pub_date=timezone.now()), Poll(id=4, question="Question 4", pub_date=timezone.now()), Poll(id=5, question="Question 5", pub_date=timezone.now()), ] self.data_with_excluded_fields = [ PollWithExcludeFields(id=1, question="Question 1", pub_date=timezone.now()), PollWithExcludeFields(id=2, question="Question 2", pub_date=timezone.now()), PollWithExcludeFields(id=3, question="Question 3", pub_date=timezone.now()), PollWithExcludeFields(id=4, question="Question 4", pub_date=timezone.now()), PollWithExcludeFields(id=5, question="Question 5", pub_date=timezone.now()), ] self.data_with_alternative_manager = [ PollWithAlternativeManager( id=1, question="Question 1", pub_date=timezone.now() ), PollWithAlternativeManager( id=2, question="Question 2", pub_date=timezone.now() ), PollWithAlternativeManager( id=3, question="Question 3", pub_date=timezone.now() ), PollWithAlternativeManager( id=4, question="Question 4", pub_date=timezone.now() ), PollWithAlternativeManager( id=5, question="Question 5", pub_date=timezone.now() ), ] self.data_with_duplicates = [ PollWithUniqueQuestion( pk=1, question="Question 1", pub_date=timezone.now() ), PollWithUniqueQuestion( pk=2, question="Question 2", pub_date=timezone.now() ), PollWithUniqueQuestion( pk=3, question="Question 1", pub_date=timezone.now() ), ] def test_bulk_create_history(self): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 5) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_bulk_create_history_with_disabled_setting(self): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 0) def test_bulk_create_history_alternative_manager(self): bulk_create_with_history( self.data, PollWithAlternativeManager, ) self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5) self.assertEqual(PollWithAlternativeManager.history.count(), 5) def test_bulk_create_history_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") bulk_create_with_history(self.data, Poll, default_user=user) self.assertTrue( all([history.history_user == user for history in Poll.history.all()]) ) def test_bulk_create_history_with_default_change_reason(self): bulk_create_with_history( self.data, Poll, default_change_reason="my change reason" ) self.assertTrue( all( [ history.history_change_reason == "my change reason" for history in Poll.history.all() ] ) ) def test_bulk_create_history_with_default_date(self): date = datetime(2020, 7, 1) bulk_create_with_history(self.data, Poll, default_date=date) self.assertTrue( all([history.history_date == date for history in Poll.history.all()]) ) def test_bulk_create_history_num_queries_is_two(self): with self.assertNumQueries(2): bulk_create_with_history(self.data, Poll) def test_bulk_create_history_on_model_without_history_raises_error(self): self.data = [ Place(id=1, name="Place 1"), Place(id=2, name="Place 2"), Place(id=3, name="Place 3"), ] with self.assertRaises(NotHistoricalModelError): bulk_create_with_history(self.data, Place) def test_num_queries_when_batch_size_is_less_than_total(self): with self.assertNumQueries(6): bulk_create_with_history(self.data, Poll, batch_size=2) def test_bulk_create_history_with_batch_size(self): bulk_create_with_history(self.data, Poll, batch_size=2) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 5) def test_bulk_create_works_with_excluded_fields(self): bulk_create_with_history(self.data_with_excluded_fields, PollWithExcludeFields) self.assertEqual(Poll.objects.count(), 0) self.assertEqual(Poll.history.count(), 0) self.assertEqual(PollWithExcludeFields.objects.count(), 5) self.assertEqual(PollWithExcludeFields.history.count(), 5) def test_bulk_create_history_with_relation_name(self): self.data = [ Street(name="Street 1"), Street(name="Street 2"), Street(name="Street 3"), Street(name="Street 4"), ] bulk_create_with_history(self.data, Street) self.assertEqual(Street.objects.count(), 4) self.assertEqual(Street.log.count(), 4) def test_bulk_create_history_with_duplicates(self): with transaction.atomic(), self.assertRaises(IntegrityError): bulk_create_with_history( self.data_with_duplicates, PollWithUniqueQuestion, ignore_conflicts=False, ) self.assertEqual(PollWithUniqueQuestion.objects.count(), 0) self.assertEqual(PollWithUniqueQuestion.history.count(), 0) def test_bulk_create_history_with_duplicates_ignore_conflicts(self): bulk_create_with_history( self.data_with_duplicates, PollWithUniqueQuestion, ignore_conflicts=True ) self.assertEqual(PollWithUniqueQuestion.objects.count(), 2) self.assertEqual(PollWithUniqueQuestion.history.count(), 2) class BulkCreateWithHistoryTransactionTestCase(TransactionTestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=timezone.now()), Poll(id=2, question="Question 2", pub_date=timezone.now()), Poll(id=3, question="Question 3", pub_date=timezone.now()), Poll(id=4, question="Question 4", pub_date=timezone.now()), Poll(id=5, question="Question 5", pub_date=timezone.now()), ] @patch( "simple_history.manager.HistoryManager.bulk_history_create", Mock(side_effect=Exception), ) def test_transaction_rolls_back_if_bulk_history_create_fails(self): with self.assertRaises(Exception): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 0) self.assertEqual(Poll.history.count(), 0) def test_bulk_create_history_on_objects_that_already_exist(self): Poll.objects.bulk_create(self.data) with self.assertRaises(IntegrityError): bulk_create_with_history(self.data, Poll) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.count(), 0) def test_bulk_create_history_rolls_back_when_last_exists(self): Poll.objects.create(id=5, question="Question 5", pub_date=timezone.now()) self.assertEqual(Poll.objects.count(), 1) self.assertEqual(Poll.history.count(), 1) with self.assertRaises(IntegrityError): bulk_create_with_history(self.data, Poll, batch_size=1) self.assertEqual(Poll.objects.count(), 1) self.assertEqual(Poll.history.count(), 1) def test_bulk_create_fails_with_wrong_model(self): with self.assertRaises(AttributeError): bulk_create_with_history(self.data, Document) self.assertEqual(Poll.objects.count(), 0) self.assertEqual(Poll.history.count(), 0) @patch("simple_history.utils.get_history_manager_for_model") def test_bulk_create_no_ids_return(self, hist_manager_mock): objects = [Place(id=1, name="Place 1")] model = Mock( _default_manager=Mock( bulk_create=Mock(return_value=[Place(name="Place 1")]), filter=Mock(return_value=objects), ), _meta=Mock(get_fields=Mock(return_value=[])), ) result = bulk_create_with_history(objects, model) self.assertEqual(result, objects) hist_manager_mock().bulk_history_create.assert_called_with( objects, batch_size=None, default_user=None, default_change_reason=None, default_date=None, ) class BulkCreateWithManyToManyField(TestCase): def setUp(self): self.data = [ BulkCreateManyToManyModel(name="Object 1"), BulkCreateManyToManyModel(name="Object 2"), BulkCreateManyToManyModel(name="Object 3"), BulkCreateManyToManyModel(name="Object 4"), BulkCreateManyToManyModel(name="Object 5"), ] def test_bulk_create_with_history(self): bulk_create_with_history(self.data, BulkCreateManyToManyModel) self.assertEqual(BulkCreateManyToManyModel.objects.count(), 5) class BulkUpdateWithHistoryTestCase(TestCase): def setUp(self): self.data = [ Poll(id=1, question="Question 1", pub_date=timezone.now()), Poll(id=2, question="Question 2", pub_date=timezone.now()), Poll(id=3, question="Question 3", pub_date=timezone.now()), Poll(id=4, question="Question 4", pub_date=timezone.now()), Poll(id=5, question="Question 5", pub_date=timezone.now()), ] bulk_create_with_history(self.data, Poll) self.data[3].question = "Updated question" def test_bulk_update_history(self): bulk_update_with_history( self.data, Poll, fields=["question"], ) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.objects.get(id=4).question, "Updated question") self.assertEqual(Poll.history.count(), 10) self.assertEqual(Poll.history.filter(history_type="~").count(), 5) @override_settings(SIMPLE_HISTORY_ENABLED=False) def test_bulk_update_history_without_history_enabled(self): self.assertEqual(Poll.history.count(), 5) # because setup called with enabled settings bulk_update_with_history( self.data, Poll, fields=["question"], ) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.objects.get(id=4).question, "Updated question") self.assertEqual(Poll.history.count(), 5) self.assertEqual(Poll.history.filter(history_type="~").count(), 0) def test_bulk_update_history_with_default_user(self): user = User.objects.create_user("tester", "tester@example.com") bulk_update_with_history( self.data, Poll, fields=["question"], default_user=user ) self.assertTrue( all( [ history.history_user == user for history in Poll.history.filter(history_type="~") ] ) ) def test_bulk_update_history_with_default_change_reason(self): bulk_update_with_history( self.data, Poll, fields=["question"], default_change_reason="my change reason", ) self.assertTrue( all( [ history.history_change_reason == "my change reason" for history in Poll.history.filter(history_type="~") ] ) ) def test_bulk_update_history_with_default_date(self): date = datetime(2020, 7, 1) bulk_update_with_history( self.data, Poll, fields=["question"], default_date=date ) self.assertTrue( all( [ history.history_date == date for history in Poll.history.filter(history_type="~") ] ) ) def test_bulk_update_history_num_queries_is_two(self): with self.assertNumQueries(2): bulk_update_with_history( self.data, Poll, fields=["question"], ) def test_bulk_update_history_on_model_without_history_raises_error(self): self.data = [ Place(id=1, name="Place 1"), Place(id=2, name="Place 2"), Place(id=3, name="Place 3"), ] Place.objects.bulk_create(self.data) self.data[0].name = "test" with self.assertRaises(NotHistoricalModelError): bulk_update_with_history(self.data, Place, fields=["name"]) def test_num_queries_when_batch_size_is_less_than_total(self): with self.assertNumQueries(6): bulk_update_with_history(self.data, Poll, fields=["question"], batch_size=2) def test_bulk_update_history_with_batch_size(self): bulk_update_with_history(self.data, Poll, fields=["question"], batch_size=2) self.assertEqual(Poll.objects.count(), 5) self.assertEqual(Poll.history.filter(history_type="~").count(), 5) class BulkUpdateWithHistoryAlternativeManagersTestCase(TestCase): def setUp(self): self.data = [ PollWithAlternativeManager( id=1, question="Question 1", pub_date=timezone.now() ), PollWithAlternativeManager( id=2, question="Question 2", pub_date=timezone.now() ), PollWithAlternativeManager( id=3, question="Question 3", pub_date=timezone.now() ), PollWithAlternativeManager( id=4, question="Question 4", pub_date=timezone.now() ), PollWithAlternativeManager( id=5, question="Question 5", pub_date=timezone.now() ), ] bulk_create_with_history( self.data, PollWithAlternativeManager, ) def test_bulk_update_history_default_manager(self): self.data[3].question = "Updated question" bulk_update_with_history( self.data, PollWithAlternativeManager, fields=["question"], ) self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5) self.assertEqual( PollWithAlternativeManager.all_objects.get(id=4).question, "Updated question", ) self.assertEqual(PollWithAlternativeManager.history.count(), 10) self.assertEqual( PollWithAlternativeManager.history.filter(history_type="~").count(), 5 ) def test_bulk_update_history_other_manager(self): # filtered by default manager self.data[0].question = "Updated question" bulk_update_with_history( self.data, PollWithAlternativeManager, fields=["question"], manager=PollWithAlternativeManager.all_objects, ) self.assertEqual(PollWithAlternativeManager.all_objects.count(), 5) self.assertEqual( PollWithAlternativeManager.all_objects.get(id=1).question, "Updated question", ) self.assertEqual(PollWithAlternativeManager.history.count(), 10) self.assertEqual( PollWithAlternativeManager.history.filter(history_type="~").count(), 5 ) def test_bulk_update_history_wrong_manager(self): with self.assertRaises(AlternativeManagerError): bulk_update_with_history( self.data, PollWithAlternativeManager, fields=["question"], manager=Poll.objects, ) class UpdateChangeReasonTestCase(TestCase): def test_update_change_reason_with_excluded_fields(self): poll = PollWithExcludeFields( question="what's up?", pub_date=timezone.now(), place="The Pub" ) poll.save() update_change_reason(poll, "Test change reason.") most_recent = poll.history.order_by("-history_date").first() self.assertEqual(most_recent.history_change_reason, "Test change reason.") django-simple-history-3.1.1/simple_history/tests/tests/utils.py000066400000000000000000000042101423103303600250240ustar00rootroot00000000000000import django from django.conf import settings from simple_history.tests.models import HistoricalModelWithHistoryInDifferentDb request_middleware = "simple_history.middleware.HistoryRequestMiddleware" OTHER_DB_NAME = "other" middleware_override_settings = { "MIDDLEWARE": (settings.MIDDLEWARE + [request_middleware]) } class TestDbRouter: def db_for_read(self, model, **hints): if model._meta.app_label == "external": return OTHER_DB_NAME return None def db_for_write(self, model, **hints): if model._meta.app_label == "external": return OTHER_DB_NAME return None def allow_relation(self, obj1, obj2, **hints): if obj1._meta.app_label == "external" and obj2._meta.app_label == "external": return True return None def allow_migrate(self, db, app_label, model_name=None, **hints): if app_label == "external": return db == OTHER_DB_NAME elif db == OTHER_DB_NAME: return False else: return None database_router_override_settings = { "DATABASE_ROUTERS": ["simple_history.tests.tests.utils.TestDbRouter"] } class TestModelWithHistoryInDifferentDbRouter: def db_for_read(self, model, **hints): if model == HistoricalModelWithHistoryInDifferentDb: return OTHER_DB_NAME return None def db_for_write(self, model, **hints): if model == HistoricalModelWithHistoryInDifferentDb: return OTHER_DB_NAME return None def allow_relation(self, obj1, obj2, **hints): if isinstance(obj1, HistoricalModelWithHistoryInDifferentDb) or isinstance( obj2, HistoricalModelWithHistoryInDifferentDb ): return False return None def allow_migrate(self, db, app_label, model_name=None, **hints): if model_name == HistoricalModelWithHistoryInDifferentDb._meta.model_name: return db == OTHER_DB_NAME return None database_router_override_settings_history_in_diff_db = { "DATABASE_ROUTERS": [ "simple_history.tests.tests.utils.TestModelWithHistoryInDifferentDbRouter" ] } django-simple-history-3.1.1/simple_history/tests/urls.py000066400000000000000000000035451423103303600235210ustar00rootroot00000000000000from django.contrib import admin from django.urls import path, re_path from simple_history.tests.view import ( BucketDataRegisterRequestUserCreate, BucketDataRegisterRequestUserDetail, PollBulkCreateView, PollBulkCreateWithDefaultUserView, PollBulkUpdateView, PollBulkUpdateWithDefaultUserView, PollCreate, PollDelete, PollDetail, PollList, PollUpdate, PollWithHistoricalIPAddressCreate, ) from . import other_admin admin.autodiscover() urlpatterns = [ path("admin/", admin.site.urls), path("other-admin/", other_admin.site.urls), path( "bucket_data/add/", BucketDataRegisterRequestUserCreate.as_view(), name="bucket_data-add", ), re_path( r"^bucket_data/(?P[0-9]+)/$", BucketDataRegisterRequestUserDetail.as_view(), name="bucket_data-detail", ), path("poll/add/", PollCreate.as_view(), name="poll-add"), path( "pollwithhistoricalipaddress/add", PollWithHistoricalIPAddressCreate.as_view(), name="pollip-add", ), re_path(r"^poll/(?P[0-9]+)/$", PollUpdate.as_view(), name="poll-update"), re_path(r"^poll/(?P[0-9]+)/delete/$", PollDelete.as_view(), name="poll-delete"), re_path(r"^polls/(?P[0-9]+)/$", PollDetail.as_view(), name="poll-detail"), path("polls/", PollList.as_view(), name="poll-list"), path("polls-bulk-create/", PollBulkCreateView.as_view(), name="poll-bulk-create"), path( "polls-bulk-create-default-user/", PollBulkCreateWithDefaultUserView.as_view(), name="poll-bulk-create-with-default-user", ), path("polls-bulk-update/", PollBulkUpdateView.as_view(), name="poll-bulk-update"), path( "polls-bulk-update-default-user/", PollBulkUpdateWithDefaultUserView.as_view(), name="poll-bulk-update-with-default-user", ), ] django-simple-history-3.1.1/simple_history/tests/view.py000066400000000000000000000061561423103303600235070ustar00rootroot00000000000000from datetime import date from django.http import HttpResponse from django.urls import reverse_lazy from django.views import View from django.views.generic import ( CreateView, DeleteView, DetailView, ListView, UpdateView, ) from simple_history.tests.custom_user.models import CustomUser from simple_history.tests.models import ( BucketDataRegisterRequestUser, Poll, PollWithHistoricalIPAddress, ) from simple_history.utils import bulk_create_with_history, bulk_update_with_history class PollCreate(CreateView): model = Poll fields = ["question", "pub_date"] class PollBulkCreateView(View): def post(self, request, *args, **kwargs): poll_info_list = [ {"question": "1", "pub_date": date(2020, 1, 1)}, {"question": "2", "pub_date": date(2020, 1, 2)}, ] polls_to_create = [Poll(**poll_info) for poll_info in poll_info_list] bulk_create_with_history(polls_to_create, Poll) return HttpResponse(status=200) class PollBulkCreateWithDefaultUserView(View): def post(self, request, *args, **kwargs): default_user = CustomUser.objects.create_superuser( "test_user", "test_user@example.com", "pass" ) # Bulk create objects with history poll_info_list = [ {"question": "1", "pub_date": date(2020, 1, 1)}, {"question": "2", "pub_date": date(2020, 1, 2)}, ] polls_to_create = [Poll(**poll_info) for poll_info in poll_info_list] bulk_create_with_history(polls_to_create, Poll, default_user=default_user) return HttpResponse(status=200) class PollBulkUpdateView(View): def post(self, request, *args, **kwargs): polls = Poll.objects.order_by("pub_date") for i, poll in enumerate(polls): poll.question = str(i) bulk_update_with_history(polls, fields=["question"], model=Poll) return HttpResponse(status=201) class PollBulkUpdateWithDefaultUserView(View): def post(self, request, *args, **kwargs): default_user = CustomUser.objects.create_superuser( "test_user", "test_user@example.com", "pass" ) polls = Poll.objects.all() for i, poll in enumerate(polls): poll.question = str(i) bulk_update_with_history( polls, fields=["question"], model=Poll, default_user=default_user ) return HttpResponse(status=201) class PollWithHistoricalIPAddressCreate(CreateView): model = PollWithHistoricalIPAddress fields = ["question", "pub_date"] class PollUpdate(UpdateView): model = Poll fields = ["question", "pub_date"] class PollDelete(DeleteView): model = Poll success_url = reverse_lazy("poll-list") class PollList(ListView): model = Poll fields = ["question", "pub_date"] class PollDetail(DetailView): model = Poll fields = ["question", "pub_date"] class BucketDataRegisterRequestUserCreate(CreateView): model = BucketDataRegisterRequestUser fields = ["data"] class BucketDataRegisterRequestUserDetail(DetailView): model = BucketDataRegisterRequestUser fields = ["data"] django-simple-history-3.1.1/simple_history/utils.py000066400000000000000000000150501423103303600225240ustar00rootroot00000000000000import warnings import django from django.db import transaction from django.db.models import ForeignKey, ManyToManyField from django.forms.models import model_to_dict from simple_history.exceptions import AlternativeManagerError, NotHistoricalModelError def update_change_reason(instance, reason): attrs = {} model = type(instance) manager = instance if instance.pk is not None else model history = get_history_manager_for_model(manager) history_fields = [field.attname for field in history.model._meta.fields] for field in instance._meta.fields: if field.attname not in history_fields: continue value = getattr(instance, field.attname) if field.primary_key is True: if value is not None: attrs[field.attname] = value else: attrs[field.attname] = value record = history.filter(**attrs).order_by("-history_date").first() record.history_change_reason = reason record.save() def get_history_manager_for_model(model): """Return the history manager for a given app model.""" try: manager_name = model._meta.simple_history_manager_attribute except AttributeError: raise NotHistoricalModelError(f"Cannot find a historical model for {model}.") return getattr(model, manager_name) def get_history_manager_from_history(history_instance): """ Return the history manager, based on an existing history instance. """ key_name = get_app_model_primary_key_name(history_instance.instance_type) return get_history_manager_for_model(history_instance.instance_type).filter( **{key_name: getattr(history_instance, key_name)} ) def get_history_model_for_model(model): """Return the history model for a given app model.""" return get_history_manager_for_model(model).model def get_app_model_primary_key_name(model): """Return the primary key name for a given app model.""" if isinstance(model._meta.pk, ForeignKey): return model._meta.pk.name + "_id" return model._meta.pk.name def bulk_create_with_history( objs, model, batch_size=None, ignore_conflicts=False, default_user=None, default_change_reason=None, default_date=None, ): """ Bulk create the objects specified by objs while also bulk creating their history (all in one transaction). Because of not providing primary key attribute after bulk_create on any DB except Postgres (https://docs.djangoproject.com/en/2.2/ref/models/querysets/#bulk-create) Divide this process on two transactions for other DB's :param objs: List of objs (not yet saved to the db) of type model :param model: Model class that should be created :param batch_size: Number of objects that should be created in each batch :param default_user: Optional user to specify as the history_user in each historical record :param default_change_reason: Optional change reason to specify as the change_reason in each historical record :param default_date: Optional date to specify as the history_date in each historical record :return: List of objs with IDs """ # Exclude ManyToManyFields because they end up as invalid kwargs to # model.objects.filter(...) below. exclude_fields = [ field.name for field in model._meta.get_fields() if isinstance(field, ManyToManyField) ] history_manager = get_history_manager_for_model(model) model_manager = model._default_manager second_transaction_required = True with transaction.atomic(savepoint=False): objs_with_id = model_manager.bulk_create( objs, batch_size=batch_size, ignore_conflicts=ignore_conflicts ) if objs_with_id and objs_with_id[0].pk and not ignore_conflicts: second_transaction_required = False history_manager.bulk_history_create( objs_with_id, batch_size=batch_size, default_user=default_user, default_change_reason=default_change_reason, default_date=default_date, ) if second_transaction_required: obj_list = [] with transaction.atomic(savepoint=False): for obj in objs_with_id: attributes = dict( filter( lambda x: x[1] is not None, model_to_dict(obj, exclude=exclude_fields).items(), ) ) obj_list += model_manager.filter(**attributes) history_manager.bulk_history_create( obj_list, batch_size=batch_size, default_user=default_user, default_change_reason=default_change_reason, default_date=default_date, ) objs_with_id = obj_list return objs_with_id def bulk_update_with_history( objs, model, fields, batch_size=None, default_user=None, default_change_reason=None, default_date=None, manager=None, ): """ Bulk update the objects specified by objs while also bulk creating their history (all in one transaction). :param objs: List of objs of type model to be updated :param model: Model class that should be updated :param fields: The fields that are updated :param batch_size: Number of objects that should be updated in each batch :param default_user: Optional user to specify as the history_user in each historical record :param default_change_reason: Optional change reason to specify as the change_reason in each historical record :param default_date: Optional date to specify as the history_date in each historical record :param manager: Optional model manager to use for the model instead of the default manager """ history_manager = get_history_manager_for_model(model) model_manager = manager or model._default_manager if model_manager.model is not model: raise AlternativeManagerError("The given manager does not belong to the model.") with transaction.atomic(savepoint=False): model_manager.bulk_update(objs, fields, batch_size=batch_size) history_manager.bulk_history_create( objs, batch_size=batch_size, update=True, default_user=default_user, default_change_reason=default_change_reason, default_date=default_date, ) def get_change_reason_from_object(obj): if hasattr(obj, "_change_reason"): return getattr(obj, "_change_reason") return None django-simple-history-3.1.1/tox.ini000066400000000000000000000026561423103303600172630ustar00rootroot00000000000000[tox] envlist = py{37,38,39,310}-dj32-{sqlite3,postgres,mysql,mariadb}, py{38,39,310}-dj{40,main}-{sqlite3,postgres,mysql,mariadb}, docs, lint [gh-actions] python = 3.7: py37 3.8: py38, docs, lint 3.9: py39 3.10: py310 [gh-actions:env] DJANGO = 3.2: dj32 4.0: dj40 main: djmain [flake8] ignore = N802,F401,W503 max-complexity = 10 max-line-length = 88 exclude = __init__.py,simple_history/registry_tests/migration_test_app/migrations/* [testenv] deps = -rrequirements/test.txt dj32: Django>=3.2,<3.3 dj40: Django>=4.0,<4.1 djmain: https://github.com/django/django/tarball/main postgres: -rrequirements/postgres.txt mysql: -rrequirements/mysql.txt mariadb: -rrequirements/mysql.txt commands = sqlite3: coverage run -a runtests.py {posargs} postgres: coverage run -a runtests.py --database=postgres {posargs} mysql: coverage run -a runtests.py --database=mysql {posargs} mariadb: coverage run -a runtests.py --database=mariadb {posargs} coverage report [testenv:format] deps = -rrequirements/lint.txt commands = isort docs simple_history runtests.py setup.py black docs simple_history runtests.py setup.py flake8 simple_history [testenv:lint] deps = pre-commit commands = pre-commit run --all-files [testenv:docs] changedir = docs deps = -rrequirements/docs.txt commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html