├── .codeclimate.yml ├── .coveragerc ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── release.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── .yamllint ├── AUTHORS.rst ├── CHANGES.rst ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst ├── LICENSE.txt ├── Makefile ├── PULL_REQUEST_TEMPLATE.md ├── README.rst ├── codecov.yml ├── doc-requirements.txt ├── docs ├── Makefile ├── _static │ └── .keep ├── admin.rst ├── common_issues.rst ├── conf.py ├── historical_model.rst ├── history_diffing.rst ├── index.rst ├── make.bat ├── multiple_dbs.rst ├── querying_history.rst ├── quick_start.rst ├── screens │ ├── 10_revert_disabled.png │ ├── 1_poll_history.png │ ├── 2_revert.png │ ├── 3_poll_reverted.png │ ├── 4_history_after_poll_reverted.png │ └── 5_history_list_display.png ├── signals.rst ├── user_tracking.rst └── utils.rst ├── pyproject.toml ├── requirements ├── coverage.txt ├── docs.txt ├── lint.txt ├── mysql.txt ├── postgres.txt ├── test.txt └── tox.txt ├── runtests.py ├── simple_history ├── __init__.py ├── admin.py ├── exceptions.py ├── locale │ ├── ar │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── cs_CZ │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── id │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── nb │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pl │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru_RU │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ur │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── zh_Hans │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── clean_duplicate_history.py │ │ ├── clean_old_history.py │ │ └── populate_history.py ├── manager.py ├── middleware.py ├── models.py ├── registry_tests │ ├── __init__.py │ ├── migration_test_app │ │ ├── __init__.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py │ │ │ ├── 0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py │ │ │ ├── 0004_history_date_indexing.py │ │ │ ├── 0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py │ │ │ ├── 0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py │ │ │ ├── 0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py │ │ │ └── __init__.py │ │ └── models.py │ ├── models.py │ └── tests.py ├── signals.py ├── template_utils.py ├── templates │ └── simple_history │ │ ├── object_history.html │ │ ├── object_history_form.html │ │ ├── object_history_list.html │ │ └── submit_line.html ├── templatetags │ ├── __init__.py │ ├── getattributes.py │ └── simple_history_compat.py ├── tests │ ├── __init__.py │ ├── admin.py │ ├── custom_user │ │ ├── __init__.py │ │ ├── admin.py │ │ └── models.py │ ├── external │ │ ├── __init__.py │ │ └── models.py │ ├── generated_file_checks │ │ ├── __init__.py │ │ └── check_translations.py │ ├── models.py │ ├── other_admin.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_admin.py │ │ ├── test_commands.py │ │ ├── test_deprecation.py │ │ ├── test_index.py │ │ ├── test_manager.py │ │ ├── test_middleware.py │ │ ├── test_models.py │ │ ├── test_signals.py │ │ ├── test_template_utils.py │ │ ├── test_templatetags.py │ │ ├── test_utils.py │ │ └── utils.py │ ├── urls.py │ └── view.py └── utils.py └── tox.ini /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "2" # required to adjust maintainability checks 3 | checks: 4 | argument-count: 5 | config: 6 | threshold: 7 7 | complex-logic: 8 | config: 9 | threshold: 7 10 | file-lines: 11 | config: 12 | threshold: 500 13 | method-complexity: 14 | config: 15 | threshold: 7 16 | method-count: 17 | config: 18 | threshold: 20 19 | method-lines: 20 | config: 21 | threshold: 25 22 | nested-control-flow: 23 | config: 24 | threshold: 4 25 | return-statements: 26 | config: 27 | threshold: 4 28 | similar-code: 29 | config: 30 | threshold: 50 # language-specific defaults. an override will affect all languages. 31 | identical-code: 32 | config: 33 | threshold: # language-specific defaults. an override will affect all languages. 34 | plugins: 35 | bandit: 36 | enabled: true 37 | pep8: 38 | enabled: false 39 | radon: 40 | enabled: true 41 | threshold: "C" 42 | sonar-python: 43 | enabled: true 44 | exclude_patterns: 45 | - "simple_history/tests/" 46 | - "simple_history/registry_tests/" 47 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = simple_history/* 3 | omit = simple_history/tests/* 4 | branch = 1 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; This file is for unifying the coding style for different editors and IDEs. 2 | ; More information at http://EditorConfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | indent_style = space 9 | end_of_line = lf 10 | insert_final_newline = true 11 | max_line_length = 88 12 | trim_trailing_whitespace = true 13 | 14 | [*.{py,rst,ini}] 15 | indent_size = 4 16 | 17 | [*.{html,yml}] 18 | indent_size = 2 19 | 20 | [LICENSE.txt] 21 | end_of_line = crlf 22 | insert_final_newline = false 23 | 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Environment (please complete the following information):** 24 | - OS: [e.g. Ubuntu 18.04] 25 | - Browser (if applicable): [e.g. chrome, safari] 26 | - Django Simple History Version: [e.g. 1.9.1] 27 | - Django Version: [e.g. 1.11.11] 28 | - Database Version: [e.g. PostgreSQL 10.5.0] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Problem Statement** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: "pip" 5 | directory: "/requirements" 6 | schedule: 7 | interval: "daily" 8 | 9 | - package-ecosystem: "github-actions" 10 | # Workflow files stored in the default location of `.github/workflows` 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | changelog: 3 | exclude: 4 | authors: 5 | - dependabot 6 | - pre-commit-ci 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | build: 11 | if: github.repository == 'jazzband/django-simple-history' 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: 3.x 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install -U pip 27 | python -m pip install -U build twine 28 | 29 | - name: Build package 30 | run: | 31 | python -m build 32 | twine check dist/* 33 | 34 | - name: Upload packages to Jazzband 35 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository-url: https://jazzband.co/projects/django-simple-history/upload 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test 3 | 4 | on: [push, pull_request] 5 | 6 | jobs: 7 | build: 8 | name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 15 | django-version: ['4.2', '5.0', '5.1', '5.2', 'main'] 16 | 17 | exclude: 18 | # Exclude py3.9 for Django >= 5.0, 19 | # and py3.10 and py3.11 for Django > 5.2 20 | - python-version: '3.9' 21 | django-version: '5.0' 22 | - python-version: '3.9' 23 | django-version: '5.1' 24 | - python-version: '3.9' 25 | django-version: '5.2' 26 | - python-version: '3.9' 27 | django-version: 'main' 28 | - python-version: '3.10' 29 | django-version: 'main' 30 | - python-version: '3.11' 31 | django-version: 'main' 32 | 33 | services: 34 | 35 | postgres: 36 | image: postgres:latest 37 | env: 38 | POSTGRES_USER: postgres 39 | POSTGRES_PASSWORD: postgres 40 | POSTGRES_DB: github_actions 41 | ports: 42 | - 5432:5432 43 | # needed because the postgres container does not provide a healthcheck 44 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 45 | 46 | mysql: 47 | image: mysql:latest 48 | env: 49 | MYSQL_DATABASE: mysql 50 | MYSQL_ROOT_PASSWORD: mysql 51 | ports: 52 | - 3306:3306 53 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 54 | 55 | mariadb: 56 | image: mariadb:latest 57 | env: 58 | MARIADB_DATABASE: mariadb 59 | MARIADB_ROOT_PASSWORD: mariadb 60 | ports: 61 | - 3307:3306 62 | options: --health-cmd="mariadb-admin ping" --health-interval=10s --health-timeout=5s --health-retries=3 63 | 64 | steps: 65 | - uses: actions/checkout@v4 66 | 67 | - name: Set up Python ${{ matrix.python-version }} 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: ${{ matrix.python-version }} 71 | cache: 'pip' 72 | cache-dependency-path: | 73 | pyproject.toml 74 | tox.ini 75 | requirements/*.txt 76 | 77 | - name: Install dependencies 78 | run: | 79 | python -m pip install --upgrade pip 80 | pip install -r requirements/tox.txt 81 | 82 | - name: Tox tests 83 | run: | 84 | tox -v 85 | env: 86 | DJANGO: ${{ matrix.django-version }} 87 | 88 | - name: Upload coverage 89 | uses: codecov/codecov-action@v3 90 | with: 91 | name: Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }} 92 | 93 | 94 | generated_file_checks: 95 | name: Check generated files 96 | runs-on: ubuntu-latest 97 | 98 | steps: 99 | - uses: actions/checkout@v4 100 | 101 | - name: Install gettext 102 | run: | 103 | sudo apt-get update 104 | sudo apt-get install -y gettext 105 | 106 | - name: Set up newest stable Python version 107 | uses: actions/setup-python@v5 108 | with: 109 | python-version: 3.13 110 | cache: 'pip' 111 | # Invalidate the cache when this file updates, as the dependencies' versions 112 | # are pinned in the step below 113 | cache-dependency-path: '.github/workflows/test.yml' 114 | 115 | - name: Install dependencies 116 | run: | 117 | python -m pip install --upgrade pip 118 | # Install this project in editable mode, so that its package metadata can be queried 119 | pip install -e . 120 | # Install the latest minor version of Django we support 121 | pip install Django==5.1 122 | 123 | - name: Check translation files are updated 124 | run: python -m simple_history.tests.generated_file_checks.check_translations 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info/ 3 | *.eggs 4 | *.pyc 5 | .coverage 6 | .idea 7 | .tox/ 8 | .venv/ 9 | .python-version 10 | /.project 11 | /.pydevproject 12 | /.ve 13 | build/ 14 | dist/ 15 | docs/_build 16 | htmlcov/ 17 | MANIFEST 18 | test_files/ 19 | venv/ 20 | .DS_Store 21 | env 22 | .vscode 23 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/PyCQA/bandit 4 | rev: 1.8.3 5 | hooks: 6 | - id: bandit 7 | exclude: /.*tests/ 8 | 9 | - repo: https://github.com/psf/black-pre-commit-mirror 10 | rev: 25.1.0 11 | hooks: 12 | - id: black 13 | language_version: python3.9 14 | 15 | - repo: https://github.com/pycqa/flake8 16 | rev: 7.2.0 17 | hooks: 18 | - id: flake8 19 | args: 20 | - "--config=tox.ini" 21 | 22 | - repo: https://github.com/PyCQA/isort 23 | rev: 6.0.1 24 | hooks: 25 | - id: isort 26 | 27 | - repo: https://github.com/pre-commit/pre-commit-hooks 28 | rev: v5.0.0 29 | hooks: 30 | - id: requirements-txt-fixer 31 | files: requirements/.*\.txt$ 32 | - id: trailing-whitespace 33 | - id: check-added-large-files 34 | - id: fix-byte-order-marker 35 | - id: check-docstring-first 36 | - id: check-executables-have-shebangs 37 | - id: check-merge-conflict 38 | - id: check-toml 39 | - id: debug-statements 40 | - id: detect-private-key 41 | 42 | - repo: https://github.com/tox-dev/pyproject-fmt 43 | rev: v2.5.1 44 | hooks: 45 | - id: pyproject-fmt 46 | - repo: https://github.com/abravalheri/validate-pyproject 47 | rev: v0.24.1 48 | hooks: 49 | - id: validate-pyproject 50 | 51 | - repo: https://github.com/adrienverge/yamllint 52 | rev: v1.37.1 53 | hooks: 54 | - id: yamllint 55 | args: 56 | - "--strict" 57 | 58 | - repo: https://github.com/asottile/pyupgrade 59 | rev: v3.19.1 60 | hooks: 61 | - id: pyupgrade 62 | args: [--py39-plus] 63 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Read the Docs configuration file for Sphinx projects 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.12" 13 | 14 | # Build documentation in the "docs/" directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | # Optional but recommended, declare the Python requirements required 19 | # to build your documentation 20 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 21 | python: 22 | install: 23 | - requirements: requirements/docs.txt 24 | # Install this project locally, so that its package metadata can be queried 25 | - method: pip 26 | path: . 27 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | # allow "on" until yamllint stops checking keys for truthy! 2 | # https://github.com/adrienverge/yamllint/issues/158 3 | --- 4 | extends: default 5 | 6 | rules: 7 | comments-indentation: disable 8 | braces: disable 9 | line-length: 10 | max: 120 11 | truthy: 12 | level: error 13 | allowed-values: ['true', 'false', 'on'] 14 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Maintainers 2 | =========== 3 | 4 | - Brian Armstrong (`barm `_) 5 | - David Grochowski (`ThePumpingLemma `_) 6 | - Kyle Seever (`kseever `_) 7 | - Micah Denbraver (`macro1 `_) 8 | - Ross Mechanic (`rossmechanic `_) 9 | - Trey Hunner (`treyhunner `_) 10 | 11 | Authors 12 | ======= 13 | 14 | - Adnan Umer (`uadnan `_) 15 | - Aleksey Kladov 16 | - Alexander Anikeev 17 | - Amanda Ng (`AmandaCLNg `_) 18 | - Amartis Gladius (`Amartis `_) 19 | - Anton Kulikov (`bigtimecriminal `_) 20 | - Ben Lawson (`blawson `_) 21 | - Benjamin Mampaey (`bmampaey `_) 22 | - Berke Agababaoglu (`bagababaoglu `_) 23 | - Bheesham Persaud (`bheesham `_) 24 | - `bradford281 `_ 25 | - Brian Armstrong (`barm `_) 26 | - Brian Dixon 27 | - Brian Mesick (`bmedx `_) 28 | - Buddy Lindsey, Jr. 29 | - Carlos San Emeterio (`Carlos-San-Emeterio `_) 30 | - Christopher Broderick (`uhurusurfa `_) 31 | - Christopher Johns (`tyrantwave `_) 32 | - Conrad (`creyD `_) 33 | - Corey Bertram 34 | - Craig Maloney (`craigmaloney `_) 35 | - Damien Nozay 36 | - Daniel Gilge 37 | - Daniel Levy 38 | - Daniel Roschka 39 | - Daniil Skrobov (`yetanotherape `_) 40 | - David Grochowski (`ThePumpingLemma `_) 41 | - David Hite 42 | - David Smith 43 | - `ddabble `_ 44 | - Dmytro Shyshov (`xahgmah `_) 45 | - Edouard Richard (`vied12 ` _) 46 | - Eduardo Cuducos 47 | - Erik van Widenfelt (`erikvw `_) 48 | - Fábio Capuano (`fabiocapsouza `_) 52 | - Frank Sachsenheim 53 | - George Kettleborough (`georgek `_) 54 | - George Vilches 55 | - Gregory Bataille 56 | - Grzegorz Bialy 57 | - Guillermo Eijo (`guilleijo `_) 58 | - Hamish Downer 59 | - Hans de Jong (`sult `_) 60 | - Hanyin Zhang 61 | - Héctor Durán (`hector97i `) 62 | - Hernan Esteves (`sevetseh28 `_) 63 | - Hielke Walinga (`hwalinga `_) 64 | - Hugo van Kemenade (`hugovk `_) 65 | - Jack Cushman (`jcushman `_) 66 | - Jake Howard (`RealOrangeOne `_) 67 | - James Muranga (`jamesmura `_) 68 | - James Pulec 69 | - Jeppe Fihl-Pearson (`Tenzer `_) 70 | - Jesse Shapiro 71 | - Jihoon Baek (`jihoon796 `_) 72 | - Jim Gomez 73 | - Jim King (`jeking3 `_) 74 | - Joao Junior (`joaojunior `_) 75 | - Joao Pedro Francese 76 | - `jofusa `_ 77 | - John Whitlock 78 | - Jonathan Leroy 79 | - Jonathan Loo (`alpha1d3d `_) 80 | - Jonathan Sanchez 81 | - Jonathan Zvesper (`zvesp `_) 82 | - Jordan Hyatt (`JordanHyatt `_) 83 | - Jordon Wing (`jordonwii `_) 84 | - Josh Fyne 85 | - Josh Thomas (`joshuadavidthomas `_) 86 | - Jurrian Tromp (`jurrian `_) 87 | - Keith Hackbarth 88 | - Kevin Foster 89 | - Kira (`kiraware `_) 90 | - Klaas van Schelven 91 | - Kris Neuharth 92 | - Kyle Seever (`kseever `_) 93 | - Léni Gauffier (`legau `_) 94 | - Leticia Portella 95 | - Lucas Wiman 96 | - Maciej "RooTer" Urbański 97 | - Marcelo Canina (`marcanuy `_) 98 | - Marco Sirabella 99 | - Mark Davidoff 100 | - Martin Bachwerk 101 | - Marty Alchin 102 | - Matheus Cansian (`mscansian `_) 103 | - Matthew Somerville (`dracos `_) 104 | - Mauricio de Abreu Antunes 105 | - Maxim Zemskov (`MaximZemskov `_) 106 | - Micah Denbraver 107 | - Michael England 108 | - Miguel Vargas 109 | - Mike Spainhower 110 | - Muneeb Shahid (`muneeb706 `_) 111 | - Nathan Villagaray-Carski (`ncvc `_) 112 | - Nianpeng Li 113 | - Nick Träger 114 | - Noel James (`NoelJames `_) 115 | - Ofek Lev (`ofek `_) 116 | - Phillip Marshall 117 | - Prakash Venkatraman (`dopatraman `_) 118 | - Rajesh Pappula 119 | - Ray Logel 120 | - Raynald de Lahondes 121 | - Renaud Perrin (`leminaw `_) 122 | - Roberto Aguilar 123 | - Rod Xavier Bondoc 124 | - Ross Lote 125 | - Ross Mechanic (`rossmechanic `_) 126 | - Ross Rogers 127 | - Sergey Ozeranskiy (`ozeranskiy `_) 128 | - Shane Engelman 129 | - Steeve Chailloux 130 | - Stefan Borer (`sbor23 `_) 131 | - Steven Buss (`sbuss `_) 132 | - Steven Klass 133 | - Thijs Kramer (`thijskramer `_) 134 | - Tim Schilling (`tim-schilling `_) 135 | - Todd Wolfson (`twolfson `_) 136 | - Tommy Beadle (`tbeadle `_) 137 | - Trey Hunner (`treyhunner `_) 138 | - Ulysses Vilela 139 | - `vnagendra `_ 140 | - `yakimka `_ 141 | - `Paulo Peres `_ 142 | - `Alex Todorov `_ 143 | - David Smith (`smithdc1 `_) 144 | - Shi Han Ng (`shihanng `_) 145 | - `ddusi `_ 146 | - `DanialErfanian `_ 147 | - `Sridhar Marella `_ 148 | - `Mattia Fantoni `_ 149 | - `Trent Holliday `_ 150 | 151 | Background 152 | ========== 153 | 154 | This code originally comes from Pro Django, published by Apress, Inc. 155 | in December 2008. The author of the book and primary author 156 | of the code is Marty Alchin , who 157 | may be found online at . 158 | 159 | As part of the technical review process, additional code 160 | modifications were provided by the technical reviewer, 161 | George Vilches . 162 | 163 | This code was originally extended, licensed, and improved by 164 | Corey Bertram with the permission of Marty Alchin. 165 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | As contributors and maintainers of the Jazzband projects, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating documentation, 6 | submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in the Jazzband a harassment-free experience 9 | for everyone, regardless of the level of experience, gender, gender identity and 10 | expression, sexual orientation, disability, personal appearance, body size, race, 11 | ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | - The use of sexualized language or imagery 16 | - Personal attacks 17 | - Trolling or insulting/derogatory comments 18 | - Public or private harassment 19 | - Publishing other's private information, such as physical or electronic addresses, 20 | without explicit permission 21 | - Other unethical or unprofessional conduct 22 | 23 | The Jazzband roadies have the right and responsibility to remove, edit, or reject 24 | comments, commits, code, wiki edits, issues, and other contributions that are not 25 | aligned to this Code of Conduct, or to ban temporarily or permanently any contributor 26 | for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 27 | 28 | By adopting this Code of Conduct, the roadies commit themselves to fairly and 29 | consistently applying these principles to every aspect of managing the jazzband 30 | projects. Roadies who do not follow or enforce the Code of Conduct may be permanently 31 | removed from the Jazzband roadies. 32 | 33 | This code of conduct applies both within project spaces and in public spaces when an 34 | individual is representing the project or its community. 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by 37 | contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and 38 | investigated and will result in a response that is deemed necessary and appropriate to 39 | the circumstances. Roadies are obligated to maintain confidentiality with regard to the 40 | reporter of an incident. 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 43 | 1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/3/0/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing to django-simple-history 2 | ===================================== 3 | 4 | .. image:: https://jazzband.co/static/img/jazzband.svg 5 | :target: https://jazzband.co/ 6 | :alt: Jazzband 7 | 8 | This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. 9 | 10 | Pull Requests 11 | ------------- 12 | 13 | Feel free to open pull requests before you've finished your code or tests. 14 | Opening your pull request soon will allow others to comment on it sooner. 15 | 16 | A checklist of things to remember when making a feature: 17 | 18 | - Write tests if applicable 19 | - Note important changes in the `CHANGES`_ file 20 | - Update the `README`_ file if needed 21 | - Update the documentation if needed 22 | - Add yourself to the `AUTHORS`_ file 23 | 24 | .. _AUTHORS: AUTHORS.rst 25 | .. _CHANGES: CHANGES.rst 26 | .. _README: README.rst 27 | 28 | Requirements 29 | ------------ 30 | 31 | The Makefile can be used for generating documentation and running tests. 32 | 33 | To install the requirements necessary for generating the documentation and 34 | running tests:: 35 | 36 | make init 37 | 38 | This will install: 39 | 40 | - `tox`_: used for running the tests against all supported versions of Django 41 | and Python as well as running tasks like lint, format, docs 42 | - `coverage`_: used for analyzing test coverage for tests 43 | 44 | If not using a virtualenv, the command should be prepended with ``sudo``. 45 | 46 | .. _tox: http://testrun.org/tox/latest// 47 | .. _coverage: http://nedbatchelder.com/code/coverage/ 48 | 49 | Documentation 50 | ------------- 51 | 52 | To regenerate the documentation run:: 53 | 54 | make docs 55 | 56 | Testing 57 | ------- 58 | 59 | Please add tests for your pull requests and make sure your changes don't break 60 | existing tests. 61 | 62 | To run tox and generate an HTML code coverage report (available in the 63 | ``htmlcov`` directory):: 64 | 65 | make test 66 | 67 | To quickly run the tests against a single version of Python and Django (note: you must 68 | ``pip install django`` beforehand):: 69 | 70 | python runtests.py 71 | 72 | Code Formatting 73 | --------------- 74 | We make use of `black`_ for code formatting. 75 | 76 | .. _black: https://black.readthedocs.io/en/stable/installation_and_usage.html 77 | 78 | You can install and run it along with other linters through pre-commit:: 79 | 80 | pre-commit install 81 | pre-commit run 82 | 83 | Once you install pre-commit it will sanity check any commit you make. 84 | Additionally, the CI process runs this check as well. 85 | 86 | Translations 87 | ------------ 88 | 89 | In order to add translations, refer to Django's `translation docs`_ and follow these 90 | steps: 91 | 92 | 1. Ensure that Django is installed 93 | 2. Invoke ``django-admin makemessages -l `` in the repository's root 94 | directory. 95 | 3. Add translations to the created 96 | ``simple_history/locale//LC_MESSAGES/django.po`` file. 97 | 4. Compile these with ``django-admin compilemessages``. 98 | 5. Commit and publish your translations as described above. 99 | 100 | .. _translation docs: https://docs.djangoproject.com/en/stable/topics/i18n/translation/#localization-how-to-create-language-files 101 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2008, Marty Alchin 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the author nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: init docs clean test 2 | 3 | clean: clean-build clean-pyc 4 | rm -fr htmlcov/ 5 | 6 | clean-build: 7 | rm -fr dist/ 8 | 9 | clean-pyc: 10 | find . -name '*.pyc' -exec rm -f {} + 11 | find . -name '*.pyo' -exec rm -f {} + 12 | find . -name '*~' -exec rm -f {} + 13 | 14 | init: 15 | pip install "tox>=1.8" coverage 16 | 17 | test: 18 | coverage erase 19 | tox 20 | coverage html 21 | 22 | docs: documentation 23 | 24 | documentation: 25 | tox -e docs 26 | 27 | dist: clean 28 | pip install -U build 29 | python -m build 30 | for file in dist/* ; do gpg --detach-sign -a "$$file" ; done 31 | ls -l dist 32 | 33 | test-release: dist 34 | pip install -U twine 35 | gpg --detach-sign -a dist/* 36 | twine upload -r pypitest dist/* 37 | 38 | release: dist 39 | pip install -U twine 40 | gpg --detach-sign -a dist/* 41 | twine upload dist/* 42 | 43 | format: 44 | tox -e format 45 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Related Issue 7 | 8 | 9 | 10 | 11 | 12 | ## Motivation and Context 13 | 14 | 15 | ## How Has This Been Tested? 16 | 17 | 18 | 19 | 20 | ## Screenshots (if appropriate): 21 | 22 | ## Types of changes 23 | 24 | - [ ] Bug fix (non-breaking change which fixes an issue) 25 | - [ ] New feature (non-breaking change which adds functionality) 26 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 27 | 28 | ## Checklist: 29 | 30 | 31 | - [ ] I have run the `pre-commit run` command to format and lint. 32 | - [ ] My change requires a change to the documentation. 33 | - [ ] I have updated the documentation accordingly. 34 | - [ ] I have read the **CONTRIBUTING** document. 35 | - [ ] I have added tests to cover my changes. 36 | - [ ] I have added my name and/or github handle to `AUTHORS.rst` 37 | - [ ] I have added my change to `CHANGES.rst` 38 | - [ ] All new and existing tests passed. 39 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-simple-history |pypi-version| 2 | ==================================== 3 | 4 | .. Start of PyPI readme 5 | 6 | |jazzband| |build-status| |docs| |coverage| |maintainability| |code-style| |downloads| 7 | 8 | .. |pypi-version| image:: https://img.shields.io/pypi/v/django-simple-history.svg 9 | :target: https://pypi.org/project/django-simple-history/ 10 | :alt: PyPI Version 11 | 12 | .. |jazzband| image:: https://jazzband.co/static/img/badge.svg 13 | :target: https://jazzband.co/ 14 | :alt: Jazzband 15 | 16 | .. |build-status| image:: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml/badge.svg 17 | :target: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml 18 | :alt: Build Status 19 | 20 | .. |docs| image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest 21 | :target: https://django-simple-history.readthedocs.io/en/latest/?badge=latest 22 | :alt: Documentation Status 23 | 24 | .. |coverage| image:: https://img.shields.io/codecov/c/github/jazzband/django-simple-history/master.svg 25 | :target: https://app.codecov.io/github/jazzband/django-simple-history?branch=master 26 | :alt: Test Coverage 27 | 28 | .. |maintainability| image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability 29 | :target: https://codeclimate.com/github/jazzband/django-simple-history/maintainability 30 | :alt: Maintainability 31 | 32 | .. |code-style| image:: https://img.shields.io/badge/code%20style-black-000000.svg 33 | :target: https://github.com/psf/black 34 | :alt: Code Style 35 | 36 | .. |downloads| image:: https://static.pepy.tech/badge/django-simple-history 37 | :target: https://pepy.tech/project/django-simple-history 38 | :alt: Downloads 39 | 40 | 41 | ``django-simple-history`` stores Django model state on every create/update/delete. 42 | 43 | This app supports the following combinations of Django and Python: 44 | 45 | ========== ======================== 46 | Django Python 47 | ========== ======================== 48 | 4.2 3.9, 3.10, 3.11, 3.12, 3.13 49 | 5.0 3.10, 3.11, 3.12, 3.13 50 | 5.1 3.10, 3.11, 3.12, 3.13 51 | 5.2 3.10, 3.11, 3.12, 3.13 52 | main 3.12, 3.13 53 | ========== ======================== 54 | 55 | Getting Help 56 | ------------ 57 | 58 | Documentation is available at https://django-simple-history.readthedocs.io/en/stable/ 59 | 60 | Pull requests are welcome. Read the `CONTRIBUTING`_ file for tips on 61 | submitting a pull request. 62 | 63 | .. _CONTRIBUTING: https://github.com/jazzband/django-simple-history/blob/master/CONTRIBUTING.rst 64 | 65 | License 66 | ------- 67 | 68 | This project is licensed under the 69 | `BSD 3-Clause license `_. 70 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | --- 2 | coverage: 3 | status: 4 | patch: 5 | default: 6 | informational: true 7 | project: 8 | default: 9 | informational: true 10 | 11 | ignore: 12 | - "requirements/*.txt" 13 | -------------------------------------------------------------------------------- /doc-requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==1.2.3 2 | sphinx-autobuild==0.3.0 3 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(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/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-simple-history.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-simple-history.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-simple-history" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-simple-history" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/_static/.keep -------------------------------------------------------------------------------- /docs/admin.rst: -------------------------------------------------------------------------------- 1 | Admin Integration 2 | ----------------- 3 | 4 | To allow viewing previous model versions on the Django admin site, inherit from 5 | the ``simple_history.admin.SimpleHistoryAdmin`` class when registering your 6 | model with the admin site. 7 | 8 | This will replace the history object page on the admin site and allow viewing 9 | and reverting to previous model versions. Changes made in admin change forms 10 | will also accurately note the user who made the change. 11 | 12 | .. image:: screens/1_poll_history.png 13 | 14 | Clicking on an object presents the option to revert to that version of the object. 15 | 16 | .. image:: screens/2_revert.png 17 | 18 | (The object is reverted to the selected state) 19 | 20 | .. image:: screens/3_poll_reverted.png 21 | 22 | Reversions like this are added to the history. 23 | 24 | .. image:: screens/4_history_after_poll_reverted.png 25 | 26 | An example of admin integration for the ``Poll`` and ``Choice`` models: 27 | 28 | .. code-block:: python 29 | 30 | from django.contrib import admin 31 | from simple_history.admin import SimpleHistoryAdmin 32 | from .models import Poll, Choice 33 | 34 | admin.site.register(Poll, SimpleHistoryAdmin) 35 | admin.site.register(Choice, SimpleHistoryAdmin) 36 | 37 | Changing a history-tracked model from the admin interface will automatically record the user who made the change (see :doc:`/user_tracking`). 38 | 39 | 40 | Displaying custom columns in the admin history list view 41 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 42 | 43 | By default, the history log displays one line per change containing 44 | 45 | * a link to the detail of the object at that point in time 46 | * the date and time the object was changed 47 | * a comment corresponding to the change 48 | * the author of the change 49 | 50 | You can add other columns (for example the object's status to see 51 | how it evolved) by adding a ``history_list_display`` array of fields to the 52 | admin class. 53 | 54 | .. code-block:: python 55 | 56 | from django.contrib import admin 57 | from simple_history.admin import SimpleHistoryAdmin 58 | from .models import Poll, Choice 59 | 60 | 61 | class PollHistoryAdmin(SimpleHistoryAdmin): 62 | list_display = ["id", "name", "status"] 63 | history_list_display = ["status"] 64 | search_fields = ['name', 'user__username'] 65 | history_list_per_page = 100 66 | 67 | admin.site.register(Poll, PollHistoryAdmin) 68 | admin.site.register(Choice, SimpleHistoryAdmin) 69 | 70 | 71 | .. image:: screens/5_history_list_display.png 72 | 73 | 74 | Changing the page size in the admin history list view 75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 76 | 77 | By default, the history list view of ``SimpleHistoryAdmin`` shows the last 100 records. 78 | You can change this by adding a `history_list_per_page` attribute to the admin class. 79 | 80 | 81 | .. code-block:: python 82 | 83 | from django.contrib import admin 84 | from simple_history.admin import SimpleHistoryAdmin 85 | from .models import Poll 86 | 87 | 88 | class PollHistoryAdmin(SimpleHistoryAdmin): 89 | # history_list_per_page defaults to 100 90 | history_list_per_page = 200 91 | 92 | admin.site.register(Poll, PollHistoryAdmin) 93 | 94 | 95 | Customizing the History Admin Templates 96 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 97 | 98 | If you'd like to customize the HTML of ``SimpleHistoryAdmin``'s object history pages, 99 | you can override the following attributes with the names of your own templates: 100 | 101 | - ``object_history_template``: The main object history page, which includes (inserts) 102 | ``object_history_list_template``. 103 | - ``object_history_list_template``: The table listing an object's historical records and 104 | the changes made between them. 105 | - ``object_history_form_template``: The form pre-filled with the details of an object's 106 | historical record, which also allows you to revert the object to a previous version. 107 | 108 | If you'd like to only customize certain parts of the mentioned templates, look for 109 | ``block`` template tags in the source code that you can override - like the 110 | ``history_delta_changes`` block in ``simple_history/object_history_list.html``, 111 | which lists the changes made between each historical record. 112 | 113 | Customizing Context 114 | ^^^^^^^^^^^^^^^^^^^ 115 | 116 | You can also customize the template context by overriding the following methods: 117 | 118 | - ``render_history_view()``: Called by both ``history_view()`` and 119 | ``history_form_view()`` before the templates are rendered. Customize the context by 120 | changing the ``context`` parameter. 121 | - ``history_view()``: Returns a rendered ``object_history_template``. 122 | Inject context by calling the super method with the ``extra_context`` argument. 123 | - ``get_historical_record_context_helper()``: Returns an instance of 124 | ``simple_history.template_utils.HistoricalRecordContextHelper`` that's used to format 125 | some template context for each historical record displayed through ``history_view()``. 126 | Customize the context by extending the mentioned class and overriding its methods. 127 | - ``history_form_view()``: Returns a rendered ``object_history_form_template``. 128 | Inject context by calling the super method with the ``extra_context`` argument. 129 | 130 | 131 | Disabling the option to revert an object 132 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 133 | 134 | By default, an object can be reverted to its previous version. To disable this option 135 | globally, update your settings with the following: 136 | 137 | .. code-block:: python 138 | 139 | SIMPLE_HISTORY_REVERT_DISABLED = True 140 | 141 | When ``SIMPLE_HISTORY_REVERT_DISABLED`` is set to ``True``, the revert button is removed from the form. 142 | 143 | .. image:: screens/10_revert_disabled.png 144 | 145 | Enforcing history model permissions in Admin 146 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 147 | 148 | To make the Django admin site evaluate history model permissions explicitly, 149 | update your settings with the following: 150 | 151 | .. code-block:: python 152 | 153 | SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True 154 | 155 | By default, ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``False``. 156 | When set to ``False``, permissions applied to the ``Poll`` model 157 | (from the examples above), also apply to the history model. 158 | That is, granting view and change permissions to the ``Poll`` model 159 | implicitly grants view and change permissions to the ``Poll`` history model. 160 | 161 | The user below has view and change permissions to the ``Poll`` model and the ``Poll`` 162 | history model in admin. 163 | 164 | .. code-block:: python 165 | 166 | user.user_permissions.clear() 167 | user.user_permissions.add( 168 | Permission.objects.get(codename="view_poll"), 169 | Permission.objects.get(codename="change_poll"), 170 | ) 171 | 172 | The user below has view permission to the ``Poll`` model and the ``Poll`` history model 173 | in admin. 174 | 175 | .. code-block:: python 176 | 177 | user.user_permissions.clear() 178 | user.user_permissions.add( 179 | Permission.objects.get(codename="view_poll"), 180 | ) 181 | 182 | When ``SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS`` is set to ``True``, 183 | permissions to history models are assigned and evaluated explicitly. 184 | 185 | The user below *does not have* view permission to the ``Poll`` history model in admin, 186 | even though they *have* view permission to the ``Poll`` model. 187 | 188 | .. code-block:: python 189 | 190 | # SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings 191 | user.user_permissions.clear() 192 | user.user_permissions.add( 193 | Permission.objects.get(codename="view_poll"), 194 | ) 195 | 196 | The user below has view permission to the ``Poll`` model and the ``Poll`` 197 | history model. 198 | 199 | .. code-block:: python 200 | 201 | # SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings 202 | user.user_permissions.clear() 203 | user.user_permissions.add( 204 | Permission.objects.get(codename="view_poll"), 205 | Permission.objects.get(codename="view_historicalpoll"), 206 | ) 207 | 208 | The user below has view permission to the ``Poll`` history model, but will need to 209 | access the page with a direct URL, since the ``Poll`` model will not be listed on 210 | the admin application index page, nor the ``Poll`` changelist. 211 | 212 | .. code-block:: python 213 | 214 | # SIMPLE_HISTORY_ENFORCE_HISTORY_MODEL_PERMISSIONS = True in settings 215 | user.user_permissions.clear() 216 | user.user_permissions.add( 217 | Permission.objects.get(codename="view_historicalpoll"), 218 | ) 219 | -------------------------------------------------------------------------------- /docs/history_diffing.rst: -------------------------------------------------------------------------------- 1 | History Diffing 2 | =============== 3 | 4 | When you have two instances of the same historical model 5 | (such as the ``HistoricalPoll`` example above), 6 | you can perform a diff using the ``diff_against()`` method to see what changed. 7 | This will return a ``ModelDelta`` object with the following attributes: 8 | 9 | - ``old_record`` and ``new_record``: The old and new history records 10 | - ``changed_fields``: A list of the names of all fields that were changed between 11 | ``old_record`` and ``new_record``, in alphabetical order 12 | - ``changes``: A list of ``ModelChange`` objects - one for each field in 13 | ``changed_fields``, in the same order. 14 | These objects have the following attributes: 15 | 16 | - ``field``: The name of the changed field 17 | (this name is equal to the corresponding field in ``changed_fields``) 18 | - ``old`` and ``new``: The old and new values of the changed field 19 | 20 | - For many-to-many fields, these values will be lists of dicts from the through 21 | model field names to the primary keys of the through model's related objects. 22 | The lists are sorted by the value of the many-to-many related object. 23 | 24 | This may be useful when you want to construct timelines and need to get only 25 | the model modifications. 26 | 27 | .. code-block:: python 28 | 29 | poll = Poll.objects.create(question="what's up?") 30 | poll.question = "what's up, man?" 31 | poll.save() 32 | 33 | new_record, old_record = poll.history.all() 34 | delta = new_record.diff_against(old_record) 35 | for change in delta.changes: 36 | print(f"'{change.field}' changed from '{change.old}' to '{change.new}'") 37 | 38 | # Output: 39 | # 'question' changed from 'what's up?' to 'what's up, man?' 40 | 41 | ``diff_against()`` also accepts the following additional arguments: 42 | 43 | - ``excluded_fields`` and ``included_fields``: These can be used to either explicitly 44 | exclude or include fields from being diffed, respectively. 45 | - ``foreign_keys_are_objs``: 46 | 47 | - If ``False`` (default): The diff will only contain the raw primary keys of any 48 | ``ForeignKey`` fields. 49 | - If ``True``: The diff will contain the actual related model objects instead of just 50 | the primary keys. 51 | Deleted related objects (both foreign key objects and many-to-many objects) 52 | will be instances of ``DeletedObject``, which only contain a ``model`` field with a 53 | reference to the deleted object's model, as well as a ``pk`` field with the value of 54 | the deleted object's primary key. 55 | 56 | Note that this will add extra database queries for each related field that's been 57 | changed - as long as the related objects have not been prefetched 58 | (using e.g. ``select_related()``). 59 | 60 | A couple examples showing the difference: 61 | 62 | .. code-block:: python 63 | 64 | # --- Effect on foreign key fields --- 65 | 66 | whats_up = Poll.objects.create(pk=15, name="what's up?") 67 | still_around = Poll.objects.create(pk=31, name="still around?") 68 | 69 | choice = Choice.objects.create(poll=whats_up) 70 | choice.poll = still_around 71 | choice.save() 72 | 73 | new, old = choice.history.all() 74 | 75 | default_delta = new.diff_against(old) 76 | # Printing the changes of `default_delta` will output: 77 | # 'poll' changed from '15' to '31' 78 | 79 | delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) 80 | # Printing the changes of `delta_with_objs` will output: 81 | # 'poll' changed from 'what's up?' to 'still around?' 82 | 83 | # Deleting all the polls: 84 | Poll.objects.all().delete() 85 | delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) 86 | # Printing the changes of `delta_with_objs` will now output: 87 | # 'poll' changed from 'Deleted poll (pk=15)' to 'Deleted poll (pk=31)' 88 | 89 | 90 | # --- Effect on many-to-many fields --- 91 | 92 | informal = Category.objects.create(pk=63, name="informal questions") 93 | whats_up.categories.add(informal) 94 | 95 | new = whats_up.history.latest() 96 | old = new.prev_record 97 | 98 | default_delta = new.diff_against(old) 99 | # Printing the changes of `default_delta` will output: 100 | # 'categories' changed from [] to [{'poll': 15, 'category': 63}] 101 | 102 | delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) 103 | # Printing the changes of `delta_with_objs` will output: 104 | # 'categories' changed from [] to [{'poll': , 'category': }] 105 | 106 | # Deleting all the categories: 107 | Category.objects.all().delete() 108 | delta_with_objs = new.diff_against(old, foreign_keys_are_objs=True) 109 | # Printing the changes of `delta_with_objs` will now output: 110 | # 'categories' changed from [] to [{'poll': , 'category': DeletedObject(model=, pk=63)}] 111 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | django-simple-history 2 | ===================== 3 | 4 | .. image:: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml/badge.svg 5 | :target: https://github.com/jazzband/django-simple-history/actions/workflows/test.yml 6 | :alt: Build Status 7 | 8 | .. image:: https://readthedocs.org/projects/django-simple-history/badge/?version=latest 9 | :target: https://django-simple-history.readthedocs.io/en/latest/?badge=latest 10 | :alt: Documentation Status 11 | 12 | .. image:: https://img.shields.io/codecov/c/github/jazzband/django-simple-history/master.svg 13 | :target: https://app.codecov.io/github/jazzband/django-simple-history?branch=master 14 | :alt: Test Coverage 15 | 16 | .. image:: https://img.shields.io/pypi/v/django-simple-history.svg 17 | :target: https://pypi.org/project/django-simple-history/ 18 | :alt: PyPI Version 19 | 20 | .. image:: https://api.codeclimate.com/v1/badges/66cfd94e2db991f2d28a/maintainability 21 | :target: https://codeclimate.com/github/jazzband/django-simple-history/maintainability 22 | :alt: Maintainability 23 | 24 | .. image:: https://static.pepy.tech/badge/django-simple-history 25 | :target: https://pepy.tech/project/django-simple-history 26 | :alt: Downloads 27 | 28 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 29 | :target: https://github.com/psf/black 30 | :alt: Code Style 31 | 32 | .. image:: https://jazzband.co/static/img/badge.svg 33 | :target: https://jazzband.co/ 34 | :alt: Jazzband 35 | 36 | 37 | django-simple-history stores Django model state on every create/update/delete. 38 | 39 | This app supports the following combinations of Django and Python: 40 | 41 | ========== ======================= 42 | Django Python 43 | ========== ======================= 44 | 4.2 3.9, 3.10, 3.11, 3.12, 3.13 45 | 5.0 3.10, 3.11, 3.12, 3.13 46 | 5.1 3.10, 3.11, 3.12, 3.13 47 | 5.2 3.10, 3.11, 3.12, 3.13 48 | main 3.12, 3.13 49 | ========== ======================= 50 | 51 | Contribute 52 | ---------- 53 | 54 | - Issue Tracker: https://github.com/jazzband/django-simple-history/issues 55 | - Source Code: https://github.com/jazzband/django-simple-history 56 | 57 | Pull requests are welcome. 58 | 59 | 60 | Documentation 61 | ------------- 62 | 63 | .. toctree:: 64 | :maxdepth: 2 65 | 66 | quick_start 67 | querying_history 68 | admin 69 | historical_model 70 | user_tracking 71 | signals 72 | history_diffing 73 | multiple_dbs 74 | utils 75 | common_issues 76 | 77 | 78 | .. include:: ../CHANGES.rst 79 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-simple-history.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-simple-history.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/multiple_dbs.rst: -------------------------------------------------------------------------------- 1 | Multiple databases 2 | ================== 3 | 4 | Interacting with Multiple Databases 5 | ----------------------------------- 6 | 7 | `django-simple-history` follows the Django conventions for interacting with multiple databases. 8 | 9 | .. code-block:: python 10 | 11 | >>> # This will create a new historical record on the 'other' database. 12 | >>> poll = Poll.objects.using('other').create(question='Question 1') 13 | 14 | >>> # This will also create a new historical record on the 'other' database. 15 | >>> poll.save(using='other') 16 | 17 | 18 | When interacting with ``QuerySets``, use ``using()``: 19 | 20 | .. code-block:: python 21 | 22 | >>> # This will return a QuerySet from the 'other' database. 23 | Poll.history.using('other').all() 24 | 25 | When interacting with manager methods, use ``db_manager()``: 26 | 27 | .. code-block:: python 28 | 29 | >>> # This will call a manager method on the 'other' database. 30 | >>> poll.history.db_manager('other').as_of(datetime(2010, 10, 25, 18, 4, 0)) 31 | 32 | See the Django documentation for more information on how to interact with multiple databases. 33 | 34 | Tracking User in a Separate Database 35 | ------------------------------------ 36 | 37 | When using ``django-simple-history`` in app with multiple database, you may run into 38 | an issue where you want to track the history on a table that lives in a separate 39 | database to your user model. Since Django does not support cross-database relations, 40 | you will have to manually track the ``history_user`` using an explicit ID. The full 41 | documentation on this feature is in :ref:`Manually Track User Model`. 42 | 43 | Tracking History Separate from the Base Model 44 | --------------------------------------------- 45 | You can choose whether or not to track models' history in the same database by 46 | setting the flag `use_base_model_db`. 47 | 48 | .. code-block:: python 49 | 50 | class MyModel(models.Model): 51 | ... 52 | history = HistoricalRecords(use_base_model_db=False) 53 | 54 | If set to `True`, migrations and audit 55 | events will be sent to the same database as the base model. If `False`, they 56 | will be sent to the place specified by the database router. The default value is `False`. 57 | -------------------------------------------------------------------------------- /docs/quick_start.rst: -------------------------------------------------------------------------------- 1 | Quick Start 2 | =========== 3 | 4 | Install 5 | ------- 6 | 7 | Install from `PyPI`_ with ``pip``: 8 | 9 | .. code-block:: bash 10 | 11 | $ pip install django-simple-history 12 | 13 | .. _pypi: https://pypi.python.org/pypi/django-simple-history/ 14 | 15 | 16 | Configure 17 | --------- 18 | 19 | Settings 20 | ~~~~~~~~ 21 | 22 | Add ``simple_history`` to your ``INSTALLED_APPS`` 23 | 24 | .. code-block:: python 25 | 26 | INSTALLED_APPS = [ 27 | # ... 28 | 'simple_history', 29 | ] 30 | 31 | The historical models can track who made each change. To populate the 32 | history user automatically you can add ``HistoryRequestMiddleware`` to your Django 33 | settings: 34 | 35 | .. code-block:: python 36 | 37 | MIDDLEWARE = [ 38 | # ... 39 | 'simple_history.middleware.HistoryRequestMiddleware', 40 | ] 41 | 42 | If you do not want to use the middleware, you can explicitly indicate 43 | the user making the change as documented in :doc:`/user_tracking`. 44 | 45 | 46 | Track History 47 | ~~~~~~~~~~~~~ 48 | 49 | To track history for a model, create an instance of 50 | ``simple_history.models.HistoricalRecords`` on the model. 51 | 52 | An example for tracking changes on the ``Poll`` and ``Choice`` models in the 53 | Django tutorial: 54 | 55 | .. code-block:: python 56 | 57 | from django.db import models 58 | from simple_history.models import HistoricalRecords 59 | 60 | class Poll(models.Model): 61 | question = models.CharField(max_length=200) 62 | pub_date = models.DateTimeField('date published') 63 | history = HistoricalRecords() 64 | 65 | class Choice(models.Model): 66 | poll = models.ForeignKey(Poll) 67 | choice_text = models.CharField(max_length=200) 68 | votes = models.IntegerField(default=0) 69 | history = HistoricalRecords() 70 | 71 | Now all changes to ``Poll`` and ``Choice`` model instances will be tracked in 72 | the database. 73 | 74 | Track History for a Third-Party Model 75 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 76 | 77 | To track history for a model you didn't create, use the 78 | ``simple_history.register`` function. You can use this to track models from 79 | third-party apps you don't have control over. Here's an example of using 80 | ``simple_history.register`` to history-track the ``User`` model from the 81 | ``django.contrib.auth`` app: 82 | 83 | .. code-block:: python 84 | 85 | from simple_history import register 86 | from django.contrib.auth.models import User 87 | 88 | register(User) 89 | 90 | If you want to separate the migrations of the historical model into an app other than 91 | the third-party model's app, you can set the ``app`` parameter in 92 | ``register``. For instance, if you want the migrations to live in the migrations 93 | folder of the package you register the model in, you could do: 94 | 95 | .. code-block:: python 96 | 97 | register(User, app=__package__) 98 | 99 | 100 | Run Migrations 101 | -------------- 102 | 103 | With your model changes in place, create and apply the database migrations: 104 | 105 | .. code-block:: bash 106 | 107 | $ python manage.py makemigrations 108 | $ python manage.py migrate 109 | 110 | Existing Projects 111 | ~~~~~~~~~~~~~~~~~ 112 | 113 | For existing projects, you can call the populate command to generate an 114 | initial change for preexisting model instances: 115 | 116 | .. code-block:: bash 117 | 118 | $ python manage.py populate_history --auto 119 | 120 | By default, history rows are inserted in batches of 200. This can be changed if needed for large tables 121 | by using the ``--batchsize`` option, for example ``--batchsize 500``. 122 | 123 | What Now? 124 | --------- 125 | 126 | By adding ``HistoricalRecords`` to a model or registering a model using ``register``, 127 | you automatically start tracking any create, update, or delete that occurs on that model. 128 | Now you can :doc:`query the history programmatically ` 129 | and :doc:`view the history in Django admin `. 130 | 131 | What is ``django-simple-history`` Doing Behind the Scenes? 132 | ---------------------------------------------------------- 133 | 134 | If you tried the code `above`_ and ran the migrations on it, you'll see the following 135 | tables in your database: 136 | 137 | - ``app_choice`` 138 | - ``app_historicalchoice`` 139 | - ``app_historicalpoll`` 140 | - ``app_poll`` 141 | 142 | .. _above: `Track History`_ 143 | 144 | The two extra tables with ``historical`` prepend to their names are tables created 145 | by ``django-simple-history``. These tables store every change that you make to their 146 | respective base tables. Every time a create, update, or delete occurs on ``Choice`` or 147 | ``Poll`` a new row is created in the historical table for that model including all of 148 | the fields in the instance of the base model, as well as other metadata: 149 | 150 | - ``history_user``: the user that made the create/update/delete 151 | - ``history_date``: the ``datetime`` at which the create/update/delete occurred 152 | - ``history_change_reason``: the reason the create/update/delete occurred (null by default) 153 | - ``history_id``: the primary key for the historical table (note the base table's 154 | primary key is not unique on the historical table since there are multiple versions of it 155 | on the historical table) 156 | - ``history_type``: ``+`` for create, ``~`` for update, and ``-`` for delete 157 | 158 | 159 | Now try saving an instance of ``Choice`` or ``Poll``. Check the historical table 160 | to see that the history is being tracked. 161 | -------------------------------------------------------------------------------- /docs/screens/10_revert_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/screens/10_revert_disabled.png -------------------------------------------------------------------------------- /docs/screens/1_poll_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/screens/1_poll_history.png -------------------------------------------------------------------------------- /docs/screens/2_revert.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/screens/2_revert.png -------------------------------------------------------------------------------- /docs/screens/3_poll_reverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/screens/3_poll_reverted.png -------------------------------------------------------------------------------- /docs/screens/4_history_after_poll_reverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/screens/4_history_after_poll_reverted.png -------------------------------------------------------------------------------- /docs/screens/5_history_list_display.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/docs/screens/5_history_list_display.png -------------------------------------------------------------------------------- /docs/signals.rst: -------------------------------------------------------------------------------- 1 | Signals 2 | ------------------------------------ 3 | `django-simple-history` includes signals that help you provide custom behavior when 4 | saving a historical record. Arguments passed to the signals include the following: 5 | 6 | .. glossary:: 7 | instance 8 | The source model instance being saved 9 | 10 | history_instance 11 | The corresponding history record 12 | 13 | history_date 14 | Datetime of the history record's creation 15 | 16 | history_change_reason 17 | Freetext description of the reason for the change 18 | 19 | history_user 20 | The user that instigated the change 21 | 22 | using 23 | The database alias being used 24 | 25 | For Many To Many signals you've got the following : 26 | 27 | .. glossary:: 28 | instance 29 | The source model instance being saved 30 | 31 | history_instance 32 | The corresponding history record 33 | 34 | rows (for pre_create) 35 | The elements to be bulk inserted into the m2m table 36 | 37 | created_rows (for post_create) 38 | The created elements into the m2m table 39 | 40 | field 41 | The recorded field object 42 | 43 | To connect the signals to your callbacks, you can use the ``@receiver`` decorator: 44 | 45 | .. code-block:: python 46 | 47 | from django.dispatch import receiver 48 | from simple_history.signals import ( 49 | pre_create_historical_record, 50 | post_create_historical_record, 51 | pre_create_historical_m2m_records, 52 | post_create_historical_m2m_records, 53 | ) 54 | 55 | @receiver(pre_create_historical_record) 56 | def pre_create_historical_record_callback(sender, **kwargs): 57 | print("Sent before saving historical record") 58 | 59 | @receiver(post_create_historical_record) 60 | def post_create_historical_record_callback(sender, **kwargs): 61 | print("Sent after saving historical record") 62 | 63 | @receiver(pre_create_historical_m2m_records) 64 | def pre_create_historical_m2m_records_callback(sender, **kwargs): 65 | print("Sent before saving many to many field on historical record") 66 | 67 | @receiver(post_create_historical_m2m_records) 68 | def post_create_historical_m2m_records_callback(sender, **kwargs): 69 | print("Sent after saving many to many field on historical record") 70 | -------------------------------------------------------------------------------- /docs/user_tracking.rst: -------------------------------------------------------------------------------- 1 | User Tracking 2 | ============= 3 | 4 | 5 | Recording Which User Changed a Model 6 | ------------------------------------ 7 | There are four documented ways to attach users to a tracked change: 8 | 9 | 1. Use the ``HistoryRequestMiddleware``. The middleware sets the 10 | User instance that made the request as the ``history_user`` on the history 11 | table. 12 | 13 | 2. Use ``simple_history.admin.SimpleHistoryAdmin``. Under the hood, 14 | ``SimpleHistoryAdmin`` actually sets the ``_history_user`` on the object to 15 | attach the user to the tracked change by overriding the `save_model` method. 16 | 17 | 3. Assign a user to the ``_history_user`` attribute of the object as described 18 | in the `_history_user section`_. 19 | 20 | 4. Track the user using an explicit ``history_user_id``, which is described in 21 | `Manually Track User Model`_. This method is particularly useful when using multiple 22 | databases (where your user model lives in a separate database to your historical model), 23 | or when using a user that doesn't live within the Django app (i.e. a user model retrieved 24 | from an API). 25 | 26 | .. _`_history_user section`: 27 | 28 | Using ``_history_user`` to Record Which User Changed a Model 29 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 30 | 31 | To denote which user changed a model, assign a ``_history_user`` attribute on 32 | your model. 33 | 34 | For example if you have a ``changed_by`` field on your model that records which 35 | user last changed the model, you could create a ``_history_user`` property 36 | referencing the ``changed_by`` field: 37 | 38 | .. code-block:: python 39 | 40 | from django.db import models 41 | from simple_history.models import HistoricalRecords 42 | 43 | class Poll(models.Model): 44 | question = models.CharField(max_length=200) 45 | pub_date = models.DateTimeField('date published') 46 | changed_by = models.ForeignKey('auth.User') 47 | history = HistoricalRecords() 48 | 49 | @property 50 | def _history_user(self): 51 | return self.changed_by 52 | 53 | @_history_user.setter 54 | def _history_user(self, value): 55 | self.changed_by = value 56 | 57 | Admin integration requires that you use a ``_history_user.setter`` attribute with 58 | your custom ``_history_user`` property (see :doc:`/admin`). 59 | 60 | Another option for identifying the change user is by providing a function via ``get_user``. 61 | If provided it will be called every time that the ``history_user`` needs to be 62 | identified with the following key word arguments: 63 | 64 | * ``instance``: The current instance being modified 65 | * ``request``: If using the middleware the current request object will be provided if they are authenticated. 66 | 67 | This is very helpful when using ``register``: 68 | 69 | .. code-block:: python 70 | 71 | from django.db import models 72 | from simple_history.models import HistoricalRecords 73 | 74 | class Poll(models.Model): 75 | question = models.CharField(max_length=200) 76 | pub_date = models.DateTimeField('date published') 77 | changed_by = models.ForeignKey('auth.User') 78 | 79 | 80 | def get_poll_user(instance, **kwargs): 81 | return instance.changed_by 82 | 83 | register(Poll, get_user=get_poll_user) 84 | 85 | 86 | .. _`Manually Track User Model`: 87 | 88 | 89 | Manually Track User Model 90 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 91 | 92 | Although ``django-simple-history`` tracks the ``history_user`` (the user who changed the 93 | model) using a django foreign key, there are instances where we might want to track this 94 | user but cannot use a Django foreign key. 95 | 96 | **Note:** If you want to track a custom user model that is still accessible through a 97 | Django foreign key, refer to `Change User Model`_. 98 | 99 | The two most common cases where this feature will be helpful are: 100 | 101 | 1. You are working on a Django app with multiple databases, and your history table 102 | is in a separate database from the user table. 103 | 104 | 2. The user model that you want to use for ``history_user`` does not live within the 105 | Django app, but is only accessible elsewhere (i.e. through an API call). 106 | 107 | There are three parameters to ``HistoricalRecords`` or ``register`` that facilitate 108 | the ability to manually track a ``history_user``. 109 | 110 | 111 | :history_user_id_field: An instance of field (i.e. ``IntegerField(null=True)`` or 112 | ``UUIDField(default=uuid.uuid4, null=True)`` that will uniquely identify your user 113 | object. This is generally the field type of the primary key on your user object. 114 | 115 | :history_user_getter: *optional*. A callable that takes the historical instance of the 116 | model and returns the ``history_user`` object. The default getter is shown below: 117 | 118 | .. code-block:: python 119 | 120 | def _history_user_getter(historical_instance): 121 | if historical_instance.history_user_id is None: 122 | return None 123 | User = get_user_model() 124 | try: 125 | return User.objects.get(pk=historical_instance.history_user_id) 126 | except User.DoesNotExist: 127 | return None 128 | 129 | 130 | :history_user_setter: *optional*. A callable that takes the historical instance and 131 | the user instance, and sets ``history_user_id`` on the historical instance. The 132 | default setter is shown below: 133 | 134 | .. code-block:: python 135 | 136 | def _history_user_setter(historical_instance, user): 137 | if user is not None: 138 | historical_instance.history_user_id = user.pk 139 | 140 | 141 | .. _`Change User Model`: 142 | 143 | Change User Model 144 | ----------------- 145 | 146 | If you need to use a different user model then ``settings.AUTH_USER_MODEL``, 147 | pass in the required model to ``user_model``. Doing this requires ``_history_user`` 148 | or ``get_user`` is provided as detailed above. 149 | 150 | .. code-block:: python 151 | 152 | from django.db import models 153 | from simple_history.models import HistoricalRecords 154 | 155 | class PollUser(models.Model): 156 | user_id = models.ForeignKey('auth.User') 157 | 158 | 159 | # Only PollUsers should be modifying a Poll 160 | class Poll(models.Model): 161 | question = models.CharField(max_length=200) 162 | pub_date = models.DateTimeField('date published') 163 | changed_by = models.ForeignKey(PollUser) 164 | history = HistoricalRecords(user_model=PollUser) 165 | 166 | @property 167 | def _history_user(self): 168 | return self.changed_by 169 | 170 | @_history_user.setter 171 | def _history_user(self, value): 172 | self.changed_by = value 173 | -------------------------------------------------------------------------------- /docs/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | 5 | clean_duplicate_history 6 | ----------------------- 7 | 8 | For performance reasons, ``django-simple-history`` always creates an ``HistoricalRecord`` 9 | when ``Model.save()`` is called regardless of data having actually changed. 10 | If you find yourself with a lot of history duplicates you can schedule the 11 | ``clean_duplicate_history`` command 12 | 13 | .. code-block:: bash 14 | 15 | $ python manage.py clean_duplicate_history --auto 16 | 17 | You can use ``--auto`` to clean up duplicates for every model 18 | with ``HistoricalRecords`` or enumerate specific models as args. 19 | There is also ``-m/--minutes`` to specify how many minutes to go 20 | back in history while searching (default checks whole history), 21 | so you can schedule, for instance, an hourly cronjob such as 22 | 23 | .. code-block:: bash 24 | 25 | $ python manage.py clean_duplicate_history -m 60 --auto 26 | 27 | You can also use ``--excluded_fields`` to provide a list of fields to be excluded 28 | from the duplicate check 29 | 30 | .. code-block:: bash 31 | 32 | $ python manage.py clean_duplicate_history --auto --excluded_fields field1 field2 33 | 34 | You can use Django's base manager to perform the cleanup over all records, 35 | including those that would otherwise be filtered or modified by a 36 | custom manager, by using the ``--base-manager`` flag. 37 | 38 | .. code-block:: bash 39 | 40 | $ python manage.py clean_duplicate_history --auto --base-manager 41 | 42 | clean_old_history 43 | ----------------------- 44 | 45 | You may want to remove historical records that have existed for a certain amount of time. 46 | 47 | If you find yourself with a lot of old history you can schedule the 48 | ``clean_old_history`` command 49 | 50 | .. code-block:: bash 51 | 52 | $ python manage.py clean_old_history --auto 53 | 54 | You can use ``--auto`` to remove old historical entries 55 | with ``HistoricalRecords`` or enumerate specific models as args. 56 | You may also specify a ``--days`` parameter, which indicates how many 57 | days of records you want to keep. The default it 30 days, meaning that 58 | all records older than 30 days would be removed. 59 | 60 | .. code-block:: bash 61 | 62 | $ python manage.py clean_old_history --days 60 --auto 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = [ 4 | "hatch-fancy-pypi-readme", 5 | "hatch-vcs", 6 | "hatchling", 7 | ] 8 | 9 | [project] 10 | name = "django-simple-history" 11 | description = "Store model history and view/revert changes from admin site." 12 | maintainers = [ 13 | { name = "Trey Hunner" }, 14 | ] 15 | authors = [ 16 | { name = "Corey Bertram", email = "corey@qr7.com" }, 17 | ] 18 | requires-python = ">=3.9" 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Framework :: Django :: 4.2", 24 | "Framework :: Django :: 5.0", 25 | "Framework :: Django :: 5.1", 26 | "Framework :: Django :: 5.2", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: BSD License", 29 | "Programming Language :: Python", 30 | "Programming Language :: Python :: 3 :: Only", 31 | "Programming Language :: Python :: 3.9", 32 | "Programming Language :: Python :: 3.10", 33 | "Programming Language :: Python :: 3.11", 34 | "Programming Language :: Python :: 3.12", 35 | "Programming Language :: Python :: 3.13", 36 | ] 37 | dynamic = [ 38 | "readme", 39 | "version", 40 | ] 41 | dependencies = [ 42 | "django>=4.2", 43 | ] 44 | urls.Changelog = "https://github.com/jazzband/django-simple-history/blob/master/CHANGES.rst" 45 | urls.Documentation = "https://django-simple-history.readthedocs.io/en/stable/" 46 | urls.Homepage = "https://github.com/jazzband/django-simple-history" 47 | urls.Source = "https://github.com/jazzband/django-simple-history" 48 | urls.Tracker = "https://github.com/jazzband/django-simple-history/issues" 49 | 50 | [tool.hatch.version] 51 | source = "vcs" 52 | fallback-version = "0.0.0" 53 | 54 | [tool.hatch.version.raw-options] 55 | version_scheme = "no-guess-dev" 56 | local_scheme = "node-and-date" 57 | 58 | [tool.hatch.build.targets.wheel] 59 | # Jazzband's release process is limited to 2.2 metadata 60 | # - see https://github.com/jazzband/help/issues/360 61 | core-metadata-version = "2.2" 62 | packages = [ 63 | "simple_history", 64 | ] 65 | exclude = [ 66 | "simple_history/registry_tests", 67 | "simple_history/tests", 68 | ] 69 | 70 | [tool.hatch.build.targets.sdist] 71 | # Jazzband's release process is limited to 2.2 metadata 72 | # - see https://github.com/jazzband/help/issues/360 73 | core-metadata-version = "2.2" 74 | 75 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 76 | content-type = "text/x-rst" 77 | # (Preview the generated readme by installing `hatch` and running 78 | # `hatch project metadata readme` - see 79 | # https://github.com/hynek/hatch-fancy-pypi-readme/blob/24.1.0/README.md#cli-interface) 80 | fragments = [ 81 | { path = "README.rst", start-after = ".. Start of PyPI readme\n\n" }, 82 | { text = "\n====\n\nChangelog\n=========\n\n" }, 83 | # Only include the first title after "Unreleased" - as well as the rest of the file 84 | { path = "CHANGES.rst", pattern = "\nUnreleased\n-{4,}\n(?:.*?)\n([^\n]+\n-{4,}\n.*)" }, 85 | ] 86 | 87 | [tool.black] 88 | line-length = 88 89 | target-version = [ 90 | "py39", 91 | ] 92 | 93 | [tool.isort] 94 | profile = "black" 95 | py_version = "39" 96 | 97 | [tool.coverage.run] 98 | parallel = true 99 | branch = true 100 | source = [ 101 | "simple_history", 102 | ] 103 | 104 | [tool.coverage.paths] 105 | source = [ 106 | "simple_history", 107 | ".tox/*/site-packages", 108 | ] 109 | 110 | [tool.coverage.report] 111 | show_missing = true 112 | skip_covered = true 113 | omit = [ 114 | "requirements/*", 115 | ] 116 | -------------------------------------------------------------------------------- /requirements/coverage.txt: -------------------------------------------------------------------------------- 1 | coverage==7.8.0 2 | toml==0.10.2 3 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx==8.2.3 2 | sphinx-rtd-theme==3.0.2 3 | -------------------------------------------------------------------------------- /requirements/lint.txt: -------------------------------------------------------------------------------- 1 | black==25.1.0 2 | flake8==7.2.0 3 | isort==6.0.1 4 | -------------------------------------------------------------------------------- /requirements/mysql.txt: -------------------------------------------------------------------------------- 1 | mysqlclient==2.2.7 2 | -------------------------------------------------------------------------------- /requirements/postgres.txt: -------------------------------------------------------------------------------- 1 | psycopg[binary]==3.2.9 2 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | -r ./coverage.txt 2 | -------------------------------------------------------------------------------- /requirements/tox.txt: -------------------------------------------------------------------------------- 1 | -r ./coverage.txt 2 | tox==4.26.0 3 | tox-gh-actions==3.3.0 4 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from argparse import ArgumentParser 4 | from os.path import abspath, dirname, join 5 | from shutil import rmtree 6 | 7 | import django 8 | from django.conf import settings 9 | from django.test.runner import DiscoverRunner 10 | 11 | sys.path.insert(0, abspath(dirname(__file__))) 12 | 13 | media_root = join(abspath(dirname(__file__)), "test_files") 14 | rmtree(media_root, ignore_errors=True) 15 | 16 | installed_apps = [ 17 | "simple_history.tests", 18 | "simple_history.tests.custom_user", 19 | "simple_history.tests.external", 20 | "simple_history.registry_tests.migration_test_app", 21 | "simple_history", 22 | "django.contrib.contenttypes", 23 | "django.contrib.auth", 24 | "django.contrib.sessions", 25 | "django.contrib.admin", 26 | "django.contrib.messages", 27 | ] 28 | 29 | 30 | class DisableMigrations: 31 | def __contains__(self, item): 32 | return True 33 | 34 | def __getitem__(self, item): 35 | return None 36 | 37 | 38 | DATABASE_NAME_TO_DATABASE_SETTINGS = { 39 | "sqlite3": { 40 | "default": { 41 | "ENGINE": "django.db.backends.sqlite3", 42 | }, 43 | "other": {"ENGINE": "django.db.backends.sqlite3"}, 44 | }, 45 | "postgres": { 46 | "default": { 47 | "ENGINE": "django.db.backends.postgresql", 48 | "NAME": "test", 49 | "USER": "postgres", 50 | "PASSWORD": "postgres", 51 | "HOST": "127.0.0.1", 52 | "PORT": 5432, 53 | }, 54 | "other": { 55 | "ENGINE": "django.db.backends.postgresql", 56 | "NAME": "other", 57 | "USER": "postgres", 58 | "PASSWORD": "postgres", 59 | "HOST": "127.0.0.1", 60 | "PORT": 5432, 61 | }, 62 | }, 63 | "mysql": { 64 | "default": { 65 | "ENGINE": "django.db.backends.mysql", 66 | "NAME": "test", 67 | "USER": "root", 68 | "PASSWORD": "mysql", 69 | "HOST": "127.0.0.1", 70 | "PORT": 3306, 71 | }, 72 | "other": { 73 | "ENGINE": "django.db.backends.mysql", 74 | "NAME": "other", 75 | "USER": "root", 76 | "PASSWORD": "mysql", 77 | "HOST": "127.0.0.1", 78 | "PORT": 3306, 79 | }, 80 | }, 81 | "mariadb": { 82 | "default": { 83 | "ENGINE": "django.db.backends.mysql", 84 | "NAME": "test", 85 | "USER": "root", 86 | "PASSWORD": "mariadb", 87 | "HOST": "127.0.0.1", 88 | "PORT": 3307, 89 | }, 90 | "other": { 91 | "ENGINE": "django.db.backends.mysql", 92 | "NAME": "other", 93 | "USER": "root", 94 | "PASSWORD": "mariadb", 95 | "HOST": "127.0.0.1", 96 | "PORT": 3307, 97 | }, 98 | }, 99 | } 100 | DEFAULT_DATABASE_NAME = "sqlite3" 101 | 102 | 103 | DEFAULT_SETTINGS = dict( # nosec 104 | SECRET_KEY="not a secret", 105 | ALLOWED_HOSTS=["localhost"], 106 | AUTH_USER_MODEL="custom_user.CustomUser", 107 | ROOT_URLCONF="simple_history.tests.urls", 108 | MEDIA_ROOT=media_root, 109 | STATIC_URL="/static/", 110 | INSTALLED_APPS=installed_apps, 111 | LOGGING={ 112 | "version": 1, 113 | "disable_existing_loggers": True, 114 | "handlers": { 115 | "console": { 116 | "class": "logging.StreamHandler", 117 | }, 118 | }, 119 | "root": { 120 | "handlers": ["console"], 121 | "level": "INFO", 122 | }, 123 | }, 124 | MIGRATION_MODULES=DisableMigrations(), 125 | TEMPLATES=[ 126 | { 127 | "BACKEND": "django.template.backends.django.DjangoTemplates", 128 | "APP_DIRS": True, 129 | "OPTIONS": { 130 | "context_processors": [ 131 | "django.template.context_processors.request", 132 | "django.contrib.auth.context_processors.auth", 133 | "django.contrib.messages.context_processors.messages", 134 | ] 135 | }, 136 | } 137 | ], 138 | STORAGES={ 139 | "default": { 140 | # Speeds up tests and prevents locally storing files created through them 141 | "BACKEND": "django.core.files.storage.InMemoryStorage", 142 | }, 143 | }, 144 | DEFAULT_AUTO_FIELD="django.db.models.AutoField", 145 | USE_TZ=False, 146 | ) 147 | MIDDLEWARE = [ 148 | "django.contrib.sessions.middleware.SessionMiddleware", 149 | "django.contrib.auth.middleware.AuthenticationMiddleware", 150 | "django.contrib.messages.middleware.MessageMiddleware", 151 | ] 152 | 153 | DEFAULT_SETTINGS["MIDDLEWARE"] = MIDDLEWARE 154 | 155 | 156 | def get_default_settings(*, database_name=DEFAULT_DATABASE_NAME): 157 | return { 158 | **DEFAULT_SETTINGS, 159 | "DATABASES": DATABASE_NAME_TO_DATABASE_SETTINGS[database_name], 160 | } 161 | 162 | 163 | def main(): 164 | parser = ArgumentParser(description="Run package tests.") 165 | parser.add_argument( 166 | "--database", action="store", nargs="?", default=DEFAULT_DATABASE_NAME 167 | ) 168 | parser.add_argument("--failfast", action="store_true") 169 | parser.add_argument("--pdb", action="store_true") 170 | parser.add_argument("--tag", action="append", nargs="?") 171 | namespace = parser.parse_args() 172 | if not settings.configured: 173 | default_settings = get_default_settings(database_name=namespace.database) 174 | settings.configure(**default_settings) 175 | 176 | django.setup() 177 | 178 | tags = namespace.tag 179 | failures = DiscoverRunner( 180 | failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags 181 | ).run_tests(["simple_history.tests"]) 182 | failures |= DiscoverRunner( 183 | failfast=bool(namespace.failfast), pdb=bool(namespace.pdb), tags=tags 184 | ).run_tests(["simple_history.registry_tests"]) 185 | sys.exit(failures) 186 | 187 | 188 | if __name__ == "__main__": 189 | main() 190 | -------------------------------------------------------------------------------- /simple_history/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib import metadata 2 | 3 | __version__ = metadata.version("django-simple-history") 4 | 5 | 6 | def register( 7 | model, 8 | app=None, 9 | manager_name="history", 10 | records_class=None, 11 | table_name=None, 12 | **records_config, 13 | ): 14 | """ 15 | Create historical model for `model` and attach history manager to `model`. 16 | 17 | Keyword arguments: 18 | app -- App to install historical model into (defaults to model.__module__) 19 | manager_name -- class attribute name to use for historical manager 20 | records_class -- class to use for history relation (defaults to 21 | HistoricalRecords) 22 | table_name -- Custom name for history table (defaults to 23 | 'APPNAME_historicalMODELNAME') 24 | 25 | This method should be used as an alternative to attaching an 26 | `HistoricalManager` instance directly to `model`. 27 | """ 28 | from . import models 29 | 30 | if records_class is None: 31 | records_class = models.HistoricalRecords 32 | 33 | records = records_class(**records_config) 34 | records.manager_name = manager_name 35 | records.table_name = table_name 36 | records.module = app and ("%s.models" % app) or model.__module__ 37 | records.cls = model 38 | records.add_extra_methods(model) 39 | records.finalize(model) 40 | -------------------------------------------------------------------------------- /simple_history/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | django-simple-history exceptions and warnings classes. 3 | """ 4 | 5 | 6 | class MultipleRegistrationsError(Exception): 7 | """The model has been registered to have history tracking more than once""" 8 | 9 | pass 10 | 11 | 12 | class NotHistoricalModelError(TypeError): 13 | """No related history model found.""" 14 | 15 | pass 16 | 17 | 18 | class RelatedNameConflictError(Exception): 19 | """Related name conflicting with history manager""" 20 | 21 | pass 22 | 23 | 24 | class AlternativeManagerError(Exception): 25 | """Manager does not belong to model""" 26 | 27 | pass 28 | -------------------------------------------------------------------------------- /simple_history/locale/ar/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/ar/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/ar/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-11-08 11:30+0300\n" 11 | "PO-Revision-Date: 2022-11-08 13:54+0300\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: ar\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " 19 | "&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" 20 | "X-Generator: Poedit 2.4.2\n" 21 | 22 | #: simple_history/admin.py:102 23 | #, python-format 24 | msgid "View history: %s" 25 | msgstr "عرض سجل تغيرات: %s" 26 | 27 | #: simple_history/admin.py:104 28 | #, python-format 29 | msgid "Change history: %s" 30 | msgstr "تعديل سجل تغيرات: %s" 31 | 32 | #: simple_history/admin.py:110 33 | #, python-format 34 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 35 | msgstr "تم تعديل %(name)s \"%(obj)s\" بنجاح." 36 | 37 | #: simple_history/admin.py:116 38 | msgid "You may edit it again below" 39 | msgstr "يمكنك تعديله مجددا ادناه" 40 | 41 | #: simple_history/admin.py:216 42 | #, python-format 43 | msgid "View %s" 44 | msgstr "عرض %s" 45 | 46 | #: simple_history/admin.py:218 47 | #, python-format 48 | msgid "Revert %s" 49 | msgstr "استرجاع %s" 50 | 51 | #: simple_history/models.py:552 52 | msgid "Created" 53 | msgstr "تم انشاءه" 54 | 55 | #: simple_history/models.py:552 56 | msgid "Changed" 57 | msgstr "تغيير" 58 | 59 | #: simple_history/models.py:552 60 | msgid "Deleted" 61 | msgstr "تمت إزالته" 62 | 63 | #: simple_history/templates/simple_history/object_history.html:11 64 | msgid "" 65 | "Choose a date from the list below to revert to a previous version of this " 66 | "object." 67 | msgstr "إختر تاريخ من القائمة ادناه." 68 | 69 | #: simple_history/templates/simple_history/object_history.html:16 70 | msgid "This object doesn't have a change history." 71 | msgstr "هذا العنصر لا يملك سجل تغييرات." 72 | 73 | #: simple_history/templates/simple_history/object_history_form.html:7 74 | msgid "Home" 75 | msgstr "الرئيسية" 76 | 77 | #: simple_history/templates/simple_history/object_history_form.html:11 78 | msgid "History" 79 | msgstr "سجل التغيرات" 80 | 81 | #: simple_history/templates/simple_history/object_history_form.html:12 82 | #, python-format 83 | msgid "View %(verbose_name)s" 84 | msgstr "عرض %(verbose_name)s" 85 | 86 | #: simple_history/templates/simple_history/object_history_form.html:12 87 | #, python-format 88 | msgid "Revert %(verbose_name)s" 89 | msgstr "استرجاع %(verbose_name)s" 90 | 91 | #: simple_history/templates/simple_history/object_history_form.html:25 92 | msgid "" 93 | "Press the 'Revert' button below to revert to this version of the object." 94 | msgstr "اضغط على زر 'استرجاع' ادناه للاسترجاع لهذه النسخة من العنصر." 95 | 96 | #: simple_history/templates/simple_history/object_history_form.html:25 97 | msgid "Press the 'Change History' button below to edit the history." 98 | msgstr "اضغط على زر 'تعديل سجل التغيرات' ادناه لتعديل التاريخ." 99 | 100 | #: simple_history/templates/simple_history/object_history_list.html:9 101 | msgid "Object" 102 | msgstr "عنصر" 103 | 104 | #: simple_history/templates/simple_history/object_history_list.html:13 105 | msgid "Date/time" 106 | msgstr "التاريخ/الوقت" 107 | 108 | #: simple_history/templates/simple_history/object_history_list.html:14 109 | msgid "Comment" 110 | msgstr "تعليق" 111 | 112 | #: simple_history/templates/simple_history/object_history_list.html:15 113 | msgid "Changed by" 114 | msgstr "تغير من قبل" 115 | 116 | #: simple_history/templates/simple_history/object_history_list.html:16 117 | msgid "Change reason" 118 | msgstr "سبب التغير" 119 | 120 | #: simple_history/templates/simple_history/object_history_list.html:42 121 | msgid "None" 122 | msgstr "فارغ" 123 | 124 | #: simple_history/templates/simple_history/submit_line.html:4 125 | msgid "Revert" 126 | msgstr "استرجاع" 127 | 128 | #: simple_history/templates/simple_history/submit_line.html:6 129 | msgid "Change History" 130 | msgstr "تعديل سجل التغيرات" 131 | 132 | #: simple_history/templates/simple_history/submit_line.html:7 133 | msgid "Close" 134 | msgstr "إغلاق" 135 | -------------------------------------------------------------------------------- /simple_history/locale/cs_CZ/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/cs_CZ/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/cs_CZ/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-09-18 21:54+0200\n" 11 | "PO-Revision-Date: 2021-09-20 19:50+0200\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: cs\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n " 19 | "<= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n" 20 | 21 | #: simple_history/admin.py:102 22 | #, python-format 23 | msgid "View history: %s" 24 | msgstr "Zobrazit historii: %s" 25 | 26 | #: simple_history/admin.py:104 27 | #, python-format 28 | msgid "Change history: %s" 29 | msgstr "Historie změn: %s" 30 | 31 | #: simple_history/admin.py:110 32 | #, python-format 33 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 34 | msgstr "%(name)s \"%(obj)s\" bylo úspěšně změněno." 35 | 36 | #: simple_history/admin.py:116 37 | msgid "You may edit it again below" 38 | msgstr "Níže jej můžete znovu upravit" 39 | 40 | #: simple_history/admin.py:216 41 | #, python-format 42 | msgid "View %s" 43 | msgstr "Zobrazit %s" 44 | 45 | #: simple_history/admin.py:218 46 | #, python-format 47 | msgid "Revert %s" 48 | msgstr "Vrátit změny: %s" 49 | 50 | #: simple_history/models.py:433 51 | msgid "Created" 52 | msgstr "Vytvořeno" 53 | 54 | #: simple_history/models.py:433 55 | msgid "Changed" 56 | msgstr "Změněno" 57 | 58 | #: simple_history/models.py:433 59 | msgid "Deleted" 60 | msgstr "Smazáno" 61 | 62 | #: simple_history/templates/simple_history/object_history.html:11 63 | msgid "" 64 | "Choose a date from the list below to revert to a previous version of this " 65 | "object." 66 | msgstr "" 67 | "Vyberte datum ze seznamu níže a vraťte se k předchozí verzi tohoto objektu." 68 | 69 | #: simple_history/templates/simple_history/object_history.html:16 70 | msgid "This object doesn't have a change history." 71 | msgstr "Tento objekt nemá historii změn." 72 | 73 | #: simple_history/templates/simple_history/object_history_form.html:7 74 | msgid "Home" 75 | msgstr "Domů" 76 | 77 | #: simple_history/templates/simple_history/object_history_form.html:11 78 | msgid "History" 79 | msgstr "Historie" 80 | 81 | #: simple_history/templates/simple_history/object_history_form.html:12 82 | #, python-format 83 | msgid "View %(verbose_name)s" 84 | msgstr "Zobrazit %(verbose_name)s" 85 | 86 | #: simple_history/templates/simple_history/object_history_form.html:12 87 | #, python-format 88 | msgid "Revert %(verbose_name)s" 89 | msgstr "Vrátit %(verbose_name)s" 90 | 91 | #: simple_history/templates/simple_history/object_history_form.html:25 92 | msgid "" 93 | "Press the 'Revert' button below to revert to this version of the object." 94 | msgstr "Stisknutím tlačítka 'Vrátit změny' se vrátíte k této verzi objektu." 95 | 96 | #: simple_history/templates/simple_history/object_history_form.html:25 97 | msgid "Press the 'Change History' button below to edit the history." 98 | msgstr "Chcete-li historii upravit, stiskněte tlačítko 'Změnit historii'" 99 | 100 | #: simple_history/templates/simple_history/object_history_list.html:9 101 | msgid "Object" 102 | msgstr "Objekt" 103 | 104 | #: simple_history/templates/simple_history/object_history_list.html:13 105 | msgid "Date/time" 106 | msgstr "Datum/čas" 107 | 108 | #: simple_history/templates/simple_history/object_history_list.html:14 109 | msgid "Comment" 110 | msgstr "Komentář" 111 | 112 | #: simple_history/templates/simple_history/object_history_list.html:15 113 | msgid "Changed by" 114 | msgstr "Změnil" 115 | 116 | #: simple_history/templates/simple_history/object_history_list.html:16 117 | msgid "Change reason" 118 | msgstr "Důvod změny" 119 | 120 | #: simple_history/templates/simple_history/object_history_list.html:42 121 | msgid "None" 122 | msgstr "Žádné" 123 | 124 | #: simple_history/templates/simple_history/submit_line.html:4 125 | msgid "Revert" 126 | msgstr "Vrátit změny" 127 | 128 | #: simple_history/templates/simple_history/submit_line.html:6 129 | msgid "Change History" 130 | msgstr "Historie změn" 131 | 132 | #: simple_history/templates/simple_history/submit_line.html:7 133 | msgid "Close" 134 | msgstr "Zavřít" 135 | -------------------------------------------------------------------------------- /simple_history/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: django-simple-history\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2018-11-21 15:58+0100\n" 12 | "PO-Revision-Date: 2018-11-21 16:31+0100\n" 13 | "Last-Translator: \n" 14 | "Language-Team: \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | #: simple_history/admin.py:78 21 | #, python-format 22 | msgid "Change history: %s" 23 | msgstr "Änderungshistorie: %s" 24 | 25 | #: simple_history/admin.py:97 26 | #, python-format 27 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 28 | msgstr "%(name)s \"%(obj)s\" wurde erfolgreich geändert." 29 | 30 | #: simple_history/admin.py:103 31 | msgid "You may edit it again below" 32 | msgstr "Sie können es unten wieder bearbeiten" 33 | 34 | #: simple_history/admin.py:162 35 | #, python-format 36 | msgid "Revert %s" 37 | msgstr "%s wiederherstellen" 38 | 39 | #: simple_history/models.py:314 40 | msgid "Created" 41 | msgstr "Erstellt" 42 | 43 | #: simple_history/models.py:314 44 | msgid "Changed" 45 | msgstr "Geändert" 46 | 47 | #: simple_history/models.py:314 48 | msgid "Deleted" 49 | msgstr "Gelöscht" 50 | 51 | #: simple_history/templates/simple_history/object_history.html:11 52 | msgid "" 53 | "Choose a date from the list below to revert to a previous version of this " 54 | "object." 55 | msgstr "" 56 | "Wählen Sie eine Version des Objektes aus der untenstehenden Liste, um diese " 57 | "wiederherzustellen." 58 | 59 | #: simple_history/templates/simple_history/object_history.html:17 60 | msgid "This object doesn't have a change history." 61 | msgstr "Dieses Objekt hat keine Änderungshistorie." 62 | 63 | #: simple_history/templates/simple_history/object_history_form.html:7 64 | msgid "Home" 65 | msgstr "Start" 66 | 67 | #: simple_history/templates/simple_history/object_history_form.html:11 68 | msgid "History" 69 | msgstr "Änderungshistorie" 70 | 71 | #: simple_history/templates/simple_history/object_history_form.html:12 72 | #, python-format 73 | msgid "Revert %(verbose_name)s" 74 | msgstr "%(verbose_name)s wiederherstellen" 75 | 76 | #: simple_history/templates/simple_history/object_history_form.html:21 77 | msgid "" 78 | "Press the 'Revert' button below to revert to this version of the object." 79 | msgstr "" 80 | "Klicken Sie unten auf 'Wiederherstellen', um diese Version des Objektes " 81 | "wiederherzustellen." 82 | 83 | #: simple_history/templates/simple_history/object_history_form.html:21 84 | msgid "Or press the 'Change History' button to edit the history." 85 | msgstr "Oder wählen Sie 'Historie ändern', um diese zu bearbeiten." 86 | 87 | #: simple_history/templates/simple_history/object_history_list.html:9 88 | msgid "Object" 89 | msgstr "Objekt" 90 | 91 | #: simple_history/templates/simple_history/object_history_list.html:13 92 | msgid "Date/time" 93 | msgstr "Datum/Uhrzeit" 94 | 95 | #: simple_history/templates/simple_history/object_history_list.html:14 96 | msgid "Comment" 97 | msgstr "Kommentar" 98 | 99 | #: simple_history/templates/simple_history/object_history_list.html:15 100 | msgid "Changed by" 101 | msgstr "Geändert von" 102 | 103 | #: simple_history/templates/simple_history/object_history_list.html:16 104 | msgid "Change reason" 105 | msgstr "Änderungsgrund" 106 | 107 | #: simple_history/templates/simple_history/object_history_list.html:42 108 | msgid "None" 109 | msgstr "Keine/r" 110 | 111 | #: simple_history/templates/simple_history/submit_line.html:3 112 | msgid "Revert" 113 | msgstr "Wiederherstellen" 114 | 115 | #: simple_history/templates/simple_history/submit_line.html:4 116 | msgid "Change History" 117 | msgstr "Historie ändern" 118 | -------------------------------------------------------------------------------- /simple_history/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2021-04-04 01:50+0300\n" 11 | "PO-Revision-Date: 2020-04-25 22:50+0200\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: fr\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | "X-Generator: Poedit 2.3\n" 20 | 21 | #: .\simple_history\admin.py:102 22 | #, python-format 23 | msgid "View history: %s" 24 | msgstr "Historique de vue: %s" 25 | 26 | #: .\simple_history\admin.py:104 27 | #, python-format 28 | msgid "Change history: %s" 29 | msgstr "Historique de changement: %s" 30 | 31 | #: .\simple_history\admin.py:110 32 | #, python-format 33 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 34 | msgstr "L'objet \"%(obj)s\" %(name)s a été changé avec succès." 35 | 36 | #: .\simple_history\admin.py:116 37 | msgid "You may edit it again below" 38 | msgstr "Vous pouvez le modifier à nouveau ci-dessous" 39 | 40 | #: .\simple_history\admin.py:216 41 | #, python-format 42 | msgid "View %s" 43 | msgstr "Voir %s" 44 | 45 | #: .\simple_history\admin.py:218 46 | #, python-format 47 | msgid "Revert %s" 48 | msgstr "Rétablir %s" 49 | 50 | #: .\simple_history\models.py:433 51 | msgid "Created" 52 | msgstr "Créé" 53 | 54 | #: .\simple_history\models.py:433 55 | msgid "Changed" 56 | msgstr "Modifié" 57 | 58 | #: .\simple_history\models.py:433 59 | msgid "Deleted" 60 | msgstr "Effacé" 61 | 62 | #: .\simple_history\templates\simple_history\object_history.html:11 63 | msgid "" 64 | "Choose a date from the list below to revert to a previous version of this " 65 | "object." 66 | msgstr "" 67 | "Choisissez une date dans la liste ci-dessous pour revenir à une version " 68 | "précédente de cet objet." 69 | 70 | #: .\simple_history\templates\simple_history\object_history.html:16 71 | msgid "This object doesn't have a change history." 72 | msgstr "Cet objet n'a pas d'historique." 73 | 74 | #: .\simple_history\templates\simple_history\object_history_form.html:7 75 | msgid "Home" 76 | msgstr "Accueil" 77 | 78 | #: .\simple_history\templates\simple_history\object_history_form.html:11 79 | msgid "History" 80 | msgstr "Historique" 81 | 82 | #: .\simple_history\templates\simple_history\object_history_form.html:12 83 | #, python-format 84 | msgid "View %(verbose_name)s" 85 | msgstr "Voir %(verbose_name)s" 86 | 87 | #: .\simple_history\templates\simple_history\object_history_form.html:12 88 | #, python-format 89 | msgid "Revert %(verbose_name)s" 90 | msgstr "Rétablir %(verbose_name)s" 91 | 92 | #: .\simple_history\templates\simple_history\object_history_form.html:25 93 | msgid "" 94 | "Press the 'Revert' button below to revert to this version of the object." 95 | msgstr "" 96 | "Cliquez sur le bouton 'Rétablir' ci-dessous pour revenir à cette version de " 97 | "l' objet." 98 | 99 | #: .\simple_history\templates\simple_history\object_history_form.html:25 100 | msgid "Press the 'Change History' button below to edit the history." 101 | msgstr "" 102 | "Cliquez sur le bouton 'Historique' ci-dessous pour modifier l'historique." 103 | 104 | #: .\simple_history\templates\simple_history\object_history_list.html:9 105 | msgid "Object" 106 | msgstr "Objet" 107 | 108 | #: .\simple_history\templates\simple_history\object_history_list.html:13 109 | msgid "Date/time" 110 | msgstr "Date/heure" 111 | 112 | #: .\simple_history\templates\simple_history\object_history_list.html:14 113 | msgid "Comment" 114 | msgstr "Commentaire" 115 | 116 | #: .\simple_history\templates\simple_history\object_history_list.html:15 117 | msgid "Changed by" 118 | msgstr "Modifié par" 119 | 120 | #: .\simple_history\templates\simple_history\object_history_list.html:16 121 | msgid "Change reason" 122 | msgstr "Raison de la modification" 123 | 124 | #: .\simple_history\templates\simple_history\object_history_list.html:42 125 | msgid "None" 126 | msgstr "Aucun" 127 | 128 | #: .\simple_history\templates\simple_history\submit_line.html:4 129 | msgid "Revert" 130 | msgstr "Rétablir" 131 | 132 | #: .\simple_history\templates\simple_history\submit_line.html:6 133 | msgid "Change History" 134 | msgstr "Historique des changements" 135 | 136 | #: .\simple_history\templates\simple_history\submit_line.html:7 137 | msgid "Close" 138 | msgstr "Fermer" 139 | -------------------------------------------------------------------------------- /simple_history/locale/id/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/id/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/id/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Indonesian translation for django-simple-history 2 | # Copyright (C) 2023 3 | # This file is distributed under the same license as the django-simple-history package. 4 | # Kira , 2023. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2023-06-30 15:21+0800\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: Kira \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: id\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=1; plural=0;\n" 19 | 20 | #: .\simple_history\admin.py:102 21 | #, python-format 22 | msgid "View history: %s" 23 | msgstr "Lihat riwayat: %s" 24 | 25 | #: .\simple_history\admin.py:104 26 | #, python-format 27 | msgid "Change history: %s" 28 | msgstr "Ubah riwayat: %s" 29 | 30 | #: .\simple_history\admin.py:110 31 | #, python-format 32 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 33 | msgstr "%(name)s \"%(obj)s\" berhasil diubah." 34 | 35 | #: .\simple_history\admin.py:116 36 | msgid "You may edit it again below" 37 | msgstr "Anda dapat mengeditnya lagi di bawah ini" 38 | 39 | #: .\simple_history\admin.py:217 40 | #, python-format 41 | msgid "View %s" 42 | msgstr "Lihat %s" 43 | 44 | #: .\simple_history\admin.py:219 45 | #, python-format 46 | msgid "Revert %s" 47 | msgstr "Kembalikan %s" 48 | 49 | #: .\simple_history\models.py:552 50 | msgid "Created" 51 | msgstr "Dibuat" 52 | 53 | #: .\simple_history\models.py:552 54 | msgid "Changed" 55 | msgstr "Diubah" 56 | 57 | #: .\simple_history\models.py:552 58 | msgid "Deleted" 59 | msgstr "Dihapus" 60 | 61 | #: .\simple_history\templates\simple_history\object_history.html:11 62 | msgid "" 63 | "Choose a date from the list below to revert to a previous version of this " 64 | "object." 65 | msgstr "" 66 | "Pilih tanggal dari daftar di bawah ini untuk kembali ke versi sebelumnya " 67 | "dari objek ini." 68 | 69 | #: .\simple_history\templates\simple_history\object_history.html:16 70 | msgid "This object doesn't have a change history." 71 | msgstr "Objek ini tidak memiliki riwayat perubahan." 72 | 73 | #: .\simple_history\templates\simple_history\object_history_form.html:7 74 | msgid "Home" 75 | msgstr "Beranda" 76 | 77 | #: .\simple_history\templates\simple_history\object_history_form.html:11 78 | msgid "History" 79 | msgstr "Riwayat" 80 | 81 | #: .\simple_history\templates\simple_history\object_history_form.html:12 82 | #, python-format 83 | msgid "View %(verbose_name)s" 84 | msgstr "Lihat %(verbose_name)s" 85 | 86 | #: .\simple_history\templates\simple_history\object_history_form.html:12 87 | #, python-format 88 | msgid "Revert %(verbose_name)s" 89 | msgstr "Kembalikan %(verbose_name)s" 90 | 91 | #: .\simple_history\templates\simple_history\object_history_form.html:25 92 | msgid "" 93 | "Press the 'Revert' button below to revert to this version of the object." 94 | msgstr "" 95 | "Tekan tombol 'Kembalikan' di bawah ini untuk kembali ke versi objek ini." 96 | 97 | #: .\simple_history\templates\simple_history\object_history_form.html:25 98 | msgid "Press the 'Change History' button below to edit the history." 99 | msgstr "Tekan tombol 'Ubah Riwayat' di bawah ini untuk mengubah riwayat." 100 | 101 | #: .\simple_history\templates\simple_history\object_history_list.html:9 102 | msgid "Object" 103 | msgstr "Objek" 104 | 105 | #: .\simple_history\templates\simple_history\object_history_list.html:13 106 | msgid "Date/time" 107 | msgstr "Tanggal/waktu" 108 | 109 | #: .\simple_history\templates\simple_history\object_history_list.html:14 110 | msgid "Comment" 111 | msgstr "Komentar" 112 | 113 | #: .\simple_history\templates\simple_history\object_history_list.html:15 114 | msgid "Changed by" 115 | msgstr "Diubah oleh" 116 | 117 | #: .\simple_history\templates\simple_history\object_history_list.html:16 118 | msgid "Change reason" 119 | msgstr "Alasan perubahan" 120 | 121 | #: .\simple_history\templates\simple_history\object_history_list.html:42 122 | msgid "None" 123 | msgstr "Tidak ada" 124 | 125 | #: .\simple_history\templates\simple_history\submit_line.html:4 126 | msgid "Revert" 127 | msgstr "Kembalikan" 128 | 129 | #: .\simple_history\templates\simple_history\submit_line.html:6 130 | msgid "Change History" 131 | msgstr "Ubah Riwayat" 132 | 133 | #: .\simple_history\templates\simple_history\submit_line.html:7 134 | msgid "Close" 135 | msgstr "Tutup" 136 | -------------------------------------------------------------------------------- /simple_history/locale/nb/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/nb/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/nb/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Norwegian Bokmål translation for django-simple-history 2 | # Copyright (C) 2023 3 | # This file is distributed under the same license as the django-simple-history package. 4 | # Anders , 2023. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2023-07-09 13:55+0200\n" 11 | "PO-Revision-Date: 2024-04-11 19:34+0200\n" 12 | "Last-Translator: Anders \n" 13 | "Language-Team: Norwegian Bokmål \n" 14 | "Language: nb\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | # Dette er en tittel, ikke en handlingsbeskrivelse, så f.eks. 21 | # "Se/Vis (endrings)historikk" hadde ikke fungert så bra 22 | #: simple_history/admin.py:109 23 | #, python-format 24 | msgid "View history: %s" 25 | msgstr "Endringshistorikk: %s" 26 | 27 | #: simple_history/admin.py:111 28 | #, python-format 29 | msgid "Change history: %s" 30 | msgstr "Endringshistorikk: %s" 31 | 32 | #: simple_history/admin.py:117 33 | #, python-format 34 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 35 | msgstr "%(name)s «%(obj)s» ble endret." 36 | 37 | #: simple_history/admin.py:123 38 | msgid "You may edit it again below" 39 | msgstr "Du kan redigere videre nedenfor" 40 | 41 | #: simple_history/admin.py:224 42 | #, python-format 43 | msgid "View %s" 44 | msgstr "Se %s" 45 | 46 | #: simple_history/admin.py:226 47 | #, python-format 48 | msgid "Revert %s" 49 | msgstr "Tilbakestill %s" 50 | 51 | #: simple_history/models.py:552 52 | msgid "Created" 53 | msgstr "Opprettet" 54 | 55 | #: simple_history/models.py:552 56 | msgid "Changed" 57 | msgstr "Endret" 58 | 59 | #: simple_history/models.py:552 60 | msgid "Deleted" 61 | msgstr "Slettet" 62 | 63 | #: simple_history/models.py:1124 64 | #, python-format 65 | msgid "Deleted %(type_name)s" 66 | msgstr "Slettet %(type_name)s" 67 | 68 | #: simple_history/templates/simple_history/object_history.html:11 69 | msgid "" 70 | "Choose a date from the list below to revert to a previous version of this " 71 | "object." 72 | msgstr "" 73 | "Velg en dato fra listen nedenfor for å tilbakestille til en tidligere " 74 | "versjon av dette objektet." 75 | 76 | #: simple_history/templates/simple_history/object_history.html:16 77 | msgid "This object doesn't have a change history." 78 | msgstr "Dette objektet har ingen endringshistorikk." 79 | 80 | #: simple_history/templates/simple_history/object_history_form.html:7 81 | msgid "Home" 82 | msgstr "Hjem" 83 | 84 | #: simple_history/templates/simple_history/object_history_form.html:11 85 | msgid "History" 86 | msgstr "Historikk" 87 | 88 | #: simple_history/templates/simple_history/object_history_form.html:12 89 | #, python-format 90 | msgid "View %(verbose_name)s" 91 | msgstr "Se %(verbose_name)s" 92 | 93 | #: simple_history/templates/simple_history/object_history_form.html:12 94 | #, python-format 95 | msgid "Revert %(verbose_name)s" 96 | msgstr "Tilbakestill %(verbose_name)s" 97 | 98 | #: simple_history/templates/simple_history/object_history_form.html:25 99 | msgid "" 100 | "Press the 'Revert' button below to revert to this version of the object." 101 | msgstr "" 102 | "Trykk på 'Tilbakestill'-knappen under for å tilbakestille til denne " 103 | "versjonen av objektet." 104 | 105 | #: simple_history/templates/simple_history/object_history_form.html:25 106 | msgid "Press the 'Change History' button below to edit the history." 107 | msgstr "Trykk på 'Endre historikk'-knappen under for å endre historikken." 108 | 109 | #: simple_history/templates/simple_history/object_history_list.html:9 110 | msgid "Object" 111 | msgstr "Objekt" 112 | 113 | #: simple_history/templates/simple_history/object_history_list.html:13 114 | msgid "Date/time" 115 | msgstr "Dato/tid" 116 | 117 | #: simple_history/templates/simple_history/object_history_list.html:14 118 | msgid "Comment" 119 | msgstr "Kommentar" 120 | 121 | #: simple_history/templates/simple_history/object_history_list.html:15 122 | msgid "Changed by" 123 | msgstr "Endret av" 124 | 125 | #: simple_history/templates/simple_history/object_history_list.html:16 126 | msgid "Change reason" 127 | msgstr "Endringsårsak" 128 | 129 | #: simple_history/templates/simple_history/object_history_list.html:17 130 | msgid "Changes" 131 | msgstr "Endringer" 132 | 133 | #: simple_history/templates/simple_history/object_history_list.html:42 134 | msgid "None" 135 | msgstr "Ingen" 136 | 137 | #: simple_history/templates/simple_history/submit_line.html:4 138 | msgid "Revert" 139 | msgstr "Tilbakestill" 140 | 141 | #: simple_history/templates/simple_history/submit_line.html:6 142 | msgid "Change History" 143 | msgstr "Endre historikk" 144 | 145 | #: simple_history/templates/simple_history/submit_line.html:7 146 | msgid "Close" 147 | msgstr "Lukk" 148 | -------------------------------------------------------------------------------- /simple_history/locale/pl/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/pl/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/pl/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-06-06 15:32+0200\n" 11 | "PO-Revision-Date: 2017-06-06 15:38+0200\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: pl\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 " 19 | "|| n%100>=20) ? 1 : 2);\n" 20 | "X-Generator: Poedit 2.0.2\n" 21 | 22 | #: admin.py:73 23 | #, python-format 24 | msgid "Change history: %s" 25 | msgstr "Historia zmian: %s" 26 | 27 | #: admin.py:92 28 | #, python-format 29 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 30 | msgstr "%(name)s \"%(obj)s\" został pomyślnie zmodyfikowany." 31 | 32 | #: admin.py:98 33 | msgid "You may edit it again below" 34 | msgstr "Możesz edytować go ponownie poniżej" 35 | 36 | #: admin.py:156 37 | #, python-format 38 | msgid "Revert %s" 39 | msgstr "Przywróć %s" 40 | 41 | #: models.py:211 42 | msgid "Created" 43 | msgstr "Dodane" 44 | 45 | #: models.py:212 46 | msgid "Changed" 47 | msgstr "Zmodyfikowane" 48 | 49 | #: models.py:213 50 | msgid "Deleted" 51 | msgstr "Usunięte" 52 | 53 | #: templates/simple_history/object_history.html:10 54 | msgid "" 55 | "Choose a date from the list below to revert to a previous version of this " 56 | "object." 57 | msgstr "" 58 | "Wybierz datę z poniższej listy aby przywrócić poprzednią wersję tego obiektu." 59 | 60 | #: templates/simple_history/object_history.html:46 61 | msgid "This object doesn't have a change history." 62 | msgstr "Ten obiekt nie ma historii zmian." 63 | 64 | #: templates/simple_history/object_history_form.html:7 65 | msgid "Home" 66 | msgstr "Strona główna" 67 | 68 | #: templates/simple_history/object_history_form.html:11 69 | msgid "History" 70 | msgstr "Historia" 71 | 72 | #: templates/simple_history/object_history_form.html:12 73 | #, python-format 74 | msgid "Revert %(verbose_name)s" 75 | msgstr "Przywróć %(verbose_name)s" 76 | 77 | #: templates/simple_history/object_history_form.html:21 78 | msgid "" 79 | "Press the 'Revert' button below to revert to this version of the object." 80 | msgstr "Naciśnij przycisk „Przywróć” aby przywrócić tę wersję obiektu." 81 | 82 | #: templates/simple_history/object_history_form.html:21 83 | msgid "Or press the 'Change History' button to edit the history." 84 | msgstr "Lub naciśnij przycisk „Historia zmian” aby edytować historię." 85 | 86 | #: templates/simple_history/object_history_list.html:9 87 | msgid "Object" 88 | msgstr "Obiekt" 89 | 90 | #: templates/simple_history/object_history_list.html:13 91 | msgid "Date/time" 92 | msgstr "Data/czas" 93 | 94 | #: templates/simple_history/object_history_list.html:14 95 | msgid "Comment" 96 | msgstr "Komentarz" 97 | 98 | #: templates/simple_history/object_history_list.html:15 99 | msgid "Changed by" 100 | msgstr "Zmodyfikowane przez" 101 | 102 | #: templates/simple_history/object_history_list.html:42 103 | msgid "None" 104 | msgstr "Brak" 105 | 106 | #: templates/simple_history/submit_line.html:3 107 | msgid "Revert" 108 | msgstr "Przywróć" 109 | 110 | #: templates/simple_history/submit_line.html:4 111 | msgid "Change History" 112 | msgstr "Historia zmian" 113 | -------------------------------------------------------------------------------- /simple_history/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/pt_BR/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2017-06-01 15:47-0300\n" 11 | "PO-Revision-Date: 2017-06-01 15:47-0300\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: pt_BR\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 19 | "X-Generator: Poedit 1.8.11\n" 20 | 21 | #: simple_history/admin.py:73 22 | #, python-format 23 | msgid "Change history: %s" 24 | msgstr "Histórico de modificações: %s" 25 | 26 | #: simple_history/admin.py:92 27 | #, python-format 28 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 29 | msgstr "%(name)s \"%(obj)s\" modificados com sucesso." 30 | 31 | #: simple_history/admin.py:98 32 | msgid "You may edit it again below" 33 | msgstr "Você pode fazer novas modificações abaixo:" 34 | 35 | #: simple_history/admin.py:156 36 | #, python-format 37 | msgid "Revert %s" 38 | msgstr "Reverter %s" 39 | 40 | #: simple_history/models.py:209 41 | msgid "Created" 42 | msgstr "Criado" 43 | 44 | #: simple_history/models.py:210 45 | msgid "Changed" 46 | msgstr "Modificado" 47 | 48 | #: simple_history/models.py:211 49 | msgid "Deleted" 50 | msgstr "Excluído" 51 | 52 | #: simple_history/templates/simple_history/object_history.html:10 53 | msgid "" 54 | "Choose a date from the list below to revert to a previous version of this " 55 | "object." 56 | msgstr "" 57 | "Escolha a data desejada na lista a seguir para reverter as modificações " 58 | "feitas nesse objeto." 59 | 60 | #: simple_history/templates/simple_history/object_history.html:46 61 | msgid "This object doesn't have a change history." 62 | msgstr "Esse objeto não tem um histórico de modificações." 63 | 64 | #: simple_history/templates/simple_history/object_history_form.html:7 65 | msgid "Home" 66 | msgstr "Início" 67 | 68 | #: simple_history/templates/simple_history/object_history_form.html:11 69 | msgid "History" 70 | msgstr "Histórico" 71 | 72 | #: simple_history/templates/simple_history/object_history_form.html:12 73 | #, python-format 74 | msgid "Revert %(verbose_name)s" 75 | msgstr "Reverter %(verbose_name)s" 76 | 77 | #: simple_history/templates/simple_history/object_history_form.html:21 78 | msgid "" 79 | "Press the 'Revert' button below to revert to this version of the object." 80 | msgstr "" 81 | "Clique em 'Reverter' para reverter as modificações feitas nesse objeto." 82 | 83 | #: simple_history/templates/simple_history/object_history_form.html:21 84 | msgid "Or press the 'Change History' button to edit the history." 85 | msgstr "Ou clique em 'Histórico de Modificações' para modificar o histórico." 86 | 87 | #: simple_history/templates/simple_history/object_history_list.html:9 88 | msgid "Object" 89 | msgstr "Objeto" 90 | 91 | #: simple_history/templates/simple_history/object_history_list.html:13 92 | msgid "Date/time" 93 | msgstr "Data/hora" 94 | 95 | #: simple_history/templates/simple_history/object_history_list.html:14 96 | msgid "Comment" 97 | msgstr "Comentário" 98 | 99 | #: simple_history/templates/simple_history/object_history_list.html:15 100 | msgid "Changed by" 101 | msgstr "Modificado por" 102 | 103 | #: simple_history/templates/simple_history/object_history_list.html:42 104 | msgid "None" 105 | msgstr "-" 106 | 107 | #: simple_history/templates/simple_history/submit_line.html:3 108 | msgid "Revert" 109 | msgstr "Reverter" 110 | 111 | #: simple_history/templates/simple_history/submit_line.html:4 112 | msgid "Change History" 113 | msgstr "Histórico de Modificações" 114 | -------------------------------------------------------------------------------- /simple_history/locale/ru_RU/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/ru_RU/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/ru_RU/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2018-10-10 16:47+0300\n" 11 | "PO-Revision-Date: 2021-10-14 14:05+0300\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: ru_RU\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 19 | "%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" 20 | "X-Generator: Poedit 3.0\n" 21 | 22 | #: admin.py:77 23 | #, python-format 24 | msgid "Change history: %s" 25 | msgstr "История изменений: %s" 26 | 27 | #: admin.py:96 28 | #, python-format 29 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 30 | msgstr "%(name)s \"%(obj)s\" было успешно изменено." 31 | 32 | #: admin.py:102 33 | msgid "You may edit it again below" 34 | msgstr "Вы можете отредактировать его снова ниже" 35 | 36 | #: admin.py:160 37 | #, python-format 38 | msgid "Revert %s" 39 | msgstr "Восстановить %s" 40 | 41 | #: models.py:304 42 | msgid "Created" 43 | msgstr "Создано" 44 | 45 | #: models.py:305 46 | msgid "Changed" 47 | msgstr "Изменено" 48 | 49 | #: models.py:306 50 | msgid "Deleted" 51 | msgstr "Удалено" 52 | 53 | #: templates/simple_history/object_history.html:11 54 | msgid "" 55 | "Choose a date from the list below to revert to a previous version of this " 56 | "object." 57 | msgstr "" 58 | "Выберите дату из списка ниже, чтобы вернуться к предыдущей версии этого " 59 | "объекта." 60 | 61 | #: templates/simple_history/object_history.html:17 62 | msgid "This object doesn't have a change history." 63 | msgstr "Этот объект не имеет истории изменений." 64 | 65 | #: templates/simple_history/object_history_form.html:7 66 | msgid "Home" 67 | msgstr "Главная" 68 | 69 | #: templates/simple_history/object_history_form.html:11 70 | msgid "History" 71 | msgstr "История" 72 | 73 | #: templates/simple_history/object_history_form.html:12 74 | #, python-format 75 | msgid "Revert %(verbose_name)s" 76 | msgstr "Восстановить %(verbose_name)s" 77 | 78 | #: templates/simple_history/object_history_form.html:21 79 | msgid "" 80 | "Press the 'Revert' button below to revert to this version of the object." 81 | msgstr "" 82 | "Нажмите кнопку 'Восстановить' ниже, чтобы вернуться к этой версии объекта." 83 | 84 | #: templates/simple_history/object_history_form.html:21 85 | msgid "Or press the 'Change History' button to edit the history." 86 | msgstr "Или нажмите кнопку 'Изменить запись', чтобы изменить историю." 87 | 88 | #: templates/simple_history/object_history_list.html:9 89 | msgid "Object" 90 | msgstr "Объект" 91 | 92 | #: templates/simple_history/object_history_list.html:13 93 | msgid "Date/time" 94 | msgstr "Дата/время" 95 | 96 | #: templates/simple_history/object_history_list.html:14 97 | msgid "Comment" 98 | msgstr "Комментарий" 99 | 100 | #: templates/simple_history/object_history_list.html:15 101 | msgid "Changed by" 102 | msgstr "Изменено" 103 | 104 | #: templates/simple_history/object_history_list.html:16 105 | msgid "Change reason" 106 | msgstr "Причина изменения" 107 | 108 | #: templates/simple_history/object_history_list.html:42 109 | msgid "None" 110 | msgstr "None" 111 | 112 | #: templates/simple_history/submit_line.html:3 113 | msgid "Revert" 114 | msgstr "Восстановить" 115 | 116 | #: templates/simple_history/submit_line.html:4 117 | msgid "Change History" 118 | msgstr "Изменить запись" 119 | -------------------------------------------------------------------------------- /simple_history/locale/ur/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/ur/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/ur/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Urdu translation for django-simple-history 2 | # Copyright (C) 2023 3 | # This file is distributed under the same license as the django-simple-history package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2023-07-02 11:10+0800\n" 11 | "PO-Revision-Date: 2023-07-02 11:50+0800\n" 12 | "Last-Translator: \n" 13 | "Language-Team: \n" 14 | "Language: ur\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: .\simple_history\admin.py:102 21 | #, python-format 22 | msgid "View history: %s" 23 | msgstr "تاریخ دیکھیں: %s" 24 | 25 | #: .\simple_history\admin.py:104 26 | #, python-format 27 | msgid "Change history: %s" 28 | msgstr "تاریخ کو تبدیل کریں: %s" 29 | 30 | #: .\simple_history\admin.py:110 31 | #, python-format 32 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 33 | msgstr "%(name)s \"%(obj)s\" کامیابی سے تبدیل کر دیا گیا." 34 | 35 | #: .\simple_history\admin.py:116 36 | msgid "You may edit it again below" 37 | msgstr "آپ نیچے اس میں دوبارہ ترمیم کر سکتے ہیں" 38 | 39 | #: .\simple_history\admin.py:217 40 | #, python-format 41 | msgid "View %s" 42 | msgstr "دیکھیں %s" 43 | 44 | #: .\simple_history\admin.py:219 45 | #, python-format 46 | msgid "Revert %s" 47 | msgstr "تبدیلی واپس کریں %s" 48 | 49 | #: .\simple_history\models.py:552 50 | msgid "Created" 51 | msgstr "بنایا" 52 | 53 | #: .\simple_history\models.py:552 54 | msgid "Changed" 55 | msgstr "بدل گیا" 56 | 57 | #: .\simple_history\models.py:552 58 | msgid "Deleted" 59 | msgstr "حذف کر دیا گیا" 60 | 61 | #: .\simple_history\templates\simple_history\object_history.html:11 62 | msgid "" 63 | "Choose a date from the list below to revert to a previous version of this " 64 | "object." 65 | msgstr "" 66 | "اس آبجیکٹ کے پچھلے ورژن پر واپس جانے کے لیے نیچے دی گئی فہرست میں سے ایک تاریخ کا انتخاب کریں۔" 67 | 68 | #: .\simple_history\templates\simple_history\object_history.html:16 69 | msgid "This object doesn't have a change history." 70 | msgstr "اس آبجیکٹ کی تاریخ نہیں ہے." 71 | 72 | #: .\simple_history\templates\simple_history\object_history_form.html:7 73 | msgid "Home" 74 | msgstr "گھر" 75 | 76 | #: .\simple_history\templates\simple_history\object_history_form.html:11 77 | msgid "History" 78 | msgstr "تاریخ" 79 | 80 | #: .\simple_history\templates\simple_history\object_history_form.html:12 81 | #, python-format 82 | msgid "View %(verbose_name)s" 83 | msgstr "دیکھیں %(verbose_name)s" 84 | 85 | #: .\simple_history\templates\simple_history\object_history_form.html:12 86 | #, python-format 87 | msgid "Revert %(verbose_name)s" 88 | msgstr "تبدیلی واپس کریں %(verbose_name)s" 89 | 90 | #: .\simple_history\templates\simple_history\object_history_form.html:25 91 | msgid "" 92 | "Press the 'Revert' button below to revert to this version of the object." 93 | msgstr "" 94 | "آبجیکٹ کے اس ورژن پر واپس جانے کے لیے نیچے 'تبدیلی واپس کریں' کے بٹن کو دبائیں." 95 | 96 | #: .\simple_history\templates\simple_history\object_history_form.html:25 97 | msgid "Press the 'Change History' button below to edit the history." 98 | msgstr "تاریخ میں ترمیم کرنے کے لیے نیچے دیے گئے 'تاریخ کو تبدیل کریں' کے بٹن کو دبائیں." 99 | 100 | #: .\simple_history\templates\simple_history\object_history_list.html:9 101 | msgid "Object" 102 | msgstr "آبجیکٹ" 103 | 104 | #: .\simple_history\templates\simple_history\object_history_list.html:13 105 | msgid "Date/time" 106 | msgstr "تاریخ/وقت" 107 | 108 | #: .\simple_history\templates\simple_history\object_history_list.html:14 109 | msgid "Comment" 110 | msgstr "تبصرہ" 111 | 112 | #: .\simple_history\templates\simple_history\object_history_list.html:15 113 | msgid "Changed by" 114 | msgstr "کی طرف سے تبدیل" 115 | 116 | #: .\simple_history\templates\simple_history\object_history_list.html:16 117 | msgid "Change reason" 118 | msgstr "تبدیلی کا سبب" 119 | 120 | #: .\simple_history\templates\simple_history\object_history_list.html:42 121 | msgid "None" 122 | msgstr "کوئی نہیں" 123 | 124 | #: .\simple_history\templates\simple_history\submit_line.html:4 125 | msgid "Revert" 126 | msgstr "تبدیلی واپس کریں" 127 | 128 | #: .\simple_history\templates\simple_history\submit_line.html:6 129 | msgid "Change History" 130 | msgstr "تاریخ کو تبدیل کریں" 131 | 132 | #: .\simple_history\templates\simple_history\submit_line.html:7 133 | msgid "Close" 134 | msgstr "بند کریں" 135 | -------------------------------------------------------------------------------- /simple_history/locale/zh_Hans/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/locale/zh_Hans/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simple_history/locale/zh_Hans/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Simplified Chinese translation for django-simple-history 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the django-simple-history package. 4 | # Peihao Ren , 2023. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: django-simple-history VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2023-11-30 22:12+0800\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: Peihao Ren \n" 13 | "Language-Team: Simplified Chinese \n" 14 | "Language: zh_Hans\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=1; plural=0;\n" 19 | 20 | #: simple_history/admin.py:102 21 | #, python-format 22 | msgid "View history: %s" 23 | msgstr "查看历史记录: %s" 24 | 25 | #: simple_history/admin.py:104 26 | #, python-format 27 | msgid "Change history: %s" 28 | msgstr "修改历史记录: %s" 29 | 30 | #: simple_history/admin.py:110 31 | #, python-format 32 | msgid "The %(name)s \"%(obj)s\" was changed successfully." 33 | msgstr "%(name)s \"%(obj)s\" 已成功修改。" 34 | 35 | #: simple_history/admin.py:116 36 | msgid "You may edit it again below" 37 | msgstr "你可在下方再次编辑" 38 | 39 | #: simple_history/admin.py:217 40 | #, python-format 41 | msgid "View %s" 42 | msgstr "查看 %s" 43 | 44 | #: simple_history/admin.py:219 45 | #, python-format 46 | msgid "Revert %s" 47 | msgstr "还原 %s" 48 | 49 | #: simple_history/models.py:552 50 | msgid "Created" 51 | msgstr "已创建" 52 | 53 | #: simple_history/models.py:552 54 | msgid "Changed" 55 | msgstr "已修改" 56 | 57 | #: simple_history/models.py:552 58 | msgid "Deleted" 59 | msgstr "已删除" 60 | 61 | #: simple_history/templates/simple_history/object_history.html:11 62 | msgid "" 63 | "Choose a date from the list below to revert to a previous version of this " 64 | "object." 65 | msgstr "从下面的列表中选择一个日期以还原到该记录对象的先前版本" 66 | 67 | #: simple_history/templates/simple_history/object_history.html:16 68 | msgid "This object doesn't have a change history." 69 | msgstr "该记录对象没有修改的记录" 70 | 71 | #: simple_history/templates/simple_history/object_history_form.html:7 72 | msgid "Home" 73 | msgstr "主页" 74 | 75 | #: simple_history/templates/simple_history/object_history_form.html:11 76 | msgid "History" 77 | msgstr "历史记录" 78 | 79 | #: simple_history/templates/simple_history/object_history_form.html:12 80 | #, python-format 81 | msgid "View %(verbose_name)s" 82 | msgstr "查看 %(verbose_name)s" 83 | 84 | #: simple_history/templates/simple_history/object_history_form.html:12 85 | #, python-format 86 | msgid "Revert %(verbose_name)s" 87 | msgstr "还原 %(verbose_name)s" 88 | 89 | #: simple_history/templates/simple_history/object_history_form.html:25 90 | msgid "" 91 | "Press the 'Revert' button below to revert to this version of the object." 92 | msgstr "按下面的“还原”按钮还原记录到当前版本。" 93 | 94 | #: simple_history/templates/simple_history/object_history_form.html:25 95 | msgid "Press the 'Change History' button below to edit the history." 96 | msgstr "按下面的“修改历史记录”按钮编辑历史记录。" 97 | 98 | #: simple_history/templates/simple_history/object_history_list.html:9 99 | msgid "Object" 100 | msgstr "记录对象" 101 | 102 | #: simple_history/templates/simple_history/object_history_list.html:13 103 | msgid "Date/time" 104 | msgstr "日期/时间" 105 | 106 | #: simple_history/templates/simple_history/object_history_list.html:14 107 | msgid "Comment" 108 | msgstr "备注" 109 | 110 | #: simple_history/templates/simple_history/object_history_list.html:15 111 | msgid "Changed by" 112 | msgstr "修改人" 113 | 114 | #: simple_history/templates/simple_history/object_history_list.html:16 115 | msgid "Change reason" 116 | msgstr "修改原因" 117 | 118 | #: simple_history/templates/simple_history/object_history_list.html:42 119 | msgid "None" 120 | msgstr "无" 121 | 122 | #: simple_history/templates/simple_history/submit_line.html:4 123 | msgid "Revert" 124 | msgstr "还原" 125 | 126 | #: simple_history/templates/simple_history/submit_line.html:6 127 | msgid "Change History" 128 | msgstr "修改历史记录" 129 | 130 | #: simple_history/templates/simple_history/submit_line.html:7 131 | msgid "Close" 132 | msgstr "关闭" 133 | -------------------------------------------------------------------------------- /simple_history/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/management/__init__.py -------------------------------------------------------------------------------- /simple_history/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/management/commands/__init__.py -------------------------------------------------------------------------------- /simple_history/management/commands/clean_duplicate_history.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | from django.utils import timezone 3 | 4 | from ... import utils 5 | from . import populate_history 6 | 7 | 8 | class Command(populate_history.Command): 9 | args = "" 10 | help = ( 11 | "Scans HistoricalRecords for identical sequencial entries " 12 | "(duplicates) in a model and deletes them." 13 | ) 14 | 15 | DONE_CLEANING_FOR_MODEL = "Removed {count} historical records for {model}\n" 16 | 17 | def add_arguments(self, parser): 18 | parser.add_argument("models", nargs="*", type=str) 19 | parser.add_argument( 20 | "--auto", 21 | action="store_true", 22 | dest="auto", 23 | default=False, 24 | help="Automatically search for models with the HistoricalRecords field " 25 | "type", 26 | ) 27 | parser.add_argument( 28 | "-d", "--dry", action="store_true", help="Dry (test) run only, no changes" 29 | ) 30 | parser.add_argument( 31 | "-m", "--minutes", type=int, help="Only search the last MINUTES of history" 32 | ) 33 | parser.add_argument( 34 | "--excluded_fields", 35 | nargs="+", 36 | help="List of fields to be excluded from the diff_against check", 37 | ) 38 | parser.add_argument( 39 | "--base-manager", 40 | action="store_true", 41 | default=False, 42 | help="Use Django's base manager to handle all records stored in the" 43 | " database, including those that would otherwise be filtered or modified" 44 | " by a custom manager.", 45 | ) 46 | 47 | def handle(self, *args, **options): 48 | self.verbosity = options["verbosity"] 49 | self.excluded_fields = options.get("excluded_fields") 50 | self.base_manager = options.get("base_manager") 51 | 52 | to_process = set() 53 | model_strings = options.get("models", []) or args 54 | 55 | if model_strings: 56 | for model_pair in self._handle_model_list(*model_strings): 57 | to_process.add(model_pair) 58 | 59 | elif options["auto"]: 60 | to_process = self._auto_models() 61 | 62 | else: 63 | self.log(self.COMMAND_HINT) 64 | 65 | self._process(to_process, date_back=options["minutes"], dry_run=options["dry"]) 66 | 67 | def _process(self, to_process, date_back=None, dry_run=True): 68 | if date_back: 69 | stop_date = timezone.now() - timezone.timedelta(minutes=date_back) 70 | else: 71 | stop_date = None 72 | 73 | for model, history_model in to_process: 74 | m_qs = history_model.objects 75 | if stop_date: 76 | m_qs = m_qs.filter(history_date__gte=stop_date) 77 | if self.verbosity >= 2: 78 | found = m_qs.count() 79 | self.log(f"{model} has {found} historical entries", 2) 80 | if not m_qs.exists(): 81 | continue 82 | 83 | # Break apart the query so we can add additional filtering 84 | if self.base_manager: 85 | model_query = model._base_manager.all() 86 | else: 87 | model_query = model._default_manager.all() 88 | 89 | # If we're provided a stop date take the initial hit of getting the 90 | # filtered records to iterate over 91 | if stop_date: 92 | model_query = model_query.filter( 93 | pk__in=(m_qs.values_list(model._meta.pk.name).distinct()) 94 | ) 95 | 96 | for o in model_query.iterator(): 97 | self._process_instance(o, model, stop_date=stop_date, dry_run=dry_run) 98 | 99 | def _process_instance(self, instance, model, stop_date=None, dry_run=True): 100 | entries_deleted = 0 101 | history = utils.get_history_manager_for_model(instance) 102 | o_qs = history.all() 103 | if stop_date: 104 | # to compare last history match 105 | extra_one = o_qs.filter(history_date__lte=stop_date).first() 106 | o_qs = o_qs.filter(history_date__gte=stop_date) 107 | else: 108 | extra_one = None 109 | with transaction.atomic(): 110 | # ordering is ('-history_date', '-history_id') so this is ok 111 | f1 = o_qs.first() 112 | if not f1: 113 | return 114 | 115 | for f2 in o_qs[1:]: 116 | entries_deleted += self._check_and_delete(f1, f2, dry_run) 117 | f1 = f2 118 | if extra_one: 119 | entries_deleted += self._check_and_delete(f1, extra_one, dry_run) 120 | 121 | self.log( 122 | self.DONE_CLEANING_FOR_MODEL.format(model=model, count=entries_deleted) 123 | ) 124 | 125 | def log(self, message, verbosity_level=1): 126 | if self.verbosity >= verbosity_level: 127 | self.stdout.write(message) 128 | 129 | def _check_and_delete(self, entry1, entry2, dry_run=True): 130 | delta = entry1.diff_against(entry2, excluded_fields=self.excluded_fields) 131 | if not delta.changed_fields: 132 | if not dry_run: 133 | entry1.delete() 134 | return 1 135 | return 0 136 | -------------------------------------------------------------------------------- /simple_history/management/commands/clean_old_history.py: -------------------------------------------------------------------------------- 1 | from django.db import transaction 2 | from django.utils import timezone 3 | 4 | from ... import models, utils 5 | from ...exceptions import NotHistoricalModelError 6 | from . import populate_history 7 | 8 | 9 | class Command(populate_history.Command): 10 | args = "" 11 | help = "Scans HistoricalRecords for old entries " "and deletes them." 12 | 13 | DONE_CLEANING_FOR_MODEL = "Removed {count} historical records for {model}\n" 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument("models", nargs="*", type=str) 17 | parser.add_argument( 18 | "--auto", 19 | action="store_true", 20 | dest="auto", 21 | default=False, 22 | help="Automatically search for models with the HistoricalRecords field " 23 | "type", 24 | ) 25 | parser.add_argument( 26 | "--days", 27 | help="Only Keep the last X Days of history, default is 30", 28 | dest="days", 29 | type=int, 30 | default=30, 31 | ) 32 | 33 | parser.add_argument( 34 | "-d", "--dry", action="store_true", help="Dry (test) run only, no changes" 35 | ) 36 | 37 | def handle(self, *args, **options): 38 | self.verbosity = options["verbosity"] 39 | 40 | to_process = set() 41 | model_strings = options.get("models", []) or args 42 | 43 | if model_strings: 44 | for model_pair in self._handle_model_list(*model_strings): 45 | to_process.add(model_pair) 46 | 47 | elif options["auto"]: 48 | to_process = self._auto_models() 49 | 50 | else: 51 | self.log(self.COMMAND_HINT) 52 | 53 | self._process(to_process, days_back=options["days"], dry_run=options["dry"]) 54 | 55 | def _process(self, to_process, days_back=None, dry_run=True): 56 | start_date = timezone.now() - timezone.timedelta(days=days_back) 57 | for model, history_model in to_process: 58 | history_model_manager = history_model.objects 59 | history_model_manager = history_model_manager.filter( 60 | history_date__lt=start_date 61 | ) 62 | found = history_model_manager.count() 63 | self.log(f"{model} has {found} old historical entries", 2) 64 | if not found: 65 | continue 66 | if not dry_run: 67 | history_model_manager.delete() 68 | 69 | self.log(self.DONE_CLEANING_FOR_MODEL.format(model=model, count=found)) 70 | 71 | def log(self, message, verbosity_level=1): 72 | if self.verbosity >= verbosity_level: 73 | self.stdout.write(message) 74 | -------------------------------------------------------------------------------- /simple_history/management/commands/populate_history.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.core.management.base import BaseCommand, CommandError 3 | 4 | from ... import models, utils 5 | from ...exceptions import NotHistoricalModelError 6 | 7 | get_model = apps.get_model 8 | 9 | 10 | class Command(BaseCommand): 11 | args = "" 12 | help = ( 13 | "Populates the corresponding HistoricalRecords field with " 14 | "the current state of all instances in a model" 15 | ) 16 | 17 | COMMAND_HINT = "Please specify a model or use the --auto option" 18 | MODEL_NOT_FOUND = "Unable to find model" 19 | MODEL_NOT_HISTORICAL = "No history model found" 20 | NO_REGISTERED_MODELS = "No registered models were found\n" 21 | START_SAVING_FOR_MODEL = "Saving historical records for {model}\n" 22 | DONE_SAVING_FOR_MODEL = "Finished saving historical records for {model}\n" 23 | EXISTING_HISTORY_FOUND = "Existing history found, skipping model" 24 | INVALID_MODEL_ARG = "An invalid model was specified" 25 | 26 | def add_arguments(self, parser): 27 | super().add_arguments(parser) 28 | parser.add_argument("models", nargs="*", type=str) 29 | parser.add_argument( 30 | "--auto", 31 | action="store_true", 32 | dest="auto", 33 | default=False, 34 | help="Automatically search for models with the HistoricalRecords field " 35 | "type", 36 | ) 37 | parser.add_argument( 38 | "--batchsize", 39 | action="store", 40 | dest="batchsize", 41 | default=200, 42 | type=int, 43 | help="Set a custom batch size when bulk inserting historical records.", 44 | ) 45 | 46 | def handle(self, *args, **options): 47 | self.verbosity = options["verbosity"] 48 | 49 | to_process = set() 50 | model_strings = options.get("models", []) or args 51 | 52 | if model_strings: 53 | for model_pair in self._handle_model_list(*model_strings): 54 | to_process.add(model_pair) 55 | 56 | elif options["auto"]: 57 | to_process = self._auto_models() 58 | 59 | else: 60 | if self.verbosity >= 1: 61 | self.stdout.write(self.COMMAND_HINT) 62 | 63 | self._process(to_process, batch_size=options["batchsize"]) 64 | 65 | def _auto_models(self): 66 | to_process = set() 67 | for model in models.registered_models.values(): 68 | try: # avoid issues with multi-table inheritance 69 | history_model = utils.get_history_model_for_model(model) 70 | except NotHistoricalModelError: 71 | continue 72 | to_process.add((model, history_model)) 73 | if not to_process: 74 | if self.verbosity >= 1: 75 | self.stdout.write(self.NO_REGISTERED_MODELS) 76 | return to_process 77 | 78 | def _handle_model_list(self, *args): 79 | failing = False 80 | for natural_key in args: 81 | try: 82 | model, history = self._model_from_natural_key(natural_key) 83 | except ValueError as e: 84 | failing = True 85 | self.stderr.write(f"{e}\n") 86 | else: 87 | if not failing: 88 | yield (model, history) 89 | if failing: 90 | raise CommandError(self.INVALID_MODEL_ARG) 91 | 92 | def _model_from_natural_key(self, natural_key): 93 | try: 94 | app_label, model = natural_key.split(".", 1) 95 | except ValueError: 96 | model = None 97 | else: 98 | try: 99 | model = get_model(app_label, model) 100 | except LookupError: 101 | model = None 102 | if not model: 103 | msg = self.MODEL_NOT_FOUND + f" < {natural_key} >\n" 104 | raise ValueError(msg) 105 | try: 106 | history_model = utils.get_history_model_for_model(model) 107 | except NotHistoricalModelError: 108 | msg = self.MODEL_NOT_HISTORICAL + f" < {natural_key} >\n" 109 | raise ValueError(msg) 110 | return model, history_model 111 | 112 | def _bulk_history_create(self, model, batch_size): 113 | """Save a copy of all instances to the historical model. 114 | 115 | :param model: Model you want to bulk create 116 | :param batch_size: number of models to create at once. 117 | :return: 118 | """ 119 | 120 | instances = [] 121 | history = utils.get_history_manager_for_model(model) 122 | if self.verbosity >= 1: 123 | self.stdout.write( 124 | "Starting bulk creating history models for {} instances {}-{}".format( 125 | model, 0, batch_size 126 | ) 127 | ) 128 | 129 | iterator_kwargs = {"chunk_size": batch_size} 130 | for index, instance in enumerate( 131 | model._default_manager.iterator(**iterator_kwargs) 132 | ): 133 | # Can't Just pass batch_size to bulk_create as this can lead to 134 | # Out of Memory Errors as we load too many models into memory after 135 | # creating them. So we only keep batch_size worth of models in 136 | # historical_instances and clear them after we hit batch_size 137 | if index % batch_size == 0: 138 | history.bulk_history_create(instances, batch_size=batch_size) 139 | 140 | instances = [] 141 | 142 | if self.verbosity >= 1: 143 | self.stdout.write( 144 | "Finished bulk creating history models for {} " 145 | "instances {}-{}, starting next {}".format( 146 | model, index - batch_size, index, batch_size 147 | ) 148 | ) 149 | 150 | instances.append(instance) 151 | 152 | # create any we didn't get in the last loop 153 | if instances: 154 | history.bulk_history_create(instances, batch_size=batch_size) 155 | 156 | def _process(self, to_process, batch_size): 157 | for model, history_model in to_process: 158 | if history_model.objects.exists(): 159 | self.stderr.write( 160 | "{msg} {model}\n".format( 161 | msg=self.EXISTING_HISTORY_FOUND, model=model 162 | ) 163 | ) 164 | continue 165 | if self.verbosity >= 1: 166 | self.stdout.write(self.START_SAVING_FOR_MODEL.format(model=model)) 167 | self._bulk_history_create(model, batch_size) 168 | if self.verbosity >= 1: 169 | self.stdout.write(self.DONE_SAVING_FOR_MODEL.format(model=model)) 170 | -------------------------------------------------------------------------------- /simple_history/middleware.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | 3 | from asgiref.sync import iscoroutinefunction 4 | from django.utils.decorators import sync_and_async_middleware 5 | 6 | from .models import HistoricalRecords 7 | 8 | 9 | @contextmanager 10 | def _context_manager(request): 11 | HistoricalRecords.context.request = request 12 | 13 | try: 14 | yield None 15 | finally: 16 | try: 17 | del HistoricalRecords.context.request 18 | except AttributeError: 19 | pass 20 | 21 | 22 | @sync_and_async_middleware 23 | def HistoryRequestMiddleware(get_response): 24 | """Expose request to HistoricalRecords. 25 | 26 | This middleware sets request as a local context/thread variable, making it 27 | available to the model-level utilities to allow tracking of the authenticated user 28 | making a change. 29 | """ 30 | 31 | if iscoroutinefunction(get_response): 32 | 33 | async def middleware(request): 34 | with _context_manager(request): 35 | return await get_response(request) 36 | 37 | else: 38 | 39 | def middleware(request): 40 | with _context_manager(request): 41 | return get_response(request) 42 | 43 | return middleware 44 | -------------------------------------------------------------------------------- /simple_history/registry_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/registry_tests/__init__.py -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/registry_tests/migration_test_app/__init__.py -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 1.9.12 on 2017-01-18 21:58 2 | import django.db.models.deletion 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="DoYouKnow", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ) 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name="HistoricalYar", 29 | fields=[ 30 | ( 31 | "id", 32 | models.IntegerField( 33 | auto_created=True, blank=True, db_index=True, verbose_name="ID" 34 | ), 35 | ), 36 | ("history_id", models.AutoField(primary_key=True, serialize=False)), 37 | ("history_date", models.DateTimeField()), 38 | ( 39 | "history_type", 40 | models.CharField( 41 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], 42 | max_length=1, 43 | ), 44 | ), 45 | ("history_change_reason", models.CharField(max_length=100, null=True)), 46 | ( 47 | "history_user", 48 | models.ForeignKey( 49 | null=True, 50 | on_delete=django.db.models.deletion.SET_NULL, 51 | related_name="+", 52 | to=settings.AUTH_USER_MODEL, 53 | ), 54 | ), 55 | ], 56 | options={ 57 | "verbose_name": "historical yar", 58 | "ordering": ("-history_date", "-history_id"), 59 | "get_latest_by": "history_date", 60 | }, 61 | ), 62 | migrations.CreateModel( 63 | name="Yar", 64 | fields=[ 65 | ( 66 | "id", 67 | models.AutoField( 68 | auto_created=True, 69 | primary_key=True, 70 | serialize=False, 71 | verbose_name="ID", 72 | ), 73 | ) 74 | ], 75 | ), 76 | migrations.CreateModel( 77 | name="WhatIMean", 78 | fields=[ 79 | ( 80 | "doyouknow_ptr", 81 | models.OneToOneField( 82 | auto_created=True, 83 | on_delete=django.db.models.deletion.CASCADE, 84 | parent_link=True, 85 | primary_key=True, 86 | serialize=False, 87 | to="migration_test_app.DoYouKnow", 88 | ), 89 | ) 90 | ], 91 | bases=("migration_test_app.doyouknow",), 92 | ), 93 | migrations.AddField( 94 | model_name="yar", 95 | name="what", 96 | field=models.ForeignKey( 97 | on_delete=django.db.models.deletion.CASCADE, 98 | to="migration_test_app.WhatIMean", 99 | ), 100 | ), 101 | migrations.AddField( 102 | model_name="historicalyar", 103 | name="what", 104 | field=models.ForeignKey( 105 | blank=True, 106 | db_constraint=False, 107 | null=True, 108 | on_delete=django.db.models.deletion.DO_NOTHING, 109 | related_name="+", 110 | to="migration_test_app.WhatIMean", 111 | ), 112 | ), 113 | ] 114 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1 on 2018-10-19 21:53 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import simple_history.models 8 | 9 | from .. import models as my_models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ("migration_test_app", "0001_initial"), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="HistoricalModelWithCustomAttrForeignKey", 21 | fields=[ 22 | ( 23 | "id", 24 | models.IntegerField( 25 | auto_created=True, blank=True, db_index=True, verbose_name="ID" 26 | ), 27 | ), 28 | ("history_id", models.AutoField(primary_key=True, serialize=False)), 29 | ("history_change_reason", models.CharField(max_length=100, null=True)), 30 | ("history_date", models.DateTimeField()), 31 | ( 32 | "history_type", 33 | models.CharField( 34 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], 35 | max_length=1, 36 | ), 37 | ), 38 | ( 39 | "history_user", 40 | models.ForeignKey( 41 | null=True, 42 | on_delete=django.db.models.deletion.SET_NULL, 43 | related_name="+", 44 | to=settings.AUTH_USER_MODEL, 45 | ), 46 | ), 47 | ( 48 | "what_i_mean", 49 | my_models.CustomAttrNameForeignKey( 50 | attr_name="custom_attr_name", 51 | blank=True, 52 | db_constraint=False, 53 | null=True, 54 | on_delete=django.db.models.deletion.DO_NOTHING, 55 | related_name="+", 56 | to="migration_test_app.WhatIMean", 57 | ), 58 | ), 59 | ], 60 | options={ 61 | "verbose_name": "historical model with custom attr foreign key", 62 | "ordering": ("-history_date", "-history_id"), 63 | "get_latest_by": "history_date", 64 | }, 65 | bases=(simple_history.models.HistoricalChanges, models.Model), 66 | ), 67 | migrations.CreateModel( 68 | name="ModelWithCustomAttrForeignKey", 69 | fields=[ 70 | ( 71 | "id", 72 | models.AutoField( 73 | auto_created=True, 74 | primary_key=True, 75 | serialize=False, 76 | verbose_name="ID", 77 | ), 78 | ), 79 | ( 80 | "what_i_mean", 81 | my_models.CustomAttrNameForeignKey( 82 | attr_name="custom_attr_name", 83 | on_delete=django.db.models.deletion.CASCADE, 84 | to="migration_test_app.WhatIMean", 85 | ), 86 | ), 87 | ], 88 | ), 89 | ] 90 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.dev20210802171549 on 2021-08-11 11:05 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "migration_test_app", 10 | "0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="historicalmodelwithcustomattrforeignkey", 17 | options={ 18 | "get_latest_by": ("history_date", "history_id"), 19 | "ordering": ("-history_date", "-history_id"), 20 | "verbose_name": "historical model with custom attr foreign key", 21 | }, 22 | ), 23 | migrations.AlterModelOptions( 24 | name="historicalyar", 25 | options={ 26 | "get_latest_by": ("history_date", "history_id"), 27 | "ordering": ("-history_date", "-history_id"), 28 | "verbose_name": "historical yar", 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0004_history_date_indexing.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.dev20210811195242 on 2021-08-13 10:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "migration_test_app", 10 | "0003_alter_historicalmodelwithcustomattrforeignkey_options_and_more", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="historicalmodelwithcustomattrforeignkey", 17 | name="history_date", 18 | field=models.DateTimeField(db_index=True), 19 | ), 20 | migrations.AlterField( 21 | model_name="historicalyar", 22 | name="history_date", 23 | field=models.DateTimeField(db_index=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-08-24 10:36 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import simple_history.models 8 | import simple_history.registry_tests.migration_test_app.models 9 | 10 | 11 | class Migration(migrations.Migration): 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ( 15 | "migration_test_app", 16 | "0004_history_date_indexing", 17 | ), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="ModelWithCustomAttrOneToOneField", 23 | fields=[ 24 | ( 25 | "id", 26 | models.AutoField( 27 | auto_created=True, 28 | primary_key=True, 29 | serialize=False, 30 | verbose_name="ID", 31 | ), 32 | ), 33 | ( 34 | "what_i_mean", 35 | simple_history.registry_tests.migration_test_app.models.CustomAttrNameOneToOneField( 36 | attr_name="custom_attr_name", 37 | on_delete=django.db.models.deletion.CASCADE, 38 | to="migration_test_app.whatimean", 39 | ), 40 | ), 41 | ], 42 | ), 43 | migrations.CreateModel( 44 | name="HistoricalModelWithCustomAttrOneToOneField", 45 | fields=[ 46 | ( 47 | "id", 48 | models.IntegerField( 49 | auto_created=True, blank=True, db_index=True, verbose_name="ID" 50 | ), 51 | ), 52 | ("history_id", models.AutoField(primary_key=True, serialize=False)), 53 | ("history_date", models.DateTimeField()), 54 | ("history_change_reason", models.CharField(max_length=100, null=True)), 55 | ( 56 | "history_type", 57 | models.CharField( 58 | choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], 59 | max_length=1, 60 | ), 61 | ), 62 | ( 63 | "history_user", 64 | models.ForeignKey( 65 | null=True, 66 | on_delete=django.db.models.deletion.SET_NULL, 67 | related_name="+", 68 | to=settings.AUTH_USER_MODEL, 69 | ), 70 | ), 71 | ( 72 | "what_i_mean", 73 | models.ForeignKey( 74 | blank=True, 75 | db_constraint=False, 76 | null=True, 77 | on_delete=django.db.models.deletion.DO_NOTHING, 78 | related_name="+", 79 | to="migration_test_app.whatimean", 80 | ), 81 | ), 82 | ], 83 | options={ 84 | "verbose_name": "historical model with custom attr one to one field", 85 | "ordering": ("-history_date", "-history_id"), 86 | "get_latest_by": "history_date", 87 | }, 88 | bases=(simple_history.models.HistoricalChanges, models.Model), 89 | ), 90 | ] 91 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.dev20211006030854 on 2021-10-07 13:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "migration_test_app", 10 | "0005_historicalmodelwithcustomattronetoonefield_modelwithcustomattronetoonefield", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="historicalmodelwithcustomattronetoonefield", 17 | options={ 18 | "get_latest_by": ("history_date", "history_id"), 19 | "ordering": ("-history_date", "-history_id"), 20 | "verbose_name": "historical model with custom attr one to one field", 21 | }, 22 | ), 23 | migrations.AlterField( 24 | model_name="historicalmodelwithcustomattronetoonefield", 25 | name="history_date", 26 | field=models.DateTimeField(db_index=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/0007_alter_historicalmodelwithcustomattrforeignkey_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.1 on 2022-01-28 11:26 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "migration_test_app", 10 | "0006_alter_historicalmodelwithcustomattronetoonefield_options_and_more", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="historicalmodelwithcustomattrforeignkey", 17 | options={ 18 | "get_latest_by": ("history_date", "history_id"), 19 | "ordering": ("-history_date", "-history_id"), 20 | "verbose_name": "historical model with custom attr foreign key", 21 | "verbose_name_plural": "historical model with custom attr foreign keys", 22 | }, 23 | ), 24 | migrations.AlterModelOptions( 25 | name="historicalmodelwithcustomattronetoonefield", 26 | options={ 27 | "get_latest_by": ("history_date", "history_id"), 28 | "ordering": ("-history_date", "-history_id"), 29 | "verbose_name": "historical model with custom attr one to one field", 30 | "verbose_name_plural": "historical model with custom attr one to one fields", 31 | }, 32 | ), 33 | migrations.AlterModelOptions( 34 | name="historicalyar", 35 | options={ 36 | "get_latest_by": ("history_date", "history_id"), 37 | "ordering": ("-history_date", "-history_id"), 38 | "verbose_name": "historical yar", 39 | "verbose_name_plural": "historical yars", 40 | }, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/registry_tests/migration_test_app/migrations/__init__.py -------------------------------------------------------------------------------- /simple_history/registry_tests/migration_test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from simple_history.models import HistoricalRecords 4 | 5 | 6 | class DoYouKnow(models.Model): 7 | pass 8 | 9 | 10 | class WhatIMean(DoYouKnow): 11 | pass 12 | 13 | 14 | class Yar(models.Model): 15 | what = models.ForeignKey(WhatIMean, on_delete=models.CASCADE) 16 | history = HistoricalRecords() 17 | 18 | 19 | class CustomAttrNameForeignKey(models.ForeignKey): 20 | def __init__(self, *args, **kwargs): 21 | self.attr_name = kwargs.pop("attr_name", None) 22 | super().__init__(*args, **kwargs) 23 | 24 | def get_attname(self): 25 | return self.attr_name or super().get_attname() 26 | 27 | def deconstruct(self): 28 | name, path, args, kwargs = super().deconstruct() 29 | if self.attr_name: 30 | kwargs["attr_name"] = self.attr_name 31 | return name, path, args, kwargs 32 | 33 | 34 | class ModelWithCustomAttrForeignKey(models.Model): 35 | what_i_mean = CustomAttrNameForeignKey( 36 | WhatIMean, models.CASCADE, attr_name="custom_attr_name" 37 | ) 38 | history = HistoricalRecords() 39 | 40 | 41 | class CustomAttrNameOneToOneField(models.OneToOneField): 42 | def __init__(self, *args, **kwargs): 43 | self.attr_name = kwargs.pop("attr_name", None) 44 | super().__init__(*args, **kwargs) 45 | 46 | def get_attname(self): 47 | return self.attr_name or super().get_attname() 48 | 49 | def deconstruct(self): 50 | name, path, args, kwargs = super().deconstruct() 51 | if self.attr_name: 52 | kwargs["attr_name"] = self.attr_name 53 | return name, path, args, kwargs 54 | 55 | 56 | class ModelWithCustomAttrOneToOneField(models.Model): 57 | what_i_mean = CustomAttrNameOneToOneField( 58 | WhatIMean, models.CASCADE, attr_name="custom_attr_name" 59 | ) 60 | history = HistoricalRecords(excluded_field_kwargs={"what_i_mean": {"attr_name"}}) 61 | -------------------------------------------------------------------------------- /simple_history/registry_tests/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/registry_tests/models.py -------------------------------------------------------------------------------- /simple_history/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | 3 | # Arguments: "instance", "history_instance", "history_date", 4 | # "history_user", "history_change_reason", "using" 5 | pre_create_historical_record = django.dispatch.Signal() 6 | 7 | # Arguments: "instance", "history_instance", "history_date", 8 | # "history_user", "history_change_reason", "using" 9 | post_create_historical_record = django.dispatch.Signal() 10 | 11 | # Arguments: "sender", "rows", "history_instance", "instance", 12 | # "field" 13 | pre_create_historical_m2m_records = django.dispatch.Signal() 14 | 15 | # Arguments: "sender", "created_rows", "history_instance", 16 | # "instance", "field" 17 | post_create_historical_m2m_records = django.dispatch.Signal() 18 | -------------------------------------------------------------------------------- /simple_history/templates/simple_history/object_history.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/object_history.html" %} 2 | {% load i18n %} 3 | 4 | 5 | {% block content %} 6 |
7 | {% if not revert_disabled %}

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

{% endif %} 9 |
10 | {% if page_obj.object_list %} 11 | {% include object_history_list_template %} 12 | {% else %} 13 |

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

14 | {% endif %} 15 |
16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /simple_history/templates/simple_history/object_history_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/change_form.html" %} 2 | {% load i18n admin_urls %} 3 | {% load url from simple_history_compat %} 4 | 5 | {% block breadcrumbs %} 6 | 14 | {% endblock %} 15 | 16 | {% block submit_buttons_top %} 17 | {% include "simple_history/submit_line.html" %} 18 | {% endblock %} 19 | 20 | {% block submit_buttons_bottom %} 21 | {% include "simple_history/submit_line.html" %} 22 | {% endblock %} 23 | 24 | {% block object-tools-items %} 25 | {# We override this block from the django template to fix up the history link #} 26 |
  • 27 | {% translate "History" %} 28 |
  • 29 | {% if has_absolute_url %}
  • {% translate "View on site" %}
  • {% endif %} 30 | {% endblock %} 31 | 32 | {% block form_top %} 33 |

    {% 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 %}

    34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /simple_history/templates/simple_history/object_history_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load url from simple_history_compat %} 3 | {% load admin_urls %} 4 | {% load getattribute from getattributes %} 5 | 6 | 7 | 8 | 9 | 10 | {% for column in history_list_display %} 11 | 12 | {% endfor %} 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% for record in page_obj %} 22 | 23 | 28 | {% for column in history_list_display %} 29 | 30 | {% endfor %} 31 | 32 | 33 | 45 | 48 | 64 | 65 | {% endfor %} 66 | 67 |
    {% trans 'Object' %}{% trans column %}{% trans 'Date/time' %}{% trans 'Comment' %}{% trans 'Changed by' %}{% trans 'Change reason' %}{% trans 'Changes' %}
    24 | 25 | {{ record.history_object }} 26 | 27 | {{ record|getattribute:column }}{{ record.history_date }}{{ record.get_history_type_display }} 34 | {% if record.history_user %} 35 | {% url admin_user_view record.history_user_id as admin_user_url %} 36 | {% if admin_user_url %} 37 | {{ record.history_user }} 38 | {% else %} 39 | {{ record.history_user }} 40 | {% endif %} 41 | {% else %} 42 | {% trans "None" %} 43 | {% endif %} 44 | 46 | {{ record.history_change_reason }} 47 | 49 | {% block history_delta_changes %} 50 | {% if record.history_delta_changes %} 51 |
      52 | {% for change in record.history_delta_changes %} 53 |
    • 54 | {{ change.field }}: 55 | {{ change.old }} 56 | {# Add some spacing, and prevent having the arrow point to the edge of the page if `new` is wrapped #} 57 |  →  {{ change.new }} 58 |
    • 59 | {% endfor %} 60 |
    61 | {% endif %} 62 | {% endblock %} 63 |
    68 | 69 |

    70 | {% if pagination_required %} 71 | {% for i in page_range %} 72 | {% if i == page_obj.paginator.ELLIPSIS %} 73 | {{ page_obj.paginator.ELLIPSIS }} 74 | {% elif i == page_obj.number %} 75 | {{ i }} 76 | {% else %} 77 | {{ i }} 78 | {% endif %} 79 | {% endfor %} 80 | {% endif %} 81 | {{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %} 82 |

    83 | -------------------------------------------------------------------------------- /simple_history/templates/simple_history/submit_line.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
    3 | {% if not revert_disabled %} 4 | {% endif %} 5 | {% if change_history %} 6 | {% endif %} 7 | {% trans 'Close' %} 8 |
    9 | -------------------------------------------------------------------------------- /simple_history/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/templatetags/__init__.py -------------------------------------------------------------------------------- /simple_history/templatetags/getattributes.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | def getattribute(value, arg): 7 | """Gets an attribute of an object dynamically from a string name""" 8 | 9 | return getattr(value, arg, None) 10 | 11 | 12 | register.filter("getattribute", getattribute) 13 | -------------------------------------------------------------------------------- /simple_history/templatetags/simple_history_compat.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.template.defaulttags import url 3 | 4 | register = template.Library() 5 | 6 | register.tag(url) 7 | -------------------------------------------------------------------------------- /simple_history/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/tests/__init__.py -------------------------------------------------------------------------------- /simple_history/tests/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.safestring import SafeString, mark_safe 3 | 4 | from simple_history.admin import SimpleHistoryAdmin 5 | from simple_history.template_utils import HistoricalRecordContextHelper 6 | from simple_history.tests.external.models import ExternalModelWithCustomUserIdField 7 | 8 | from .models import ( 9 | Book, 10 | Choice, 11 | ConcreteExternal, 12 | Document, 13 | Employee, 14 | FileModel, 15 | Paper, 16 | Person, 17 | Place, 18 | Planet, 19 | Poll, 20 | PollWithManyToMany, 21 | ) 22 | 23 | 24 | class PersonAdmin(SimpleHistoryAdmin): 25 | def has_change_permission(self, request, obj=None): 26 | return False 27 | 28 | def has_view_permission(self, request, obj=None): 29 | return False 30 | 31 | 32 | class ChoiceAdmin(SimpleHistoryAdmin): 33 | history_list_display = ["votes"] 34 | 35 | 36 | class FileModelAdmin(SimpleHistoryAdmin): 37 | def test_method(self, obj): 38 | return "test_method_value" 39 | 40 | history_list_display = ["title", "test_method"] 41 | 42 | 43 | class PlanetAdmin(SimpleHistoryAdmin): 44 | def test_method(self, obj): 45 | return "test_method_value" 46 | 47 | history_list_display = ["title", "test_method"] 48 | 49 | 50 | class HistoricalPollWithManyToManyContextHelper(HistoricalRecordContextHelper): 51 | def prepare_delta_change_value(self, change, value): 52 | display_value = super().prepare_delta_change_value(change, value) 53 | if change.field == "places": 54 | assert isinstance(display_value, list) 55 | assert all(isinstance(place, Place) for place in display_value) 56 | 57 | places = sorted(display_value, key=lambda place: place.name) 58 | display_value = list(map(self.place_display, places)) 59 | return display_value 60 | 61 | @staticmethod 62 | def place_display(place: Place) -> SafeString: 63 | return mark_safe(f"{place.name}") 64 | 65 | 66 | class PollWithManyToManyAdmin(SimpleHistoryAdmin): 67 | def get_historical_record_context_helper(self, request, historical_record): 68 | return HistoricalPollWithManyToManyContextHelper(self.model, historical_record) 69 | 70 | 71 | admin.site.register(Book, SimpleHistoryAdmin) 72 | admin.site.register(Choice, ChoiceAdmin) 73 | admin.site.register(ConcreteExternal, SimpleHistoryAdmin) 74 | admin.site.register(Document, SimpleHistoryAdmin) 75 | admin.site.register(Employee, SimpleHistoryAdmin) 76 | admin.site.register(ExternalModelWithCustomUserIdField, SimpleHistoryAdmin) 77 | admin.site.register(FileModel, FileModelAdmin) 78 | admin.site.register(Paper, SimpleHistoryAdmin) 79 | admin.site.register(Person, PersonAdmin) 80 | admin.site.register(Planet, PlanetAdmin) 81 | admin.site.register(Poll, SimpleHistoryAdmin) 82 | admin.site.register(PollWithManyToMany, PollWithManyToManyAdmin) 83 | -------------------------------------------------------------------------------- /simple_history/tests/custom_user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/tests/custom_user/__init__.py -------------------------------------------------------------------------------- /simple_history/tests/custom_user/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin 3 | 4 | from .models import CustomUser 5 | 6 | admin.site.register(CustomUser, UserAdmin) 7 | -------------------------------------------------------------------------------- /simple_history/tests/custom_user/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | 3 | 4 | class CustomUser(AbstractUser): 5 | pass 6 | -------------------------------------------------------------------------------- /simple_history/tests/external/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/tests/external/__init__.py -------------------------------------------------------------------------------- /simple_history/tests/external/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from simple_history import register 4 | from simple_history.models import HistoricalRecords 5 | from simple_history.tests.custom_user.models import CustomUser 6 | 7 | 8 | class AbstractExternal(models.Model): 9 | history = HistoricalRecords(inherit=True) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | 15 | class AbstractExternal2(models.Model): 16 | history = HistoricalRecords(inherit=True, custom_model_name=lambda x: f"Audit{x}") 17 | 18 | class Meta: 19 | abstract = True 20 | app_label = "external" 21 | 22 | 23 | class AbstractExternal3(models.Model): 24 | history = HistoricalRecords( 25 | inherit=True, app="external", custom_model_name=lambda x: f"Audit{x}" 26 | ) 27 | 28 | class Meta: 29 | abstract = True 30 | app_label = "external" 31 | 32 | 33 | class ExternalModel(models.Model): 34 | name = models.CharField(max_length=100) 35 | history = HistoricalRecords() 36 | 37 | 38 | class ExternalModelRegistered(models.Model): 39 | name = models.CharField(max_length=100) 40 | 41 | 42 | register(ExternalModelRegistered, app="simple_history.tests", manager_name="histories") 43 | 44 | 45 | class ExternalModelWithCustomUserIdField(models.Model): 46 | name = models.CharField(max_length=100) 47 | history = HistoricalRecords(history_user_id_field=models.IntegerField(null=True)) 48 | 49 | 50 | class Poll(models.Model): 51 | """Test model for same-named historical models 52 | 53 | This model intentionally conflicts with the 'Polls' model in 'tests.models'. 54 | """ 55 | 56 | history = HistoricalRecords(user_related_name="+") 57 | -------------------------------------------------------------------------------- /simple_history/tests/generated_file_checks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-simple-history/8bb61757cb9563c2fd0c17e1fa22c53013c311e3/simple_history/tests/generated_file_checks/__init__.py -------------------------------------------------------------------------------- /simple_history/tests/generated_file_checks/check_translations.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from glob import glob 4 | from pathlib import Path 5 | 6 | import django 7 | from django.conf import settings 8 | from django.core.management import call_command 9 | 10 | from runtests import get_default_settings 11 | 12 | 13 | def log(*args, **kwargs): 14 | # Flush so that all printed messages appear in the correct order, not matter what 15 | # `file` argument is passed (e.g. `sys.stdout` (default) or `sys.stderr`) 16 | print(*args, **{"flush": True, **kwargs}) 17 | 18 | 19 | def log_err(*args, **kwargs): 20 | log(*args, **{"file": sys.stderr, **kwargs}) 21 | 22 | 23 | # For some reason, changes in the .po files are often not reflected in the .mo files 24 | # when running 'compilemessages' - unless the .mo files are deleted first, 25 | # in which case they seem to be consistently updated 26 | def delete_mo_files(): 27 | locale_dir = Path("simple_history/locale") 28 | log( 29 | f"Deleting the following files inside '{locale_dir}'" 30 | f" so that they can be regenerated by 'compilemessages':" 31 | ) 32 | for file_path in glob("**/*.mo", root_dir=locale_dir, recursive=True): 33 | log(f"\t{file_path}") 34 | (locale_dir / file_path).unlink() 35 | 36 | 37 | # Code based on 38 | # https://github.com/stefanfoulis/django-phonenumber-field/blob/e653a0972b56d39f1f51fa1f5124b70c2a5549bc/check-translations 39 | def main(): 40 | # Delete the .mo files before regenerating them below 41 | delete_mo_files() 42 | 43 | log("Running 'compilemessages'...") 44 | call_command("compilemessages") 45 | 46 | log("\nRunning 'git status'...") 47 | result = subprocess.run( 48 | ["git", "status", "--porcelain"], 49 | check=True, 50 | stdout=subprocess.PIPE, 51 | ) 52 | assert result.stderr is None 53 | stdout = result.stdout.decode() 54 | if stdout: 55 | log_err(f"Unexpected changes found in the workspace:\n\n{stdout}") 56 | if ".mo" in stdout: 57 | log_err( 58 | "To properly update any '.mo' files," 59 | " try deleting them before running 'compilemessages'." 60 | ) 61 | sys.exit(1) 62 | else: 63 | # Print the human-readable status to the console 64 | subprocess.run(["git", "status"]) 65 | 66 | 67 | if __name__ == "__main__": 68 | if not settings.configured: 69 | settings.configure(**get_default_settings()) 70 | django.setup() 71 | main() 72 | -------------------------------------------------------------------------------- /simple_history/tests/other_admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.sites import AdminSite 2 | 3 | from simple_history.admin import SimpleHistoryAdmin 4 | 5 | from .models import State 6 | 7 | site = AdminSite(name="other_admin") 8 | 9 | site.register(State, SimpleHistoryAdmin) 10 | -------------------------------------------------------------------------------- /simple_history/tests/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_admin import * 2 | from .test_commands import * 3 | from .test_manager import * 4 | from .test_models import * 5 | -------------------------------------------------------------------------------- /simple_history/tests/tests/test_deprecation.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class DeprecationWarningTest(unittest.TestCase): 5 | """Tests that check whether ``DeprecationWarning`` is raised for certain features, 6 | and that compare ``simple_history.__version__`` against the version the features 7 | will be removed in. 8 | 9 | If this class is empty, it normally means that nothing is currently deprecated. 10 | """ 11 | -------------------------------------------------------------------------------- /simple_history/tests/tests/test_index.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.test import TestCase, override_settings 4 | 5 | from simple_history.models import HistoricalRecords 6 | 7 | 8 | @override_settings(SIMPLE_HISTORY_DATE_INDEX="Composite") 9 | class HistoricalIndexTest(TestCase): 10 | def test_has_composite_index(self): 11 | self.assertEqual(settings.SIMPLE_HISTORY_DATE_INDEX, "Composite") 12 | 13 | class Foo(models.Model): 14 | history = HistoricalRecords() 15 | 16 | self.assertEqual( 17 | ["history_date", "id"], Foo.history.model._meta.indexes[0].fields 18 | ) 19 | -------------------------------------------------------------------------------- /simple_history/tests/tests/test_signals.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.test import TestCase 4 | 5 | from simple_history.signals import ( 6 | post_create_historical_m2m_records, 7 | post_create_historical_record, 8 | pre_create_historical_m2m_records, 9 | pre_create_historical_record, 10 | ) 11 | 12 | from ..models import Place, Poll, PollWithManyToMany 13 | 14 | today = datetime(2021, 1, 1, 10, 0) 15 | 16 | 17 | class PrePostCreateHistoricalRecordSignalTest(TestCase): 18 | def setUp(self): 19 | self.signal_was_called = False 20 | self.signal_instance = None 21 | self.signal_history_instance = None 22 | self.signal_sender = None 23 | self.field = None 24 | self.rows = None 25 | 26 | def test_pre_create_historical_record_signal(self): 27 | def handler(sender, instance, **kwargs): 28 | self.signal_was_called = True 29 | self.signal_instance = instance 30 | self.signal_history_instance = kwargs["history_instance"] 31 | self.signal_sender = sender 32 | 33 | pre_create_historical_record.connect(handler) 34 | 35 | p = Poll(question="what's up?", pub_date=today) 36 | p.save() 37 | 38 | self.assertTrue(self.signal_was_called) 39 | self.assertEqual(self.signal_instance, p) 40 | self.assertIsNotNone(self.signal_history_instance) 41 | self.assertEqual(self.signal_sender, p.history.first().__class__) 42 | 43 | def test_post_create_historical_record_signal(self): 44 | def handler(sender, instance, history_instance, **kwargs): 45 | self.signal_was_called = True 46 | self.signal_instance = instance 47 | self.signal_history_instance = history_instance 48 | self.signal_sender = sender 49 | 50 | post_create_historical_record.connect(handler) 51 | 52 | p = Poll(question="what's up?", pub_date=today) 53 | p.save() 54 | 55 | self.assertTrue(self.signal_was_called) 56 | self.assertEqual(self.signal_instance, p) 57 | self.assertIsNotNone(self.signal_history_instance) 58 | self.assertEqual(self.signal_sender, p.history.first().__class__) 59 | 60 | def test_pre_create_historical_m2m_records_signal(self): 61 | def handler(sender, rows, history_instance, instance, field, **kwargs): 62 | self.signal_was_called = True 63 | self.signal_instance = instance 64 | self.signal_history_instance = history_instance 65 | self.signal_sender = sender 66 | self.rows = rows 67 | self.field = field 68 | 69 | pre_create_historical_m2m_records.connect(handler) 70 | 71 | p = PollWithManyToMany( 72 | question="what's up?", 73 | pub_date=today, 74 | ) 75 | p.save() 76 | self.setUp() 77 | p.places.add( 78 | Place.objects.create(name="London"), Place.objects.create(name="Paris") 79 | ) 80 | 81 | self.assertTrue(self.signal_was_called) 82 | self.assertEqual(self.signal_instance, p) 83 | self.assertIsNotNone(self.signal_history_instance) 84 | self.assertEqual(self.signal_sender, p.history.first().places.model) 85 | self.assertEqual(self.field, PollWithManyToMany._meta.many_to_many[0]) 86 | self.assertEqual(len(self.rows), 2) 87 | 88 | def test_post_create_historical_m2m_records_signal(self): 89 | def handler(sender, created_rows, history_instance, instance, field, **kwargs): 90 | self.signal_was_called = True 91 | self.signal_instance = instance 92 | self.signal_history_instance = history_instance 93 | self.signal_sender = sender 94 | self.rows = created_rows 95 | self.field = field 96 | 97 | post_create_historical_m2m_records.connect(handler) 98 | 99 | p = PollWithManyToMany( 100 | question="what's up?", 101 | pub_date=today, 102 | ) 103 | p.save() 104 | self.setUp() 105 | p.places.add( 106 | Place.objects.create(name="London"), Place.objects.create(name="Paris") 107 | ) 108 | 109 | self.assertTrue(self.signal_was_called) 110 | self.assertEqual(self.signal_instance, p) 111 | self.assertIsNotNone(self.signal_history_instance) 112 | self.assertEqual(self.signal_sender, p.history.first().places.model) 113 | self.assertEqual(self.field, PollWithManyToMany._meta.many_to_many[0]) 114 | self.assertEqual(len(self.rows), 2) 115 | -------------------------------------------------------------------------------- /simple_history/tests/tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from simple_history.templatetags.getattributes import getattribute 4 | 5 | 6 | class Foo: 7 | bar = "bar" 8 | 9 | 10 | class TestGetAttributes(TestCase): 11 | def test_get_existing_attributes_return_it(self): 12 | self.assertEqual(getattribute(Foo(), "bar"), "bar") 13 | 14 | def test_get_missing_attributes_return_None(self): 15 | self.assertIsNone(getattribute(Foo(), "baz")) 16 | -------------------------------------------------------------------------------- /simple_history/tests/tests/utils.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from django.conf import settings 4 | from django.db.models import Model 5 | from django.test import TestCase 6 | 7 | request_middleware = "simple_history.middleware.HistoryRequestMiddleware" 8 | 9 | OTHER_DB_NAME = "other" 10 | 11 | middleware_override_settings = { 12 | "MIDDLEWARE": (settings.MIDDLEWARE + [request_middleware]) 13 | } 14 | 15 | 16 | class HistoricalTestCase(TestCase): 17 | def assertRecordValues(self, record, klass: type[Model], values_dict: dict): 18 | """ 19 | Fail if ``record`` doesn't contain the field values in ``values_dict``. 20 | ``record.history_object`` is also checked. 21 | History-tracked fields in ``record`` that are not in ``values_dict``, are not 22 | checked. 23 | 24 | :param record: A historical record. 25 | :param klass: The type of the history-tracked class of ``record``. 26 | :param values_dict: Field names of ``record`` mapped to their expected values. 27 | """ 28 | for key, value in values_dict.items(): 29 | self.assertEqual(getattr(record, key), value) 30 | 31 | self.assertEqual(record.history_object.__class__, klass) 32 | for key, value in values_dict.items(): 33 | if key not in ("history_type", "history_change_reason"): 34 | self.assertEqual(getattr(record.history_object, key), value) 35 | 36 | 37 | class TestDbRouter: 38 | def db_for_read(self, model, **hints): 39 | if model._meta.app_label == "external": 40 | return OTHER_DB_NAME 41 | return None 42 | 43 | def db_for_write(self, model, **hints): 44 | if model._meta.app_label == "external": 45 | return OTHER_DB_NAME 46 | return None 47 | 48 | def allow_relation(self, obj1, obj2, **hints): 49 | if obj1._meta.app_label == "external" and obj2._meta.app_label == "external": 50 | return True 51 | return None 52 | 53 | def allow_migrate(self, db, app_label, model_name=None, **hints): 54 | if app_label == "external": 55 | return db == OTHER_DB_NAME 56 | elif db == OTHER_DB_NAME: 57 | return False 58 | else: 59 | return None 60 | 61 | 62 | database_router_override_settings = { 63 | "DATABASE_ROUTERS": ["simple_history.tests.tests.utils.TestDbRouter"] 64 | } 65 | 66 | 67 | class TestModelWithHistoryInDifferentDbRouter: 68 | def db_for_read(self, model, **hints): 69 | # Avoids circular importing 70 | from ..models import HistoricalModelWithHistoryInDifferentDb 71 | 72 | if model == HistoricalModelWithHistoryInDifferentDb: 73 | return OTHER_DB_NAME 74 | return None 75 | 76 | def db_for_write(self, model, **hints): 77 | # Avoids circular importing 78 | from ..models import HistoricalModelWithHistoryInDifferentDb 79 | 80 | if model == HistoricalModelWithHistoryInDifferentDb: 81 | return OTHER_DB_NAME 82 | return None 83 | 84 | def allow_relation(self, obj1, obj2, **hints): 85 | # Avoids circular importing 86 | from ..models import HistoricalModelWithHistoryInDifferentDb 87 | 88 | if isinstance(obj1, HistoricalModelWithHistoryInDifferentDb) or isinstance( 89 | obj2, HistoricalModelWithHistoryInDifferentDb 90 | ): 91 | return False 92 | return None 93 | 94 | def allow_migrate(self, db, app_label, model_name=None, **hints): 95 | # Avoids circular importing 96 | from ..models import HistoricalModelWithHistoryInDifferentDb 97 | 98 | if model_name == HistoricalModelWithHistoryInDifferentDb._meta.model_name: 99 | return db == OTHER_DB_NAME 100 | return None 101 | 102 | 103 | database_router_override_settings_history_in_diff_db = { 104 | "DATABASE_ROUTERS": [ 105 | "simple_history.tests.tests.utils.TestModelWithHistoryInDifferentDbRouter" 106 | ] 107 | } 108 | 109 | 110 | class PermissionAction(Enum): 111 | ADD = "add" 112 | CHANGE = "change" 113 | DELETE = "delete" 114 | VIEW = "view" 115 | -------------------------------------------------------------------------------- /simple_history/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path, re_path 3 | 4 | from simple_history.tests.view import ( 5 | BucketDataRegisterRequestUserCreate, 6 | BucketDataRegisterRequestUserDetail, 7 | MockableView, 8 | PollBulkCreateView, 9 | PollBulkCreateWithDefaultUserView, 10 | PollBulkUpdateView, 11 | PollBulkUpdateWithDefaultUserView, 12 | PollCreate, 13 | PollDelete, 14 | PollDetail, 15 | PollList, 16 | PollUpdate, 17 | PollWithHistoricalIPAddressCreate, 18 | ) 19 | 20 | from . import other_admin 21 | 22 | admin.autodiscover() 23 | 24 | urlpatterns = [ 25 | path("admin/", admin.site.urls), 26 | path("other-admin/", other_admin.site.urls), 27 | path( 28 | "bucket_data/add/", 29 | BucketDataRegisterRequestUserCreate.as_view(), 30 | name="bucket_data-add", 31 | ), 32 | re_path( 33 | r"^bucket_data/(?P[0-9]+)/$", 34 | BucketDataRegisterRequestUserDetail.as_view(), 35 | name="bucket_data-detail", 36 | ), 37 | path("poll/add/", PollCreate.as_view(), name="poll-add"), 38 | path( 39 | "pollwithhistoricalipaddress/add", 40 | PollWithHistoricalIPAddressCreate.as_view(), 41 | name="pollip-add", 42 | ), 43 | re_path(r"^poll/(?P[0-9]+)/$", PollUpdate.as_view(), name="poll-update"), 44 | re_path(r"^poll/(?P[0-9]+)/delete/$", PollDelete.as_view(), name="poll-delete"), 45 | re_path(r"^polls/(?P[0-9]+)/$", PollDetail.as_view(), name="poll-detail"), 46 | path("polls/", PollList.as_view(), name="poll-list"), 47 | path("polls-bulk-create/", PollBulkCreateView.as_view(), name="poll-bulk-create"), 48 | path( 49 | "polls-bulk-create-default-user/", 50 | PollBulkCreateWithDefaultUserView.as_view(), 51 | name="poll-bulk-create-with-default-user", 52 | ), 53 | path("polls-bulk-update/", PollBulkUpdateView.as_view(), name="poll-bulk-update"), 54 | path( 55 | "polls-bulk-update-default-user/", 56 | PollBulkUpdateWithDefaultUserView.as_view(), 57 | name="poll-bulk-update-with-default-user", 58 | ), 59 | path("mockable/", MockableView.as_view(), name="mockable"), 60 | ] 61 | -------------------------------------------------------------------------------- /simple_history/tests/view.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from django.http import HttpResponse 4 | from django.urls import reverse_lazy 5 | from django.views import View 6 | from django.views.generic import ( 7 | CreateView, 8 | DeleteView, 9 | DetailView, 10 | ListView, 11 | UpdateView, 12 | ) 13 | 14 | from simple_history.tests.custom_user.models import CustomUser 15 | from simple_history.tests.models import ( 16 | BucketDataRegisterRequestUser, 17 | Poll, 18 | PollWithHistoricalIPAddress, 19 | ) 20 | from simple_history.utils import bulk_create_with_history, bulk_update_with_history 21 | 22 | 23 | class PollCreate(CreateView): 24 | model = Poll 25 | fields = ["question", "pub_date"] 26 | 27 | 28 | class PollBulkCreateView(View): 29 | def post(self, request, *args, **kwargs): 30 | poll_info_list = [ 31 | {"question": "1", "pub_date": date(2020, 1, 1)}, 32 | {"question": "2", "pub_date": date(2020, 1, 2)}, 33 | ] 34 | polls_to_create = [Poll(**poll_info) for poll_info in poll_info_list] 35 | bulk_create_with_history(polls_to_create, Poll) 36 | return HttpResponse(status=200) 37 | 38 | 39 | class PollBulkCreateWithDefaultUserView(View): 40 | def post(self, request, *args, **kwargs): 41 | default_user = CustomUser.objects.create_superuser( 42 | "test_user", "test_user@example.com", "pass" 43 | ) 44 | # Bulk create objects with history 45 | poll_info_list = [ 46 | {"question": "1", "pub_date": date(2020, 1, 1)}, 47 | {"question": "2", "pub_date": date(2020, 1, 2)}, 48 | ] 49 | polls_to_create = [Poll(**poll_info) for poll_info in poll_info_list] 50 | bulk_create_with_history(polls_to_create, Poll, default_user=default_user) 51 | return HttpResponse(status=200) 52 | 53 | 54 | class PollBulkUpdateView(View): 55 | def post(self, request, *args, **kwargs): 56 | polls = Poll.objects.order_by("pub_date") 57 | for i, poll in enumerate(polls): 58 | poll.question = str(i) 59 | 60 | bulk_update_with_history(polls, fields=["question"], model=Poll) 61 | return HttpResponse(status=201) 62 | 63 | 64 | class PollBulkUpdateWithDefaultUserView(View): 65 | def post(self, request, *args, **kwargs): 66 | default_user = CustomUser.objects.create_superuser( 67 | "test_user", "test_user@example.com", "pass" 68 | ) 69 | polls = Poll.objects.all() 70 | for i, poll in enumerate(polls): 71 | poll.question = str(i) 72 | 73 | bulk_update_with_history( 74 | polls, fields=["question"], model=Poll, default_user=default_user 75 | ) 76 | return HttpResponse(status=201) 77 | 78 | 79 | class PollWithHistoricalIPAddressCreate(CreateView): 80 | model = PollWithHistoricalIPAddress 81 | fields = ["question", "pub_date"] 82 | 83 | 84 | class PollUpdate(UpdateView): 85 | model = Poll 86 | fields = ["question", "pub_date"] 87 | 88 | 89 | class PollDelete(DeleteView): 90 | model = Poll 91 | success_url = reverse_lazy("poll-list") 92 | 93 | 94 | class PollList(ListView): 95 | model = Poll 96 | fields = ["question", "pub_date"] 97 | 98 | 99 | class PollDetail(DetailView): 100 | model = Poll 101 | fields = ["question", "pub_date"] 102 | 103 | 104 | class BucketDataRegisterRequestUserCreate(CreateView): 105 | model = BucketDataRegisterRequestUser 106 | fields = ["data"] 107 | 108 | 109 | class BucketDataRegisterRequestUserDetail(DetailView): 110 | model = BucketDataRegisterRequestUser 111 | fields = ["data"] 112 | 113 | 114 | class MockableView(View): 115 | """This view exists to easily mock a response.""" 116 | 117 | def get(self, request, *args, **kwargs): 118 | return HttpResponse(status=200) 119 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py3{9-13}-dj42-{sqlite3,postgres,mysql,mariadb}, 4 | py3{10-13}-dj{50-52}-{sqlite3,postgres,mysql,mariadb}, 5 | py3{12-13}-dj{main}-{sqlite3,postgres,mysql,mariadb}, 6 | docs, 7 | lint 8 | 9 | [gh-actions] 10 | python = 11 | 3.9: py39 12 | 3.10: py310 13 | 3.11: py311, docs, lint 14 | 3.12: py312 15 | 3.13: py313 16 | 17 | [gh-actions:env] 18 | DJANGO = 19 | 4.2: dj42 20 | 5.0: dj50 21 | 5.1: dj51 22 | 5.2: dj52 23 | main: djmain 24 | 25 | [flake8] 26 | ignore = N802,F401,W503 27 | max-complexity = 10 28 | max-line-length = 88 29 | exclude = __init__.py,simple_history/registry_tests/migration_test_app/migrations/* 30 | 31 | [testenv] 32 | deps = 33 | -rrequirements/test.txt 34 | dj42: Django>=4.2,<4.3 35 | dj50: Django>=5.0,<5.1 36 | dj51: Django>=5.1,<5.2 37 | dj52: Django>=5.2a1,<5.3 # Use a1 to allow testing of the release candidates 38 | djmain: https://github.com/django/django/tarball/main 39 | postgres: -rrequirements/postgres.txt 40 | mysql: -rrequirements/mysql.txt 41 | mariadb: -rrequirements/mysql.txt 42 | 43 | commands = 44 | sqlite3: coverage run -a runtests.py {posargs} 45 | postgres: coverage run -a runtests.py --database=postgres {posargs} 46 | mysql: coverage run -a runtests.py --database=mysql {posargs} 47 | mariadb: coverage run -a runtests.py --database=mariadb {posargs} 48 | coverage report 49 | 50 | [testenv:format] 51 | deps = -rrequirements/lint.txt 52 | commands = 53 | isort docs simple_history runtests.py 54 | black docs simple_history runtests.py 55 | flake8 simple_history 56 | 57 | [testenv:lint] 58 | deps = pre-commit 59 | commands = 60 | pre-commit run --all-files 61 | 62 | [testenv:docs] 63 | changedir = docs 64 | deps = -rrequirements/docs.txt 65 | commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html 66 | --------------------------------------------------------------------------------