├── tests ├── __init__.py ├── test_fields │ ├── __init__.py │ ├── test_status_field.py │ ├── test_uuid_field.py │ ├── test_urlsafe_token_field.py │ ├── test_split_field.py │ └── test_monitor_field.py ├── test_managers │ ├── __init__.py │ ├── test_status_manager.py │ ├── test_query_manager.py │ ├── test_softdelete_manager.py │ └── test_join_manager.py ├── test_models │ ├── __init__.py │ ├── test_uuid_model.py │ ├── test_timeframed_model.py │ ├── test_softdeletable_model.py │ ├── test_deferred_fields.py │ ├── test_status_model.py │ └── test_timestamped_model.py ├── test_inheritance_iterable.py ├── settings.py ├── test_miscellaneous.py ├── fields.py ├── test_choices.py └── models.py ├── model_utils ├── py.typed ├── locale │ ├── cs │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── de │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fa │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── ru │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── sv │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ ├── pt_BR │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── zh_Hans │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── __init__.py ├── models.py ├── choices.py ├── fields.py └── tracker.py ├── docs ├── changelog.rst ├── setup.rst ├── index.rst ├── models.rst ├── managers.rst ├── fields.rst ├── Makefile ├── make.bat ├── conf.py └── utilities.rst ├── requirements-mypy.txt ├── requirements-test.txt ├── requirements.txt ├── .coveragerc ├── .gitignore ├── setup.cfg ├── docker-compose.yml ├── MANIFEST.in ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── issue-manager.yml │ └── test.yml ├── .readthedocs.yml ├── mypy.ini ├── .pre-commit-config.yaml ├── Makefile ├── translations.py ├── tox.ini ├── LICENSE.txt ├── README.rst ├── setup.py ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.rst └── AUTHORS.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /model_utils/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_fields/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. changelog: 2 | 3 | .. include:: ../CHANGES.rst -------------------------------------------------------------------------------- /requirements-mypy.txt: -------------------------------------------------------------------------------- 1 | mypy==1.10.0 2 | django-stubs==5.0.2 3 | pytest 4 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-django 3 | psycopg2-binary 4 | psycopg 5 | pytest-cov==4.1.0 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Dependencies for development of django-model-utils 2 | 3 | tox 4 | sphinx 5 | time-machine 6 | twine 7 | -------------------------------------------------------------------------------- /model_utils/locale/cs/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/cs/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/de/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/de/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/fa/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/fa/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/sv/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/sv/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /model_utils/locale/pt_BR/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/pt_BR/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | include = model_utils/*.py 3 | 4 | [report] 5 | exclude_also = 6 | # Exclusive to mypy: 7 | if TYPE_CHECKING:$ 8 | \.\.\.$ 9 | -------------------------------------------------------------------------------- /model_utils/locale/zh_Hans/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jazzband/django-model-utils/master/model_utils/locale/zh_Hans/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/* 2 | build 3 | django_model_utils.egg-info/* 4 | HGREV 5 | .coverage 6 | .tox/ 7 | Django-*.egg 8 | *.pyc 9 | htmlcov/ 10 | docs/_build/ 11 | .idea/ 12 | .eggs/ 13 | .venv/ 14 | coverage.xml 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [build_sphinx] 2 | source-dir = docs/ 3 | build-dir = docs/_build 4 | all_files = 1 5 | 6 | [tool:pytest] 7 | django_find_project = false 8 | DJANGO_SETTINGS_MODULE = tests.settings 9 | 10 | [isort] 11 | profile = black 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:14-alpine 4 | environment: 5 | POSTGRES_HOST_AUTH_METHOD: trust 6 | POSTGRES_DB: modelutils 7 | POSTGRES_USER: postgres 8 | ports: 9 | - 5432:5432 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CHANGES.rst 3 | include LICENSE.txt 4 | include MANIFEST.in 5 | include README.rst 6 | include requirements*.txt 7 | include Makefile tox.ini 8 | recursive-include model_utils/locale *.po *.mo 9 | graft docs 10 | recursive-include tests *.py 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{py,rst,ini}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.yml] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Problem 2 | 3 | Explain the problem you encountered. 4 | 5 | ## Environment 6 | 7 | - Django Model Utils version: 8 | - Django version: 9 | - Python version: 10 | - Other libraries used, if any: 11 | 12 | ## Code examples 13 | 14 | Give code example that demonstrates the issue, or even better, write new tests that fails because of that issue. 15 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | # Project page: https://readthedocs.org/projects/django-model-utils/ 4 | 5 | version: 2 6 | 7 | build: 8 | os: ubuntu-22.04 9 | tools: 10 | python: "3" 11 | 12 | python: 13 | install: 14 | - method: pip 15 | path: . 16 | 17 | sphinx: 18 | configuration: docs/conf.py 19 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disallow_incomplete_defs=True 3 | disallow_untyped_defs=True 4 | implicit_reexport=False 5 | pretty=True 6 | show_error_codes=True 7 | strict_equality=True 8 | warn_redundant_casts=True 9 | warn_unreachable=True 10 | warn_unused_ignores=True 11 | 12 | mypy_path = $MYPY_CONFIG_FILE_DIR 13 | 14 | plugins = 15 | mypy_django_plugin.main 16 | 17 | [mypy.plugins.django-stubs] 18 | django_settings_module = "tests.settings" 19 | -------------------------------------------------------------------------------- /model_utils/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | from .choices import Choices 4 | from .tracker import FieldTracker, ModelTracker 5 | 6 | try: 7 | __version__ = importlib.metadata.version('django-model-utils') 8 | except importlib.metadata.PackageNotFoundError: # pragma: no cover 9 | # package is not installed 10 | __version__ = None # type: ignore[assignment] 11 | 12 | __all__ = ("Choices", "FieldTracker", "ModelTracker") 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Problem 2 | 3 | Explain the problem you are fixing (add the link to the related issue(s), if any). 4 | 5 | ## Solution 6 | 7 | Explain the solution that has been implemented, and what has been changed. 8 | 9 | ## Commandments 10 | 11 | - [ ] Write PEP8 compliant code. 12 | - [ ] Cover it with tests. 13 | - [ ] Update `CHANGES.rst` file to describe the changes, and quote according issue with `GH-`. 14 | - [ ] Pay attention to backward compatibility, or if it breaks it, explain why. 15 | - [ ] Update documentation (if relevant). 16 | -------------------------------------------------------------------------------- /docs/setup.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Setup 3 | ===== 4 | 5 | Installation 6 | ============ 7 | 8 | Install from PyPI with ``pip``:: 9 | 10 | pip install django-model-utils 11 | 12 | To use ``django-model-utils`` in your Django project, just import and 13 | use the utility classes described in this documentation; there is no need to 14 | modify your ``INSTALLED_APPS`` setting. 15 | 16 | 17 | Dependencies 18 | ============ 19 | 20 | ``django-model-utils`` supports `Django`_ 3.2+ (latest bugfix 21 | release in each series only) on Python 3.7+. 22 | 23 | .. _Django: https://www.djangoproject.com/ 24 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/PyCQA/isort 3 | rev: 7.0.0 4 | hooks: 5 | - id: isort 6 | args: ['--profile', 'black', '--check-only', '--diff'] 7 | files: ^((model_utils|tests)/)|setup.py 8 | 9 | - repo: https://github.com/PyCQA/flake8 10 | rev: 7.3.0 11 | hooks: 12 | - id: flake8 13 | args: ['--ignore=E402,E501,E731,W503'] 14 | files: ^(model_utils|tests)/ 15 | 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v3.21.2 18 | hooks: 19 | - id: pyupgrade 20 | args: [--py38-plus] 21 | -------------------------------------------------------------------------------- /tests/test_inheritance_iterable.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db.models import Prefetch 4 | from django.test import TestCase 5 | 6 | from tests.models import InheritanceManagerTestChild1, InheritanceManagerTestParent 7 | 8 | 9 | class InheritanceIterableTest(TestCase): 10 | def test_prefetch(self) -> None: 11 | qs = InheritanceManagerTestChild1.objects.all().prefetch_related( 12 | Prefetch( 13 | 'normal_field', 14 | queryset=InheritanceManagerTestParent.objects.all(), 15 | to_attr='normal_field_prefetched' 16 | ) 17 | ) 18 | self.assertEqual(qs.count(), 0) 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django-model-utils 3 | ================== 4 | 5 | Django model mixins and utilities. 6 | 7 | 8 | Contents 9 | ======== 10 | 11 | .. toctree:: 12 | :maxdepth: 3 13 | 14 | setup 15 | fields 16 | models 17 | managers 18 | utilities 19 | changelog 20 | 21 | 22 | Contributing 23 | ============ 24 | 25 | Please file bugs and send pull requests to the `GitHub repository`_ and `issue 26 | tracker`_. 27 | 28 | .. _GitHub repository: https://github.com/jazzband/django-model-utils/ 29 | .. _issue tracker: https://github.com/jazzband/django-model-utils/issues 30 | 31 | 32 | 33 | Indices and tables 34 | ================== 35 | 36 | * :ref:`genindex` 37 | * :ref:`modindex` 38 | * :ref:`search` 39 | 40 | -------------------------------------------------------------------------------- /tests/test_models/test_uuid_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import CustomNotPrimaryUUIDModel, CustomUUIDModel 6 | 7 | 8 | class UUIDFieldTests(TestCase): 9 | 10 | def test_uuid_model_with_uuid_field_as_primary_key(self) -> None: 11 | instance = CustomUUIDModel() 12 | instance.save() 13 | self.assertEqual(instance.id.__class__.__name__, 'UUID') 14 | self.assertEqual(instance.id, instance.pk) 15 | 16 | def test_uuid_model_with_uuid_field_as_not_primary_key(self) -> None: 17 | instance = CustomNotPrimaryUUIDModel() 18 | instance.save() 19 | self.assertEqual(instance.uuid.__class__.__name__, 'UUID') 20 | self.assertNotEqual(instance.uuid, instance.pk) 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VIRTUALENV = virtualenv --python=python3 2 | PYTHON = $(VENV)/bin/python 3 | VENV := $(shell echo $${VIRTUAL_ENV-.venv}) 4 | INSTALL_STAMP = $(VENV)/.install.stamp 5 | 6 | all: init docs test 7 | 8 | init: $(INSTALL_STAMP) 9 | $(INSTALL_STAMP): $(PYTHON) setup.py 10 | $(VENV)/bin/pip install -e . 11 | $(VENV)/bin/pip install tox coverage Sphinx 12 | touch $(INSTALL_STAMP) 13 | 14 | virtualenv: $(PYTHON) 15 | $(PYTHON): 16 | $(VIRTUALENV) $(VENV) 17 | 18 | test: init 19 | $(VENV)/bin/coverage erase 20 | $(VENV)/bin/tox 21 | $(VENV)/bin/coverage html 22 | 23 | docs: documentation 24 | 25 | documentation: init 26 | $(PYTHON) setup.py build_sphinx 27 | 28 | messages: init 29 | $(PYTHON) translations.py make 30 | 31 | compilemessages: init 32 | $(PYTHON) translations.py compile 33 | 34 | format: 35 | isort model_utils tests setup.py 36 | -------------------------------------------------------------------------------- /tests/test_managers/test_status_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.exceptions import ImproperlyConfigured 4 | from django.db import models 5 | from django.test import TestCase 6 | 7 | from model_utils.managers import QueryManager 8 | from model_utils.models import StatusModel 9 | from tests.models import StatusManagerAdded 10 | 11 | 12 | class StatusManagerAddedTests(TestCase): 13 | def test_manager_available(self) -> None: 14 | self.assertTrue(isinstance(StatusManagerAdded.active, QueryManager)) 15 | 16 | def test_conflict_error(self) -> None: 17 | with self.assertRaises(ImproperlyConfigured): 18 | class ErrorModel(StatusModel): 19 | STATUS = ( 20 | ('active', 'Is Active'), 21 | ('deleted', 'Is Deleted'), 22 | ) 23 | active = models.BooleanField() 24 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | INSTALLED_APPS = ( 4 | 'model_utils', 5 | 'tests', 6 | ) 7 | 8 | if os.environ.get('SQLITE'): 9 | DATABASES = { 10 | 'default': { 11 | 'ENGINE': 'django.db.backends.sqlite3', 12 | }, 13 | } 14 | else: 15 | DATABASES = { 16 | "default": { 17 | "ENGINE": "django.db.backends.postgresql", 18 | "NAME": os.environ.get("POSTGRES_DB", "modelutils"), 19 | "USER": os.environ.get("POSTGRES_USER", 'postgres'), 20 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), 21 | "HOST": os.environ.get("POSTGRES_HOST", "localhost"), 22 | "PORT": os.environ.get("POSTGRES_PORT", "5432") 23 | }, 24 | } 25 | SECRET_KEY = 'dummy' 26 | 27 | CACHES = { 28 | 'default': { 29 | 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', 30 | } 31 | } 32 | 33 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' 34 | 35 | USE_TZ = True 36 | -------------------------------------------------------------------------------- /tests/test_managers/test_query_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import Post 6 | 7 | 8 | class QueryManagerTests(TestCase): 9 | def setUp(self) -> None: 10 | data = ((True, True, 0), 11 | (True, False, 4), 12 | (False, False, 2), 13 | (False, True, 3), 14 | (True, True, 1), 15 | (True, False, 5)) 16 | for p, c, o in data: 17 | Post.objects.create(published=p, confirmed=c, order=o) 18 | 19 | def test_passing_kwargs(self) -> None: 20 | qs = Post.public.all() 21 | self.assertEqual([p.order for p in qs], [0, 1, 4, 5]) 22 | 23 | def test_passing_Q(self) -> None: 24 | qs = Post.public_confirmed.all() 25 | self.assertEqual([p.order for p in qs], [0, 1]) 26 | 27 | def test_ordering(self) -> None: 28 | qs = Post.public_reversed.all() 29 | self.assertEqual([p.order for p in qs], [5, 4, 1, 0]) 30 | -------------------------------------------------------------------------------- /translations.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from django.conf import settings 6 | import django 7 | 8 | DEFAULT_SETTINGS = dict( 9 | INSTALLED_APPS=( 10 | 'model_utils', 11 | 'tests', 12 | ), 13 | DATABASES={ 14 | "default": { 15 | "ENGINE": "django.db.backends.sqlite3" 16 | } 17 | }, 18 | SILENCED_SYSTEM_CHECKS=["1_7.W001"], 19 | ) 20 | 21 | 22 | def run(command): 23 | if not settings.configured: 24 | settings.configure(**DEFAULT_SETTINGS) 25 | 26 | parent = os.path.dirname(os.path.abspath(__file__)) 27 | appdir = os.path.join(parent, 'model_utils') 28 | os.chdir(appdir) 29 | 30 | from django.core.management import call_command 31 | 32 | call_command('%smessages' % command) 33 | 34 | 35 | if __name__ == '__main__': 36 | if (len(sys.argv)) < 2 or (sys.argv[1] not in {'make', 'compile'}): 37 | print("Run `translations.py make` or `translations.py compile`.") 38 | sys.exit(1) 39 | run(sys.argv[1]) 40 | -------------------------------------------------------------------------------- /model_utils/locale/zh_Hans/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Zach Cheung , 2018. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 10 | "PO-Revision-Date: 2018-10-23 15:26+0800\n" 11 | "Last-Translator: Zach Cheung \n" 12 | "Language-Team: \n" 13 | "Language: zh_CN\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=1; plural=0;\n" 18 | 19 | #: models.py:24 20 | msgid "created" 21 | msgstr "创建时间" 22 | 23 | #: models.py:25 24 | msgid "modified" 25 | msgstr "修改时间" 26 | 27 | #: models.py:49 28 | msgid "start" 29 | msgstr "开始时间" 30 | 31 | #: models.py:50 32 | msgid "end" 33 | msgstr "结束时间" 34 | 35 | #: models.py:65 36 | msgid "status" 37 | msgstr "状态" 38 | 39 | #: models.py:66 40 | msgid "status changed" 41 | msgstr "状态修改时间" 42 | -------------------------------------------------------------------------------- /model_utils/locale/fa/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Hasan Ramezani , 2020. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 11 | "Last-Translator: FULL NAME \n" 12 | "Language-Team: LANGUAGE \n" 13 | "Language: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 18 | 19 | #: models.py:24 20 | msgid "created" 21 | msgstr "ایجاد شد" 22 | 23 | #: models.py:25 24 | msgid "modified" 25 | msgstr "اصلاح شد" 26 | 27 | #: models.py:49 28 | msgid "start" 29 | msgstr "شروع" 30 | 31 | #: models.py:50 32 | msgid "end" 33 | msgstr "پایان" 34 | 35 | #: models.py:65 36 | msgid "status" 37 | msgstr "وضعیت" 38 | 39 | #: models.py:66 40 | msgid "status changed" 41 | msgstr "وضعیت تغییر کرد" 42 | -------------------------------------------------------------------------------- /tests/test_miscellaneous.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.core.management import call_command 4 | from django.test import TestCase 5 | 6 | from model_utils.fields import get_excerpt 7 | 8 | 9 | class MigrationsTests(TestCase): 10 | def test_makemigrations(self) -> None: 11 | call_command('makemigrations', dry_run=True) 12 | 13 | 14 | class GetExcerptTests(TestCase): 15 | def test_split(self) -> None: 16 | e = get_excerpt("some content\n\n\n\nsome more") 17 | self.assertEqual(e, 'some content\n') 18 | 19 | def test_auto_split(self) -> None: 20 | e = get_excerpt("para one\n\npara two\n\npara three") 21 | self.assertEqual(e, 'para one\n\npara two') 22 | 23 | def test_middle_of_para(self) -> None: 24 | e = get_excerpt("some text\n\nmore text") 25 | self.assertEqual(e, 'some text') 26 | 27 | def test_middle_of_line(self) -> None: 28 | e = get_excerpt("some text more text") 29 | self.assertEqual(e, "some text more text") 30 | -------------------------------------------------------------------------------- /model_utils/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Enrique Matías Sánchez , 2020. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: django-model-utils\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 10 | "PO-Revision-Date: 2020-03-29 11:14+0100\n" 11 | "Last-Translator: Enrique Matías Sánchez \n" 12 | "Language-Team: \n" 13 | "Language: es\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | "X-Generator: Lokalize 2.0\n" 19 | 20 | #: models.py:24 21 | msgid "created" 22 | msgstr "creado" 23 | 24 | #: models.py:25 25 | msgid "modified" 26 | msgstr "modificado" 27 | 28 | #: models.py:49 29 | msgid "start" 30 | msgstr "inicio" 31 | 32 | #: models.py:50 33 | msgid "end" 34 | msgstr "fin" 35 | 36 | #: models.py:65 37 | msgid "status" 38 | msgstr "estado" 39 | 40 | #: models.py:66 41 | msgid "status changed" 42 | msgstr "estado modificado" 43 | -------------------------------------------------------------------------------- /model_utils/locale/pt_BR/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Guilherme Martins Crocetti <24530683+gmcrocetti@users.noreply.github.com>, 2023. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: django-model-utils\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:00+0200\n" 10 | "PO-Revision-Date: 2023-07-20 22:05-0300\n" 11 | "Last-Translator: Guilherme Croceetti <24530683+gmcrocetti@users.noreply." 12 | "github.com>\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 | 20 | #: models.py:24 21 | msgid "created" 22 | msgstr "criado" 23 | 24 | #: models.py:25 25 | msgid "modified" 26 | msgstr "modificado" 27 | 28 | #: models.py:49 29 | msgid "start" 30 | msgstr "início" 31 | 32 | #: models.py:50 33 | msgid "end" 34 | msgstr "fim" 35 | 36 | #: models.py:65 37 | msgid "status" 38 | msgstr "estado" 39 | 40 | #: models.py:66 41 | msgid "status changed" 42 | msgstr "estado modificado" 43 | -------------------------------------------------------------------------------- /model_utils/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # French translations of django-model-utils 2 | # 3 | # This file is distributed under the same license as the django-model-utils package. 4 | # 5 | # Translators: 6 | # ------------ 7 | # Florian Alu , 2021. 8 | # 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: django-model-utils\n" 12 | "Report-Msgid-Bugs-To: \n" 13 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 14 | "PO-Revision-Date: 2021-01-11 11:45+0100\n" 15 | "Last-Translator: Florian Alu \n" 16 | "Language-Team: \n" 17 | "Language: fr\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 22 | "X-Generator: Poedit 2.4.2\n" 23 | 24 | #: models.py:24 25 | msgid "created" 26 | msgstr "créé" 27 | 28 | #: models.py:25 29 | msgid "modified" 30 | msgstr "modifié" 31 | 32 | #: models.py:49 33 | msgid "start" 34 | msgstr "début" 35 | 36 | #: models.py:50 37 | msgid "end" 38 | msgstr "fin" 39 | 40 | #: models.py:65 41 | msgid "status" 42 | msgstr "statut" 43 | 44 | #: models.py:66 45 | msgid "status changed" 46 | msgstr "statut modifié" 47 | -------------------------------------------------------------------------------- /tests/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from django.db import models 6 | from django.db.backends.base.base import BaseDatabaseWrapper 7 | 8 | 9 | def mutable_from_db(value: object) -> Any: 10 | if value == '': 11 | return None 12 | try: 13 | if isinstance(value, (str,)): 14 | return [int(i) for i in value.split(',')] 15 | except ValueError: 16 | pass 17 | return value 18 | 19 | 20 | def mutable_to_db(value: object) -> str: 21 | if value is None: 22 | return '' 23 | if isinstance(value, list): 24 | value = ','.join(str(i) for i in value) 25 | return str(value) 26 | 27 | 28 | class MutableField(models.TextField): 29 | def to_python(self, value: object) -> Any: 30 | return mutable_from_db(value) 31 | 32 | def from_db_value(self, value: object, expression: object, connection: BaseDatabaseWrapper) -> Any: 33 | return mutable_from_db(value) 34 | 35 | def get_db_prep_save(self, value: object, connection: BaseDatabaseWrapper) -> str: 36 | value = super().get_db_prep_save(value, connection) 37 | return mutable_to_db(value) 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | if: github.repository == 'jazzband/django-model-utils' 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.x 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install -U pip 26 | python -m pip install -U setuptools twine wheel 27 | 28 | - name: Build package 29 | run: | 30 | python setup.py --version 31 | python setup.py sdist --format=gztar bdist_wheel 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@master 37 | with: 38 | user: jazzband 39 | password: ${{ secrets.JAZZBAND_RELEASE_KEY }} 40 | repository_url: https://jazzband.co/projects/django-model-utils/upload 41 | -------------------------------------------------------------------------------- /tests/test_managers/test_softdelete_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import CustomSoftDelete 6 | 7 | 8 | class CustomSoftDeleteManagerTests(TestCase): 9 | 10 | def test_custom_manager_empty(self) -> None: 11 | qs = CustomSoftDelete.available_objects.only_read() 12 | self.assertEqual(qs.count(), 0) 13 | 14 | def test_custom_qs_empty(self) -> None: 15 | qs = CustomSoftDelete.available_objects.all().only_read() 16 | self.assertEqual(qs.count(), 0) 17 | 18 | def test_is_read(self) -> None: 19 | for is_read in [True, False, True, False]: 20 | CustomSoftDelete.available_objects.create(is_read=is_read) 21 | qs = CustomSoftDelete.available_objects.only_read() 22 | self.assertEqual(qs.count(), 2) 23 | 24 | def test_is_read_removed(self) -> None: 25 | for is_read, is_removed in [(True, True), (True, False), (False, False), (False, True)]: 26 | CustomSoftDelete.available_objects.create(is_read=is_read, is_removed=is_removed) 27 | qs = CustomSoftDelete.available_objects.only_read() 28 | self.assertEqual(qs.count(), 1) 29 | -------------------------------------------------------------------------------- /model_utils/locale/cs/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # Czech translations of django-model-utils 2 | # 3 | # This file is distributed under the same license as the django-model-utils package. 4 | # 5 | # Translators: 6 | # ------------ 7 | # Václav Dohnal , 2018. 8 | # 9 | msgid "" 10 | msgstr "" 11 | "Project-Id-Version: django-model-utils\n" 12 | "Report-Msgid-Bugs-To: \n" 13 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 14 | "PO-Revision-Date: 2018-05-04 13:46+0200\n" 15 | "Last-Translator: Václav Dohnal \n" 16 | "Language-Team: N/A\n" 17 | "Language: cs\n" 18 | "MIME-Version: 1.0\n" 19 | "Content-Type: text/plain; charset=UTF-8\n" 20 | "Content-Transfer-Encoding: 8bit\n" 21 | "Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" 22 | "X-Generator: Poedit 2.0.7\n" 23 | 24 | #: models.py:24 25 | msgid "created" 26 | msgstr "vytvořeno" 27 | 28 | #: models.py:25 29 | msgid "modified" 30 | msgstr "upraveno" 31 | 32 | #: models.py:49 33 | msgid "start" 34 | msgstr "začátek" 35 | 36 | #: models.py:50 37 | msgid "end" 38 | msgstr "konec" 39 | 40 | #: models.py:65 41 | msgid "status" 42 | msgstr "stav" 43 | 44 | #: models.py:66 45 | msgid "status changed" 46 | msgstr "změna stavu" 47 | -------------------------------------------------------------------------------- /tests/test_fields/test_status_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from model_utils.fields import StatusField 6 | from tests.models import ( 7 | Article, 8 | StatusFieldChoicesName, 9 | StatusFieldDefaultFilled, 10 | StatusFieldDefaultNotFilled, 11 | ) 12 | 13 | 14 | class StatusFieldTests(TestCase): 15 | 16 | def test_status_with_default_filled(self) -> None: 17 | instance = StatusFieldDefaultFilled() 18 | self.assertEqual(instance.status, instance.STATUS.yes) 19 | 20 | def test_status_with_default_not_filled(self) -> None: 21 | instance = StatusFieldDefaultNotFilled() 22 | self.assertEqual(instance.status, instance.STATUS.no) 23 | 24 | def test_no_check_for_status(self) -> None: 25 | field = StatusField(no_check_for_status=True) 26 | # this model has no STATUS attribute, so checking for it would error 27 | field.prepare_class(Article) 28 | 29 | def test_get_status_display(self) -> None: 30 | instance = StatusFieldDefaultFilled() 31 | self.assertEqual(instance.get_status_display(), "Yes") 32 | 33 | def test_choices_name(self) -> None: 34 | StatusFieldChoicesName() 35 | -------------------------------------------------------------------------------- /model_utils/locale/sv/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Tomas Walch , 2022. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: PACKAGE VERSION\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 10 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 11 | "Last-Translator: FULL NAME \n" 12 | "Language-Team: LANGUAGE \n" 13 | "Language: \n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | 19 | #: models.py:24 20 | msgid "created" 21 | msgstr "skapad" 22 | 23 | #: models.py:25 24 | msgid "modified" 25 | msgstr "ändrad" 26 | 27 | #: models.py:49 28 | msgid "start" 29 | msgstr "start" 30 | 31 | #: models.py:50 32 | msgid "end" 33 | msgstr "slut" 34 | 35 | #: models.py:65 36 | msgid "status" 37 | msgstr "status" 38 | 39 | #: models.py:66 40 | msgid "status changed" 41 | msgstr "status ändrad" 42 | 43 | #~ msgid "active" 44 | #~ msgstr "aktiv" 45 | 46 | #~ msgid "deleted" 47 | #~ msgstr "borttagen" 48 | 49 | #~ msgid "on hold" 50 | #~ msgstr "väntande" 51 | -------------------------------------------------------------------------------- /model_utils/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Arseny Sysolyatin , 2017. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: django-model-utils\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 10 | "PO-Revision-Date: 2017-05-22 19:46+0300\n" 11 | "Last-Translator: Arseny Sysolyatin \n" 12 | "Language-Team: \n" 13 | "Language: ru\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " 18 | "n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " 19 | "(n%100>=11 && n%100<=14)? 2 : 3);\n" 20 | 21 | #: models.py:24 22 | msgid "created" 23 | msgstr "создано" 24 | 25 | #: models.py:25 26 | msgid "modified" 27 | msgstr "изменено" 28 | 29 | #: models.py:49 30 | msgid "start" 31 | msgstr "начало" 32 | 33 | #: models.py:50 34 | msgid "end" 35 | msgstr "конец" 36 | 37 | #: models.py:65 38 | msgid "status" 39 | msgstr "статус" 40 | 41 | #: models.py:66 42 | msgid "status changed" 43 | msgstr "статус изменен" 44 | -------------------------------------------------------------------------------- /model_utils/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # This file is distributed under the same license as the django-model-utils package. 2 | # 3 | # Translators: 4 | # Philipp Steinhardt , 2015. 5 | msgid "" 6 | msgstr "" 7 | "Project-Id-Version: django-model-utils\n" 8 | "Report-Msgid-Bugs-To: \n" 9 | "POT-Creation-Date: 2024-04-01 15:01+0200\n" 10 | "PO-Revision-Date: 2015-07-01 10:12+0200\n" 11 | "Last-Translator: Philipp Steinhardt \n" 12 | "Language-Team: \n" 13 | "Language: de\n" 14 | "MIME-Version: 1.0\n" 15 | "Content-Type: text/plain; charset=UTF-8\n" 16 | "Content-Transfer-Encoding: 8bit\n" 17 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 18 | 19 | #: models.py:24 20 | msgid "created" 21 | msgstr "erstellt" 22 | 23 | #: models.py:25 24 | msgid "modified" 25 | msgstr "bearbeitet" 26 | 27 | #: models.py:49 28 | msgid "start" 29 | msgstr "Beginn" 30 | 31 | #: models.py:50 32 | msgid "end" 33 | msgstr "Ende" 34 | 35 | #: models.py:65 36 | msgid "status" 37 | msgstr "Status" 38 | 39 | #: models.py:66 40 | msgid "status changed" 41 | msgstr "Status geändert" 42 | 43 | #~ msgid "active" 44 | #~ msgstr "aktiv" 45 | 46 | #~ msgid "deleted" 47 | #~ msgstr "gelöscht" 48 | 49 | #~ msgid "on hold" 50 | #~ msgstr "wartend" 51 | -------------------------------------------------------------------------------- /tests/test_fields/test_uuid_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | 5 | from django.core.exceptions import ValidationError 6 | from django.test import TestCase 7 | 8 | from model_utils.fields import UUIDField 9 | 10 | 11 | class UUIDFieldTests(TestCase): 12 | 13 | def test_uuid_version_default(self) -> None: 14 | instance = UUIDField() 15 | self.assertEqual(instance.default, uuid.uuid4) 16 | 17 | def test_uuid_version_1(self) -> None: 18 | instance = UUIDField(version=1) 19 | self.assertEqual(instance.default, uuid.uuid1) 20 | 21 | def test_uuid_version_2_error(self) -> None: 22 | self.assertRaises(ValidationError, UUIDField, 'version', 2) 23 | 24 | def test_uuid_version_3(self) -> None: 25 | instance = UUIDField(version=3) 26 | self.assertEqual(instance.default, uuid.uuid3) 27 | 28 | def test_uuid_version_4(self) -> None: 29 | instance = UUIDField(version=4) 30 | self.assertEqual(instance.default, uuid.uuid4) 31 | 32 | def test_uuid_version_5(self) -> None: 33 | instance = UUIDField(version=5) 34 | self.assertEqual(instance.default, uuid.uuid5) 35 | 36 | def test_uuid_version_bellow_min(self) -> None: 37 | self.assertRaises(ValidationError, UUIDField, 'version', 0) 38 | 39 | def test_uuid_version_above_max(self) -> None: 40 | self.assertRaises(ValidationError, UUIDField, 'version', 6) 41 | -------------------------------------------------------------------------------- /tests/test_managers/test_join_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import BoxJoinModel, JoinItemForeignKey 6 | 7 | 8 | class JoinManagerTest(TestCase): 9 | def setUp(self) -> None: 10 | for i in range(20): 11 | BoxJoinModel.objects.create(name=f'name_{i}') 12 | 13 | JoinItemForeignKey.objects.create( 14 | weight=10, belonging=BoxJoinModel.objects.get(name='name_1') 15 | ) 16 | JoinItemForeignKey.objects.create(weight=20) 17 | 18 | def test_self_join(self) -> None: 19 | a_slice = BoxJoinModel.objects.all()[0:10] 20 | with self.assertNumQueries(1): 21 | result = a_slice.join() 22 | self.assertEqual(result.count(), 10) 23 | 24 | def test_self_join_with_where_statement(self) -> None: 25 | qs = BoxJoinModel.objects.filter(name='name_1') 26 | result = qs.join() 27 | self.assertEqual(result.count(), 1) 28 | 29 | def test_join_with_other_qs(self) -> None: 30 | item_qs = JoinItemForeignKey.objects.filter(weight=10) 31 | boxes = BoxJoinModel.objects.all().join(qs=item_qs) 32 | self.assertEqual(boxes.count(), 1) 33 | self.assertEqual(boxes[0].name, 'name_1') 34 | 35 | def test_reverse_join(self) -> None: 36 | box_qs = BoxJoinModel.objects.filter(name='name_1') 37 | items = JoinItemForeignKey.objects.all().join(box_qs) 38 | self.assertEqual(items.count(), 1) 39 | self.assertEqual(items[0].weight, 10) 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{38,39,310,311}-dj{42} 4 | py{310,311,312}-dj{50} 5 | py{310,311,312}-dj{51} 6 | py{310,311,312,313}-dj{51,52} 7 | py{310,311,312,313}-dj{main} 8 | flake8 9 | isort 10 | mypy 11 | 12 | [gh-actions] 13 | python = 14 | 3.7: py37 15 | 3.8: py38, flake8, isort, mypy 16 | 3.9: py39 17 | 3.10: py310 18 | 3.11: py311 19 | 3.12: py312 20 | 3.13: py313 21 | 22 | [testenv] 23 | deps = 24 | time-machine 25 | -rrequirements-test.txt 26 | dj42: Django==4.2.* 27 | dj50: Django==5.0.* 28 | dj51: Django==5.1.* 29 | dj52: Django==5.2.* 30 | djmain: https://github.com/django/django/archive/main.tar.gz 31 | ignore_outcome = 32 | djmain: True 33 | ignore_errors = 34 | djmain: True 35 | passenv = 36 | CI 37 | FORCE_COLOR 38 | GITHUB_* 39 | POSTGRES_* 40 | usedevelop = True 41 | commands = 42 | python -m pytest {posargs} 43 | 44 | [testenv:flake8] 45 | basepython = 46 | python3.8 47 | deps = 48 | flake8 49 | skip_install = True 50 | commands = 51 | flake8 model_utils tests 52 | 53 | [flake8] 54 | ignore = 55 | E731 56 | W503 57 | E402 58 | E501 59 | 60 | [testenv:isort] 61 | basepython = python3.8 62 | deps = isort 63 | commands = 64 | isort model_utils tests setup.py --check-only --diff 65 | skip_install = True 66 | 67 | [testenv:mypy] 68 | basepython = python3.8 69 | deps = 70 | time-machine==2.8.2 71 | -r requirements-mypy.txt 72 | set_env = 73 | SQLITE=1 74 | commands = 75 | mypy model_utils tests 76 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2019, Carl Meyer and contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above 11 | copyright notice, this list of conditions and the following 12 | disclaimer in the documentation and/or other materials provided 13 | with the distribution. 14 | * Neither the name of the author nor the names of other 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 21 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 22 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 23 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 24 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 25 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 26 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 27 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | # Automatically close issues or pull requests that have a label, after a custom delay, if no one replies. 2 | # https://github.com/tiangolo/issue-manager 3 | name: Issue Manager 4 | 5 | on: 6 | schedule: 7 | - cron: "12 0 * * *" 8 | issue_comment: 9 | types: 10 | - created 11 | issues: 12 | types: 13 | - labeled 14 | pull_request_target: 15 | types: 16 | - labeled 17 | workflow_dispatch: 18 | 19 | jobs: 20 | issue-manager: 21 | # Disables this workflow from running in a repository that is not part of the indicated organization/user 22 | if: github.repository_owner == 'jazzband' 23 | 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: tiangolo/issue-manager@0.5.0 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | config: > 30 | { 31 | "answered": { 32 | "delay": 864000, 33 | "message": "Assuming the question was answered, this will be automatically closed now." 34 | }, 35 | "solved": { 36 | "delay": 864000, 37 | "message": "Assuming the original issue was solved, it will be automatically closed now." 38 | }, 39 | "waiting": { 40 | "delay": 864000, 41 | "message": "Automatically closing after waiting for additional info. To re-open, please provide the additional information requested." 42 | }, 43 | "wontfix": { 44 | "delay": 864000, 45 | "message": "As discussed, we won't be implementing this. Automatically closing." 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | django-model-utils 3 | ================== 4 | 5 | .. image:: https://jazzband.co/static/img/badge.svg 6 | :target: https://jazzband.co/ 7 | :alt: Jazzband 8 | .. image:: https://github.com/jazzband/django-model-utils/workflows/Test/badge.svg 9 | :target: https://github.com/jazzband/django-model-utils/actions 10 | .. image:: https://codecov.io/gh/jazzband/django-model-utils/branch/master/graph/badge.svg 11 | :target: https://codecov.io/gh/jazzband/django-model-utils 12 | .. image:: https://img.shields.io/pypi/v/django-model-utils.svg 13 | :target: https://pypi.python.org/pypi/django-model-utils 14 | .. image:: https://img.shields.io/pypi/pyversions/django-model-utils.svg 15 | :target: https://pypi.python.org/pypi/django-model-utils 16 | :alt: Supported Python versions 17 | .. image:: https://img.shields.io/pypi/djversions/django-model-utils.svg 18 | :target: https://pypi.org/project/django-model-utils/ 19 | :alt: Supported Django versions 20 | 21 | Django model mixins and utilities. 22 | 23 | ``django-model-utils`` supports `Django`_ 3.2+. 24 | 25 | .. _Django: https://www.djangoproject.com/ 26 | 27 | This app is available on `PyPI`_. 28 | 29 | .. _PyPI: https://pypi.python.org/pypi/django-model-utils/ 30 | 31 | Getting Help 32 | ============ 33 | 34 | Documentation for django-model-utils is available 35 | https://django-model-utils.readthedocs.io/ 36 | 37 | Contributing 38 | ============ 39 | 40 | Please file bugs and send pull requests to the `GitHub repository`_ and `issue 41 | tracker`_. See `CONTRIBUTING.rst`_ for details. 42 | 43 | .. _GitHub repository: https://github.com/jazzband/django-model-utils/ 44 | .. _issue tracker: https://github.com/jazzband/django-model-utils/issues 45 | .. _CONTRIBUTING.rst: https://github.com/jazzband/django-model-utils/blob/master/CONTRIBUTING.rst 46 | -------------------------------------------------------------------------------- /tests/test_models/test_timeframed_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timedelta 4 | 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.db import models 7 | from django.test import TestCase 8 | 9 | from model_utils.managers import QueryManager 10 | from model_utils.models import TimeFramedModel 11 | from tests.models import TimeFrame, TimeFrameManagerAdded 12 | 13 | 14 | class TimeFramedModelTests(TestCase): 15 | def setUp(self) -> None: 16 | self.now = datetime.now() 17 | 18 | def test_not_yet_begun(self) -> None: 19 | TimeFrame.objects.create(start=self.now + timedelta(days=2)) 20 | self.assertEqual(TimeFrame.timeframed.count(), 0) 21 | 22 | def test_finished(self) -> None: 23 | TimeFrame.objects.create(end=self.now - timedelta(days=1)) 24 | self.assertEqual(TimeFrame.timeframed.count(), 0) 25 | 26 | def test_no_end(self) -> None: 27 | TimeFrame.objects.create(start=self.now - timedelta(days=10)) 28 | self.assertEqual(TimeFrame.timeframed.count(), 1) 29 | 30 | def test_no_start(self) -> None: 31 | TimeFrame.objects.create(end=self.now + timedelta(days=2)) 32 | self.assertEqual(TimeFrame.timeframed.count(), 1) 33 | 34 | def test_within_range(self) -> None: 35 | TimeFrame.objects.create(start=self.now - timedelta(days=1), 36 | end=self.now + timedelta(days=1)) 37 | self.assertEqual(TimeFrame.timeframed.count(), 1) 38 | 39 | 40 | class TimeFrameManagerAddedTests(TestCase): 41 | def test_manager_available(self) -> None: 42 | self.assertTrue(isinstance(TimeFrameManagerAdded.timeframed, QueryManager)) 43 | 44 | def test_conflict_error(self) -> None: 45 | with self.assertRaises(ImproperlyConfigured): 46 | class ErrorModel(TimeFramedModel): 47 | timeframed = models.BooleanField() 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def long_desc(root_path): 7 | FILES = ['README.rst', 'CHANGES.rst'] 8 | for filename in FILES: 9 | filepath = os.path.realpath(os.path.join(root_path, filename)) 10 | if os.path.isfile(filepath): 11 | with open(filepath) as f: 12 | yield f.read() 13 | 14 | 15 | HERE = os.path.abspath(os.path.dirname(__file__)) 16 | long_description = "\n\n".join(long_desc(HERE)) 17 | 18 | 19 | setup( 20 | name='django-model-utils', 21 | use_scm_version={"version_scheme": "post-release"}, 22 | setup_requires=["setuptools_scm"], 23 | license="BSD", 24 | description='Django model mixins and utilities', 25 | long_description=long_description, 26 | long_description_content_type='text/x-rst', 27 | author='Carl Meyer', 28 | author_email='carl@oddbird.net', 29 | maintainer='JazzBand', 30 | url='https://github.com/jazzband/django-model-utils', 31 | packages=find_packages(exclude=['tests*']), 32 | python_requires=">=3.8", 33 | install_requires=['Django>=3.2'], 34 | classifiers=[ 35 | 'Development Status :: 5 - Production/Stable', 36 | 'Environment :: Web Environment', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: BSD License', 39 | 'Operating System :: OS Independent', 40 | 'Programming Language :: Python', 41 | 'Programming Language :: Python :: 3', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Programming Language :: Python :: 3.10', 45 | 'Programming Language :: Python :: 3.11', 46 | 'Programming Language :: Python :: 3.12', 47 | 'Programming Language :: Python :: 3.13', 48 | 'Framework :: Django', 49 | 'Framework :: Django :: 4.2', 50 | 'Framework :: Django :: 5.0', 51 | 'Framework :: Django :: 5.1', 52 | 'Framework :: Django :: 5.2', 53 | ], 54 | zip_safe=False, 55 | package_data={ 56 | 'model_utils': [ 57 | 'locale/*/LC_MESSAGES/django.po', 58 | 'locale/*/LC_MESSAGES/django.mo', 59 | 'py.typed', 60 | ], 61 | }, 62 | ) 63 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | POSTGRES_USER: postgres 7 | POSTGRES_PASSWORD: postgres 8 | POSTGRES_DB: postgres 9 | FORCE_COLOR: 1 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | max-parallel: 5 17 | matrix: 18 | python-version: [ 19 | '3.8', 20 | '3.9', 21 | '3.10', 22 | '3.11', 23 | '3.12', 24 | '3.13', 25 | ] 26 | 27 | services: 28 | postgres: 29 | image: postgres:14-alpine 30 | env: 31 | POSTGRES_USER: ${{ env.POSTGRES_USER }} 32 | POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} 33 | POSTGRES_DB: ${{ env.POSTGRES_DB }} 34 | ports: 35 | - 5432:5432 36 | options: >- 37 | --health-cmd pg_isready 38 | --health-interval 10s 39 | --health-timeout 5s 40 | --health-retries 5 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | 45 | - name: Set up Python ${{ matrix.python-version }} 46 | uses: actions/setup-python@v4 47 | with: 48 | python-version: ${{ matrix.python-version }} 49 | allow-prereleases: 'true' 50 | 51 | - name: Get pip cache dir 52 | id: pip-cache 53 | run: | 54 | echo "::set-output name=dir::$(pip cache dir)" 55 | 56 | - name: Cache 57 | uses: actions/cache@v3 58 | with: 59 | path: ${{ steps.pip-cache.outputs.dir }} 60 | key: 61 | ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} 62 | restore-keys: | 63 | ${{ matrix.python-version }}-v1- 64 | 65 | - name: Install dependencies 66 | run: | 67 | python -m pip install --upgrade pip 68 | python -m pip install --upgrade tox tox-gh-actions 69 | 70 | - name: Tox tests 71 | run: | 72 | tox -v -- --cov --cov-append --cov-report term-missing --cov-report xml 73 | env: 74 | POSTGRES_DB: ${{ env.POSTGRES_DB }} 75 | POSTGRES_USER: ${{ env.POSTGRES_USER }} 76 | POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} 77 | 78 | - name: Upload coverage 79 | uses: codecov/codecov-action@v3 80 | with: 81 | name: Python ${{ matrix.python-version }} 82 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/test_fields/test_urlsafe_token_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest.mock import Mock 4 | 5 | from django.db.models import NOT_PROVIDED 6 | from django.test import TestCase 7 | 8 | from model_utils.fields import UrlsafeTokenField 9 | 10 | 11 | class UrlsaftTokenFieldTests(TestCase): 12 | def test_editable_default(self) -> None: 13 | field = UrlsafeTokenField() 14 | self.assertFalse(field.editable) 15 | 16 | def test_editable(self) -> None: 17 | field = UrlsafeTokenField(editable=True) 18 | self.assertTrue(field.editable) 19 | 20 | def test_max_length_default(self) -> None: 21 | field = UrlsafeTokenField() 22 | self.assertEqual(field.max_length, 128) 23 | 24 | def test_max_length(self) -> None: 25 | field = UrlsafeTokenField(max_length=256) 26 | self.assertEqual(field.max_length, 256) 27 | 28 | def test_factory_default(self) -> None: 29 | field = UrlsafeTokenField() 30 | self.assertIsNone(field._factory) 31 | 32 | def test_factory_not_callable(self) -> None: 33 | with self.assertRaises(TypeError): 34 | UrlsafeTokenField(factory='INVALID') # type: ignore[arg-type] 35 | 36 | def test_get_default(self) -> None: 37 | field = UrlsafeTokenField() 38 | value = field.get_default() 39 | self.assertEqual(len(value), field.max_length) 40 | 41 | def test_get_default_with_non_default_max_length(self) -> None: 42 | field = UrlsafeTokenField(max_length=64) 43 | value = field.get_default() 44 | self.assertEqual(len(value), 64) 45 | 46 | def test_get_default_with_factory(self) -> None: 47 | token = 'SAMPLE_TOKEN' 48 | factory = Mock(return_value=token) 49 | field = UrlsafeTokenField(factory=factory) 50 | value = field.get_default() 51 | 52 | self.assertEqual(value, token) 53 | factory.assert_called_once_with(field.max_length) 54 | 55 | def test_no_default_param(self) -> None: 56 | field = UrlsafeTokenField(default='DEFAULT') 57 | self.assertIs(field.default, NOT_PROVIDED) 58 | 59 | def test_deconstruct(self) -> None: 60 | def test_factory(max_length: int) -> str: 61 | assert False 62 | instance = UrlsafeTokenField(factory=test_factory) 63 | name, path, args, kwargs = instance.deconstruct() 64 | new_instance = UrlsafeTokenField(*args, **kwargs) 65 | self.assertIs(instance._factory, new_instance._factory) 66 | self.assertIs(test_factory, new_instance._factory) 67 | -------------------------------------------------------------------------------- /tests/test_models/test_softdeletable_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | from django.utils.connection import ConnectionDoesNotExist 5 | 6 | from tests.models import SoftDeletable 7 | 8 | 9 | class SoftDeletableModelTests(TestCase): 10 | def test_can_only_see_not_removed_entries(self) -> None: 11 | SoftDeletable.available_objects.create(name='a', is_removed=True) 12 | SoftDeletable.available_objects.create(name='b', is_removed=False) 13 | 14 | queryset = SoftDeletable.available_objects.all() 15 | 16 | self.assertEqual(queryset.count(), 1) 17 | self.assertEqual(queryset[0].name, 'b') 18 | 19 | def test_instance_cannot_be_fully_deleted(self) -> None: 20 | instance = SoftDeletable.available_objects.create(name='a') 21 | 22 | instance.delete() 23 | 24 | self.assertEqual(SoftDeletable.available_objects.count(), 0) 25 | self.assertEqual(SoftDeletable.all_objects.count(), 1) 26 | 27 | def test_instance_cannot_be_fully_deleted_via_queryset(self) -> None: 28 | SoftDeletable.available_objects.create(name='a') 29 | 30 | SoftDeletable.available_objects.all().delete() 31 | 32 | self.assertEqual(SoftDeletable.available_objects.count(), 0) 33 | self.assertEqual(SoftDeletable.all_objects.count(), 1) 34 | 35 | def test_delete_instance_no_connection(self) -> None: 36 | obj = SoftDeletable.available_objects.create(name='a') 37 | 38 | self.assertRaises(ConnectionDoesNotExist, obj.delete, using='other') 39 | 40 | def test_instance_purge(self) -> None: 41 | instance = SoftDeletable.available_objects.create(name='a') 42 | 43 | instance.delete(soft=False) 44 | 45 | self.assertEqual(SoftDeletable.available_objects.count(), 0) 46 | self.assertEqual(SoftDeletable.all_objects.count(), 0) 47 | 48 | def test_instance_purge_no_connection(self) -> None: 49 | instance = SoftDeletable.available_objects.create(name='a') 50 | 51 | self.assertRaises(ConnectionDoesNotExist, instance.delete, 52 | using='other', soft=False) 53 | 54 | def test_deprecation_warning(self) -> None: 55 | self.assertWarns(DeprecationWarning, SoftDeletable.objects.all) 56 | 57 | def test_delete_queryset_return(self) -> None: 58 | SoftDeletable.available_objects.create(name='a') 59 | SoftDeletable.available_objects.create(name='b') 60 | 61 | result = SoftDeletable.available_objects.filter(name="a").delete() 62 | 63 | assert result == ( 64 | 1, {SoftDeletable._meta.label: 1} 65 | ) 66 | -------------------------------------------------------------------------------- /tests/test_fields/test_split_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import Article, SplitFieldAbstractParent 6 | 7 | 8 | class SplitFieldTests(TestCase): 9 | full_text = 'summary\n\n\n\nmore' 10 | excerpt = 'summary\n' 11 | 12 | def setUp(self) -> None: 13 | self.post = Article.objects.create( 14 | title='example post', body=self.full_text) 15 | 16 | def test_unicode_content(self) -> None: 17 | self.assertEqual(str(self.post.body), self.full_text) 18 | 19 | def test_excerpt(self) -> None: 20 | self.assertEqual(self.post.body.excerpt, self.excerpt) 21 | 22 | def test_content(self) -> None: 23 | self.assertEqual(self.post.body.content, self.full_text) 24 | 25 | def test_has_more(self) -> None: 26 | self.assertTrue(self.post.body.has_more) 27 | 28 | def test_not_has_more(self) -> None: 29 | post = Article.objects.create(title='example 2', 30 | body='some text\n\nsome more\n') 31 | self.assertFalse(post.body.has_more) 32 | 33 | def test_load_back(self) -> None: 34 | post = Article.objects.get(pk=self.post.pk) 35 | self.assertEqual(post.body.content, self.post.body.content) 36 | self.assertEqual(post.body.excerpt, self.post.body.excerpt) 37 | 38 | def test_assign_to_body(self) -> None: 39 | new_text = 'different\n\n\n\nother' 40 | self.post.body = new_text 41 | self.post.save() 42 | self.assertEqual(str(self.post.body), new_text) 43 | 44 | def test_assign_to_content(self) -> None: 45 | new_text = 'different\n\n\n\nother' 46 | self.post.body.content = new_text 47 | self.post.save() 48 | self.assertEqual(str(self.post.body), new_text) 49 | 50 | def test_assign_to_excerpt(self) -> None: 51 | with self.assertRaises(AttributeError): 52 | self.post.body.excerpt = 'this should fail' # type: ignore[misc] 53 | 54 | def test_access_via_class(self) -> None: 55 | with self.assertRaises(AttributeError): 56 | Article.body 57 | 58 | def test_assign_splittext(self) -> None: 59 | a = Article(title='Some Title') 60 | a.body = self.post.body 61 | self.assertEqual(a.body.excerpt, 'summary\n') 62 | 63 | def test_value_to_string(self) -> None: 64 | f = self.post._meta.get_field('body') 65 | self.assertEqual(f.value_to_string(self.post), self.full_text) 66 | 67 | def test_abstract_inheritance(self) -> None: 68 | class Child(SplitFieldAbstractParent): 69 | pass 70 | 71 | self.assertEqual( 72 | [f.name for f in Child._meta.fields], 73 | ["id", "content", "_content_excerpt"]) 74 | -------------------------------------------------------------------------------- /tests/test_models/test_deferred_fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.test import TestCase 4 | 5 | from tests.models import ModelWithCustomDescriptor 6 | 7 | 8 | class CustomDescriptorTests(TestCase): 9 | def setUp(self) -> None: 10 | self.instance = ModelWithCustomDescriptor.objects.create( 11 | custom_field='1', 12 | tracked_custom_field='1', 13 | regular_field=1, 14 | tracked_regular_field=1, 15 | ) 16 | 17 | def test_custom_descriptor_works(self) -> None: 18 | instance = self.instance 19 | self.assertEqual(instance.custom_field, '1') 20 | self.assertEqual(instance.__dict__['custom_field'], 1) 21 | self.assertEqual(instance.regular_field, 1) 22 | instance.custom_field = 2 23 | self.assertEqual(instance.custom_field, '2') 24 | self.assertEqual(instance.__dict__['custom_field'], 2) 25 | instance.save() 26 | instance = ModelWithCustomDescriptor.objects.get(pk=instance.pk) 27 | self.assertEqual(instance.custom_field, '2') 28 | self.assertEqual(instance.__dict__['custom_field'], 2) 29 | 30 | def test_deferred(self) -> None: 31 | instance = ModelWithCustomDescriptor.objects.only('id').get( 32 | pk=self.instance.pk) 33 | self.assertIn('custom_field', instance.get_deferred_fields()) 34 | self.assertEqual(instance.custom_field, '1') 35 | self.assertNotIn('custom_field', instance.get_deferred_fields()) 36 | self.assertEqual(instance.regular_field, 1) 37 | self.assertEqual(instance.tracked_custom_field, '1') 38 | self.assertEqual(instance.tracked_regular_field, 1) 39 | 40 | self.assertFalse(instance.tracker.has_changed('tracked_custom_field')) 41 | self.assertFalse(instance.tracker.has_changed('tracked_regular_field')) 42 | 43 | instance.tracked_custom_field = 2 44 | instance.tracked_regular_field = 2 45 | self.assertTrue(instance.tracker.has_changed('tracked_custom_field')) 46 | self.assertTrue(instance.tracker.has_changed('tracked_regular_field')) 47 | instance.save() 48 | 49 | instance = ModelWithCustomDescriptor.objects.get(pk=instance.pk) 50 | self.assertEqual(instance.custom_field, '1') 51 | self.assertEqual(instance.regular_field, 1) 52 | self.assertEqual(instance.tracked_custom_field, '2') 53 | self.assertEqual(instance.tracked_regular_field, 2) 54 | 55 | instance = ModelWithCustomDescriptor.objects.only('id').get(pk=instance.pk) 56 | instance.tracked_custom_field = 3 57 | self.assertEqual(instance.tracked_custom_field, '3') 58 | self.assertTrue(instance.tracker.has_changed('tracked_custom_field')) 59 | del instance.tracked_custom_field 60 | self.assertEqual(instance.tracked_custom_field, '2') 61 | self.assertFalse(instance.tracker.has_changed('tracked_custom_field')) 62 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 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 9 | to abide by the `Contributor Code of Conduct 10 | `_ and follow the `guidelines 11 | `_. 12 | 13 | Below is a list of tips for submitting issues and pull requests. 14 | 15 | Submitting Issues 16 | ----------------- 17 | 18 | Issues are easier to reproduce/resolve when they have: 19 | 20 | - A pull request with a failing test demonstrating the issue 21 | - A code example that produces the issue consistently 22 | - A traceback (when applicable) 23 | 24 | 25 | Pull Requests 26 | ------------- 27 | 28 | When creating a pull request: 29 | 30 | - Write tests 31 | - Note user-facing changes in the `CHANGES`_ file 32 | - Update the documentation 33 | - Add yourself to the `AUTHORS`_ file 34 | - If you have added or changed translated strings, run ``make messages`` to 35 | update the ``.po`` translation files, and update translations for any 36 | languages you know. Then run ``make compilemessages`` to compile the ``.mo`` 37 | files. If your pull request leaves some translations incomplete, please 38 | mention that in the pull request and commit message. 39 | 40 | .. _AUTHORS: AUTHORS.rst 41 | .. _CHANGES: CHANGES.rst 42 | 43 | 44 | Translations 45 | ------------ 46 | 47 | If you are able to provide translations for a new language or to update an 48 | existing translation file, make sure to run makemessages beforehand:: 49 | 50 | python django-admin makemessages -l ISO_LANGUAGE_CODE 51 | 52 | This command will collect all translation strings from the source directory 53 | and create or update the translation file for the given language. Now open the 54 | translation file (.po) with a text-editor and start editing. 55 | After you finished editing add yourself to the list of translators. 56 | If you have created a new translation, make sure to copy the header from one 57 | of the existing translation files. 58 | 59 | 60 | Testing 61 | ------- 62 | 63 | Please add tests for your code and ensure existing tests don't break. To run 64 | the tests against your code:: 65 | 66 | python setup.py test 67 | 68 | Please use tox to test the code against supported Python and Django versions. 69 | First install tox:: 70 | 71 | pip install tox coverage 72 | 73 | To run tox and generate a coverage report (in ``htmlcov`` directory):: 74 | 75 | make test 76 | 77 | A database is required to run the tests. For convince there is a ``docker-compose.yml`` file for spinning up a 78 | database container. To start the database container run: 79 | 80 | docker-compose up -d 81 | 82 | Another way to run the tests with a sqlite database is to export the `SQLITE` variable:: 83 | 84 | export SQLITE=1 85 | make test 86 | # or 87 | SQLITE=1 python setup.py test 88 | 89 | **Please note**: Before a pull request can be merged, all tests must pass and 90 | code/branch coverage in tests must be 100%. 91 | 92 | Code Formatting 93 | --------------- 94 | We make use of `isort`_ to sort imports. 95 | 96 | .. _isort: https://pycqa.github.io/isort/ 97 | 98 | Once it is installed you can make sure the code is properly formatted by running:: 99 | 100 | make format 101 | -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | Models 2 | ====== 3 | 4 | TimeFramedModel 5 | --------------- 6 | 7 | An abstract base class for any model that expresses a time-range. Adds 8 | ``start`` and ``end`` nullable DateTimeFields, and provides a new 9 | ``timeframed`` manager on the subclass whose queryset pre-filters results 10 | to only include those which have a ``start`` which is not in the future, 11 | and an ``end`` which is not in the past. If either ``start`` or ``end`` is 12 | ``null``, the manager will include it. 13 | 14 | .. code-block:: python 15 | 16 | from model_utils.models import TimeFramedModel 17 | from datetime import datetime, timedelta 18 | class Post(TimeFramedModel): 19 | pass 20 | 21 | p = Post() 22 | p.start = datetime.utcnow() - timedelta(days=1) 23 | p.end = datetime.utcnow() + timedelta(days=7) 24 | p.save() 25 | 26 | # this query will return the above Post instance: 27 | Post.timeframed.all() 28 | 29 | p.start = None 30 | p.end = None 31 | p.save() 32 | 33 | # this query will also return the above Post instance, because 34 | # the `start` and/or `end` are NULL. 35 | Post.timeframed.all() 36 | 37 | p.start = datetime.utcnow() + timedelta(days=7) 38 | p.save() 39 | 40 | # this query will NOT return our Post instance, because 41 | # the start date is in the future. 42 | Post.timeframed.all() 43 | 44 | TimeStampedModel 45 | ---------------- 46 | 47 | This abstract base class just provides self-updating ``created`` and 48 | ``modified`` fields on any model that inherits from it. 49 | 50 | 51 | StatusModel 52 | ----------- 53 | 54 | Pulls together :ref:`StatusField`, :ref:`MonitorField` and :ref:`QueryManager` 55 | into an abstract base class for any model with a "status." 56 | 57 | Just provide a ``STATUS`` class-attribute (a :ref:`Choices` object or a 58 | list of two-tuples), and your model will have a ``status`` field with 59 | those choices, a ``status_changed`` field containing the date-time the 60 | ``status`` was last changed, and a manager for each status that 61 | returns objects with that status only: 62 | 63 | .. code-block:: python 64 | 65 | from model_utils.models import StatusModel 66 | from model_utils import Choices 67 | 68 | class Article(StatusModel): 69 | STATUS = Choices('draft', 'published') 70 | 71 | # ... 72 | 73 | a = Article() 74 | a.status = Article.STATUS.published 75 | 76 | # this save will update a.status_changed 77 | a.save() 78 | 79 | # this query will only return published articles: 80 | Article.published.all() 81 | 82 | 83 | SoftDeletableModel 84 | ------------------ 85 | 86 | This abstract base class just provides a field ``is_removed`` which is 87 | set to True instead of removing the instance. Entities returned in 88 | manager ``available_objects`` are limited to not-deleted instances. 89 | 90 | Note that relying on the default ``objects`` manager to filter out not-deleted 91 | instances is deprecated. ``objects`` will include deleted objects in a future 92 | release. Until then, the recommended course of action is to use the manager 93 | ``all_objects`` when you want to include all instances. 94 | 95 | UUIDModel 96 | ------------------ 97 | 98 | This abstract base class provides ``id`` field on any model that inherits from it 99 | which will be the primary key. 100 | 101 | If you dont want to set ``id`` as primary key or change the field name, you can override it 102 | with our `UUIDField`_ 103 | 104 | Also you can override the default uuid version. Versions 1,3,4 and 5 are now supported. 105 | 106 | .. code-block:: python 107 | 108 | from model_utils.models import UUIDModel 109 | 110 | class MyAppModel(UUIDModel): 111 | pass 112 | 113 | 114 | .. _`UUIDField`: https://github.com/jazzband/django-model-utils/blob/master/docs/fields.rst#uuidfield 115 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | | Adam Bogdał 2 | | Adam Chainz 3 | | Adam Dobrawy 4 | | Adam Nelson 5 | | Alejandro Varas 6 | | Alex Orange 7 | | Alexander Kavanaugh 8 | | Alexey Evseev 9 | | Andy Freeland 10 | | Ant Somers 11 | | Antti Kaihola 12 | | Arseny Sysolyatin 13 | | Artis Avotins 14 | | Asif Saif Uddin 15 | | Bo Marchman 16 | | Bojan Mihelac 17 | | Bruno Alla 18 | | Bugra Aydin 19 | | Craig Anderson 20 | | Daniel Andrlik 21 | | Daniel Stanton 22 | | Den Lesnov 23 | | Diego Navarro 24 | | Dmytro Kyrychuck 25 | | Donald Stufft 26 | | Douglas Meehan 27 | | Emin Bugra Saral 28 | | Enrique Matías Sánchez 29 | | Eran Rundstein 30 | | Eugene Kuznetsov 31 | | Felipe Prenholato 32 | | Filipe Ximenes 33 | | Florian Alu 34 | | Germano Massullo 35 | | Gregor Müllegger 36 | | Guilherme Devincenzi 37 | | Guilherme Crocetti 38 | | Hanley 39 | | Hanley Hansen 40 | | Harry Moreno 41 | | Hasan Ramezani 42 | | Ivan Virabyan 43 | | JMP 44 | | Jack Cushman 45 | | James Oakley 46 | | Jannis Leidel 47 | | Javier Garcia Sogo 48 | | Jeff Elmore 49 | | Joe Riddle 50 | | John Vandenberg 51 | | Jonathan Sundqvist 52 | | João Amaro 53 | | Karl WnW 54 | | Keryn Knight 55 | | Lucas Wiman 56 | | Martey Dodoo 57 | | Matthew Schinckel 58 | | Matthieu Rigal 59 | | Michael van Tellingen 60 | | Mike Bryant 61 | | Mikhail Silonov 62 | | Misha Kalyna 63 | | Nick Sandford 64 | | Patryk Zawadzki 65 | | Paul McLanahan 66 | | Philipp Steinhardt 67 | | Radosław Ganczarek 68 | | Reece Dunham 69 | | Remy Suen 70 | | Rinat Shigapov 71 | | Rodney Folz 72 | | Romain G 73 | | Romain Garrigues 74 | | Roman 75 | | Ryan Kaskel 76 | | Ryan P Kilby 77 | | Ryan Senkbeil 78 | | Rémy HUBSCHER 79 | | Sachi King 80 | | Sebastian Illing 81 | | Sergey Tikhonov 82 | | Sergey Zherevchuk 83 | | Seán Hayes 84 | | Simon Charette 85 | | Simon Meers 86 | | Skia 87 | | Tavistock 88 | | Thomas Schreiber 89 | | Tony Aldridge 90 | | Tony Narlock 91 | | Travis Swicegood 92 | | Trey Hunner 93 | | Václav Dohnal 94 | | Zach Cheung 95 | | ad-m 96 | | asday 97 | | bboogaard 98 | | funkybob 99 | | georgemillard 100 | | jarekwg 101 | | romgar 102 | | silonov 103 | | smacker 104 | | zyegfryed 105 | | Éric Araujo 106 | | Őry Máté 107 | | Nafees Anwar 108 | | meanmail 109 | | Nicholas Prat 110 | -------------------------------------------------------------------------------- /tests/test_models/test_status_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timezone 4 | 5 | import time_machine 6 | from django.test.testcases import TestCase 7 | 8 | from tests.models import CustomManagerStatusModel, Status, StatusPlainTuple 9 | 10 | 11 | class StatusModelTests(TestCase): 12 | model: type[Status] | type[StatusPlainTuple] 13 | 14 | def setUp(self) -> None: 15 | self.model = Status 16 | self.on_hold = Status.STATUS.on_hold 17 | self.active = Status.STATUS.active 18 | 19 | def test_created(self) -> None: 20 | with time_machine.travel(datetime(2016, 1, 1)): 21 | c1 = self.model.objects.create() 22 | self.assertTrue(c1.status_changed, datetime(2016, 1, 1)) 23 | 24 | self.model.objects.create() 25 | self.assertEqual(self.model.active.count(), 2) 26 | self.assertEqual(self.model.deleted.count(), 0) 27 | 28 | def test_modification(self) -> None: 29 | t1 = self.model.objects.create() 30 | date_created = t1.status_changed 31 | t1.status = self.on_hold 32 | t1.save() 33 | self.assertEqual(self.model.active.count(), 0) 34 | self.assertEqual(self.model.on_hold.count(), 1) 35 | self.assertTrue(t1.status_changed > date_created) 36 | date_changed = t1.status_changed 37 | t1.save() 38 | self.assertEqual(t1.status_changed, date_changed) 39 | date_active_again = t1.status_changed 40 | t1.status = self.active 41 | t1.save() 42 | self.assertTrue(t1.status_changed > date_active_again) 43 | 44 | def test_save_with_update_fields_overrides_status_changed_provided(self) -> None: 45 | ''' 46 | Tests if the save method updated status_changed field 47 | accordingly when update_fields is used as an argument 48 | and status_changed is provided 49 | ''' 50 | with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc)): 51 | t1 = Status.objects.create() 52 | 53 | with time_machine.travel(datetime(2020, 1, 2, tzinfo=timezone.utc)): 54 | t1.status = Status.on_hold 55 | t1.save(update_fields=['status', 'status_changed']) 56 | 57 | self.assertEqual(t1.status_changed, datetime(2020, 1, 2, tzinfo=timezone.utc)) 58 | 59 | def test_save_with_update_fields_overrides_status_changed_not_provided(self) -> None: 60 | ''' 61 | Tests if the save method updated status_changed field 62 | accordingly when update_fields is used as an argument 63 | with status and status_changed is not provided 64 | ''' 65 | with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc)): 66 | t1 = Status.objects.create() 67 | 68 | with time_machine.travel(datetime(2020, 1, 2, tzinfo=timezone.utc)): 69 | t1.status = Status.on_hold 70 | t1.save(update_fields=['status']) 71 | 72 | self.assertEqual(t1.status_changed, datetime(2020, 1, 2, tzinfo=timezone.utc)) 73 | 74 | 75 | class StatusModelPlainTupleTests(StatusModelTests): 76 | def setUp(self) -> None: 77 | self.model = StatusPlainTuple 78 | self.on_hold = StatusPlainTuple.STATUS[2][0] 79 | self.active = StatusPlainTuple.STATUS[0][0] 80 | 81 | 82 | class StatusModelDefaultManagerTests(TestCase): 83 | 84 | def test_default_manager_is_not_status_model_generated_ones(self) -> None: 85 | # Regression test for GH-251 86 | # The logic behind order for managers seems to have changed in Django 1.10 87 | # and affects default manager. 88 | # This code was previously failing because the first custom manager (which filters 89 | # with first Choice value, here 'first_choice') generated by StatusModel was 90 | # considered as default manager... 91 | # This situation only happens when we define a model inheriting from an "abstract" 92 | # class which defines an "objects" manager. 93 | 94 | CustomManagerStatusModel.objects.create(status='first_choice') 95 | CustomManagerStatusModel.objects.create(status='second_choice') 96 | CustomManagerStatusModel.objects.create(status='second_choice') 97 | 98 | # ...which made this count() equal to 1 (only 1 element with status='first_choice')... 99 | self.assertEqual(CustomManagerStatusModel._default_manager.count(), 3) 100 | 101 | # ...and this one equal to 0, because of 2 successive filters of 'first_choice' 102 | # (default manager) and 'second_choice' (explicit filter below). 103 | self.assertEqual(CustomManagerStatusModel._default_manager.filter(status='second_choice').count(), 2) 104 | -------------------------------------------------------------------------------- /tests/test_fields/test_monitor_field.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime, timezone 4 | 5 | import time_machine 6 | from django.test import TestCase 7 | 8 | from model_utils.fields import MonitorField 9 | from tests.models import DoubleMonitored, Monitored, MonitorWhen, MonitorWhenEmpty 10 | 11 | 12 | class MonitorFieldTests(TestCase): 13 | def setUp(self) -> None: 14 | with time_machine.travel(datetime(2016, 1, 1, 10, 0, 0, tzinfo=timezone.utc)): 15 | self.instance = Monitored(name='Charlie') 16 | self.created = self.instance.name_changed 17 | 18 | def test_save_no_change(self) -> None: 19 | self.instance.save() 20 | self.assertEqual(self.instance.name_changed, self.created) 21 | 22 | def test_save_changed(self) -> None: 23 | with time_machine.travel(datetime(2016, 1, 1, 12, 0, 0, tzinfo=timezone.utc)): 24 | self.instance.name = 'Maria' 25 | self.instance.save() 26 | self.assertEqual(self.instance.name_changed, datetime(2016, 1, 1, 12, 0, 0, tzinfo=timezone.utc)) 27 | 28 | def test_double_save(self) -> None: 29 | self.instance.name = 'Jose' 30 | self.instance.save() 31 | changed = self.instance.name_changed 32 | self.instance.save() 33 | self.assertEqual(self.instance.name_changed, changed) 34 | 35 | def test_no_monitor_arg(self) -> None: 36 | with self.assertRaises(TypeError): 37 | MonitorField() # type: ignore[call-arg] 38 | 39 | def test_monitor_default_is_none_when_nullable(self) -> None: 40 | self.assertIsNone(self.instance.name_changed_nullable) 41 | expected_datetime = datetime(2022, 1, 18, 12, 0, 0, tzinfo=timezone.utc) 42 | 43 | self.instance.name = "Jose" 44 | with time_machine.travel(expected_datetime, tick=False): 45 | self.instance.save() 46 | 47 | self.assertEqual(self.instance.name_changed_nullable, expected_datetime) 48 | 49 | 50 | class MonitorWhenFieldTests(TestCase): 51 | """ 52 | Will record changes only when name is 'Jose' or 'Maria' 53 | """ 54 | def setUp(self) -> None: 55 | with time_machine.travel(datetime(2016, 1, 1, 10, 0, 0, tzinfo=timezone.utc)): 56 | self.instance = MonitorWhen(name='Charlie') 57 | self.created = self.instance.name_changed 58 | 59 | def test_save_no_change(self) -> None: 60 | self.instance.save() 61 | self.assertEqual(self.instance.name_changed, self.created) 62 | 63 | def test_save_changed_to_Jose(self) -> None: 64 | with time_machine.travel(datetime(2016, 1, 1, 12, 0, 0, tzinfo=timezone.utc)): 65 | self.instance.name = 'Jose' 66 | self.instance.save() 67 | self.assertEqual(self.instance.name_changed, datetime(2016, 1, 1, 12, 0, 0, tzinfo=timezone.utc)) 68 | 69 | def test_save_changed_to_Maria(self) -> None: 70 | with time_machine.travel(datetime(2016, 1, 1, 12, 0, 0, tzinfo=timezone.utc)): 71 | self.instance.name = 'Maria' 72 | self.instance.save() 73 | self.assertEqual(self.instance.name_changed, datetime(2016, 1, 1, 12, 0, 0, tzinfo=timezone.utc)) 74 | 75 | def test_save_changed_to_Pedro(self) -> None: 76 | self.instance.name = 'Pedro' 77 | self.instance.save() 78 | self.assertEqual(self.instance.name_changed, self.created) 79 | 80 | def test_double_save(self) -> None: 81 | self.instance.name = 'Jose' 82 | self.instance.save() 83 | changed = self.instance.name_changed 84 | self.instance.save() 85 | self.assertEqual(self.instance.name_changed, changed) 86 | 87 | 88 | class MonitorWhenEmptyFieldTests(TestCase): 89 | """ 90 | Monitor should never be updated id when is an empty list. 91 | """ 92 | def setUp(self) -> None: 93 | self.instance = MonitorWhenEmpty(name='Charlie') 94 | self.created = self.instance.name_changed 95 | 96 | def test_save_no_change(self) -> None: 97 | self.instance.save() 98 | self.assertEqual(self.instance.name_changed, self.created) 99 | 100 | def test_save_changed_to_Jose(self) -> None: 101 | self.instance.name = 'Jose' 102 | self.instance.save() 103 | self.assertEqual(self.instance.name_changed, self.created) 104 | 105 | def test_save_changed_to_Maria(self) -> None: 106 | self.instance.name = 'Maria' 107 | self.instance.save() 108 | self.assertEqual(self.instance.name_changed, self.created) 109 | 110 | 111 | class MonitorDoubleFieldTests(TestCase): 112 | 113 | def setUp(self) -> None: 114 | DoubleMonitored.objects.create(name='Charlie', name2='Charlie2') 115 | 116 | def test_recursion_error_with_only(self) -> None: 117 | # Any field passed to only() is generating a recursion error 118 | list(DoubleMonitored.objects.only('id')) 119 | 120 | def test_recursion_error_with_defer(self) -> None: 121 | # Only monitored fields passed to defer() are failing 122 | list(DoubleMonitored.objects.defer('name')) 123 | 124 | def test_monitor_still_works_with_deferred_fields_filtered_out_of_save_initial(self) -> None: 125 | obj = DoubleMonitored.objects.defer('name').get(name='Charlie') 126 | with time_machine.travel(datetime(2016, 12, 1, tzinfo=timezone.utc)): 127 | obj.name = 'Charlie2' 128 | obj.save() 129 | self.assertEqual(obj.name_changed, datetime(2016, 12, 1, tzinfo=timezone.utc)) 130 | -------------------------------------------------------------------------------- /docs/managers.rst: -------------------------------------------------------------------------------- 1 | Model Managers 2 | ============== 3 | 4 | InheritanceManager 5 | ------------------ 6 | 7 | This manager (`contributed by Jeff Elmore`_) should be attached to a base model 8 | class in a model-inheritance tree. It allows queries on that base model to 9 | return heterogeneous results of the actual proper subtypes, without any 10 | additional queries. 11 | 12 | For instance, if you have a ``Place`` model with subclasses ``Restaurant`` and 13 | ``Bar``, you may want to query all Places: 14 | 15 | .. code-block:: python 16 | 17 | nearby_places = Place.objects.filter(location='here') 18 | 19 | But when you iterate over ``nearby_places``, you'll get only ``Place`` 20 | instances back, even for objects that are "really" ``Restaurant`` or ``Bar``. 21 | If you attach an ``InheritanceManager`` to ``Place``, you can just call the 22 | ``select_subclasses()`` method on the ``InheritanceManager`` or any 23 | ``QuerySet`` from it, and the resulting objects will be instances of 24 | ``Restaurant`` or ``Bar``: 25 | 26 | .. code-block:: python 27 | 28 | from model_utils.managers import InheritanceManager 29 | 30 | class Place(models.Model): 31 | # ... 32 | objects = InheritanceManager() 33 | 34 | class Restaurant(Place): 35 | # ... 36 | 37 | class Bar(Place): 38 | # ... 39 | 40 | nearby_places = Place.objects.filter(location='here').select_subclasses() 41 | for place in nearby_places: 42 | # "place" will automatically be an instance of Place, Restaurant, or Bar 43 | 44 | The database query performed will have an extra join for each subclass; if you 45 | want to reduce the number of joins and you only need particular subclasses to 46 | be returned as their actual type, you can pass subclass names to 47 | ``select_subclasses()``, much like the built-in ``select_related()`` method: 48 | 49 | .. code-block:: python 50 | 51 | nearby_places = Place.objects.select_subclasses("restaurant") 52 | # restaurants will be Restaurant instances, bars will still be Place instances 53 | 54 | nearby_places = Place.objects.select_subclasses("restaurant", "bar") 55 | # all Places will be converted to Restaurant and Bar instances. 56 | 57 | It is also possible to use the subclasses themselves as arguments to 58 | ``select_subclasses``, leaving it to calculate the relationship for you: 59 | 60 | .. code-block:: python 61 | 62 | nearby_places = Place.objects.select_subclasses(Restaurant) 63 | # restaurants will be Restaurant instances, bars will still be Place instances 64 | 65 | nearby_places = Place.objects.select_subclasses(Restaurant, Bar) 66 | # all Places will be converted to Restaurant and Bar instances. 67 | 68 | It is even possible to mix and match the two: 69 | 70 | .. code-block:: python 71 | 72 | nearby_places = Place.objects.select_subclasses(Restaurant, "bar") 73 | # all Places will be converted to Restaurant and Bar instances. 74 | 75 | ``InheritanceManager`` also provides a subclass-fetching alternative to the 76 | ``get()`` method: 77 | 78 | .. code-block:: python 79 | 80 | place = Place.objects.get_subclass(id=some_id) 81 | # "place" will automatically be an instance of Place, Restaurant, or Bar 82 | 83 | If you don't explicitly call ``select_subclasses()`` or ``get_subclass()``, 84 | an ``InheritanceManager`` behaves identically to a normal ``Manager``; so 85 | it's safe to use as your default manager for the model. 86 | 87 | .. _contributed by Jeff Elmore: https://jeffelmore.org/2010/11/11/automatic-downcasting-of-inherited-models-in-django/ 88 | 89 | JoinQueryset 90 | ------------ 91 | 92 | A ``JoinQueryset`` will create a temporary table containing its own query result 93 | and join that temporary table with the model it is querying. This can 94 | be advantageous if you have to page through your entire DB and using django's 95 | slice mechanism to do that. ``LIMIT .. OFFSET ..`` becomes slower the bigger 96 | offset you use. 97 | 98 | .. code-block:: python 99 | 100 | sliced_qs = Place.objects.all()[2000:2010] 101 | qs = sliced_qs.join() 102 | # qs contains 10 objects, and there will be a much smaller performance hit 103 | # for paging through all of first 2000 objects. 104 | 105 | Alternatively, you can give it another queryset and ``JoinQueryset`` will create 106 | a temporary table containing the result of the given queryset and 107 | join that temporary table to itself. This can work as a more performant 108 | alternative to using django's ``__in`` as described in the following 109 | (`StackExchange answer`_). 110 | 111 | .. code-block:: python 112 | 113 | big_qs = Restaurant.objects.filter(menu='vegetarian') 114 | qs = Country.objects.filter(country_code='SE').join(big_qs) 115 | 116 | .. _StackExchange answer: https://dba.stackexchange.com/questions/91247/optimizing-a-postgres-query-with-a-large-in 117 | 118 | You can create a manager that produces ``JoinQueryset`` instances using ``JoinQueryset.as_manager()``. 119 | 120 | .. _QueryManager: 121 | 122 | QueryManager 123 | ------------ 124 | 125 | Many custom model managers do nothing more than return a QuerySet that 126 | is filtered in some way. ``QueryManager`` allows you to express this 127 | pattern with a minimum of boilerplate: 128 | 129 | .. code-block:: python 130 | 131 | from django.db import models 132 | from model_utils.managers import QueryManager 133 | 134 | class Post(models.Model): 135 | ... 136 | published = models.BooleanField() 137 | pub_date = models.DateField() 138 | ... 139 | 140 | objects = models.Manager() 141 | public = QueryManager(published=True).order_by('-pub_date') 142 | 143 | The kwargs passed to ``QueryManager`` will be passed as-is to the 144 | ``QuerySet.filter()`` method. You can also pass a ``Q`` object to 145 | ``QueryManager`` to express more complex conditions. Note that you can 146 | set the ordering of the ``QuerySet`` returned by the ``QueryManager`` 147 | by chaining a call to ``.order_by()`` on the ``QueryManager`` (this is 148 | not required). 149 | 150 | SoftDeletableManager 151 | -------------------- 152 | 153 | Returns only model instances that have the ``is_removed`` field set 154 | to False. Uses ``SoftDeletableQuerySet``, which ensures model instances 155 | won't be removed in bulk, but they will be marked as removed instead. 156 | 157 | Mixins 158 | ------ 159 | 160 | Each of the above manager classes has a corresponding mixin that can be used to 161 | add functionality to any manager. 162 | 163 | Note that any manager class using ``InheritanceManagerMixin`` must return a 164 | ``QuerySet`` class using ``InheritanceQuerySetMixin`` from its ``get_queryset`` 165 | method. 166 | -------------------------------------------------------------------------------- /tests/test_models/test_timestamped_model.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Iterable 4 | from datetime import datetime, timedelta, timezone 5 | 6 | import time_machine 7 | from django.test import TestCase 8 | 9 | from tests.models import TimeStamp, TimeStampWithStatusModel 10 | 11 | 12 | class TimeStampedModelTests(TestCase): 13 | def test_created(self) -> None: 14 | with time_machine.travel(datetime(2016, 1, 1, tzinfo=timezone.utc)): 15 | t1 = TimeStamp.objects.create() 16 | self.assertEqual(t1.created, datetime(2016, 1, 1, tzinfo=timezone.utc)) 17 | 18 | def test_created_sets_modified(self) -> None: 19 | ''' 20 | Ensure that on creation that modified is set exactly equal to created. 21 | ''' 22 | t1 = TimeStamp.objects.create() 23 | self.assertEqual(t1.created, t1.modified) 24 | 25 | def test_modified(self) -> None: 26 | with time_machine.travel(datetime(2016, 1, 1, tzinfo=timezone.utc)): 27 | t1 = TimeStamp.objects.create() 28 | 29 | with time_machine.travel(datetime(2016, 1, 2, tzinfo=timezone.utc)): 30 | t1.save() 31 | 32 | self.assertEqual(t1.modified, datetime(2016, 1, 2, tzinfo=timezone.utc)) 33 | 34 | def test_overriding_created_via_object_creation_also_uses_creation_date_for_modified(self) -> None: 35 | """ 36 | Setting the created date when first creating an object 37 | should be permissible. 38 | """ 39 | different_date = datetime.today() - timedelta(weeks=52) 40 | t1 = TimeStamp.objects.create(created=different_date) 41 | self.assertEqual(t1.created, different_date) 42 | self.assertEqual(t1.modified, different_date) 43 | 44 | def test_overriding_modified_via_object_creation(self) -> None: 45 | """ 46 | Setting the modified date explicitly should be possible when 47 | first creating an object, but not thereafter. 48 | """ 49 | different_date = datetime.today() - timedelta(weeks=52) 50 | t1 = TimeStamp.objects.create(modified=different_date) 51 | self.assertEqual(t1.modified, different_date) 52 | self.assertNotEqual(t1.created, different_date) 53 | 54 | def test_overriding_created_after_object_created(self) -> None: 55 | """ 56 | The created date may be changed post-create 57 | """ 58 | t1 = TimeStamp.objects.create() 59 | different_date = datetime.today() - timedelta(weeks=52) 60 | t1.created = different_date 61 | t1.save() 62 | self.assertEqual(t1.created, different_date) 63 | 64 | def test_overriding_modified_after_object_created(self) -> None: 65 | """ 66 | The modified date should always be updated when the object 67 | is saved, regardless of attempts to change it. 68 | """ 69 | t1 = TimeStamp.objects.create() 70 | different_date = datetime.today() - timedelta(weeks=52) 71 | t1.modified = different_date 72 | t1.save() 73 | self.assertNotEqual(t1.modified, different_date) 74 | 75 | def test_overrides_using_save(self) -> None: 76 | """ 77 | The first time an object is saved, allow modification of both 78 | created and modified fields. 79 | After that, only created may be modified manually. 80 | """ 81 | t1 = TimeStamp() 82 | different_date = datetime.today() - timedelta(weeks=52) 83 | t1.created = different_date 84 | t1.modified = different_date 85 | t1.save() 86 | self.assertEqual(t1.created, different_date) 87 | self.assertEqual(t1.modified, different_date) 88 | different_date2 = datetime.today() - timedelta(weeks=26) 89 | t1.created = different_date2 90 | t1.modified = different_date2 91 | t1.save() 92 | self.assertEqual(t1.created, different_date2) 93 | self.assertNotEqual(t1.modified, different_date2) 94 | self.assertNotEqual(t1.modified, different_date) 95 | 96 | def test_save_with_update_fields_overrides_modified_provided_within_a(self) -> None: 97 | """ 98 | Tests if the save method updated modified field 99 | accordingly when update_fields is used as an argument 100 | and modified is provided 101 | """ 102 | tests = ( 103 | ['modified'], # list 104 | ('modified',), # tuple 105 | {'modified'}, # set 106 | ) 107 | 108 | for update_fields in tests: 109 | with self.subTest(update_fields=update_fields): 110 | with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc)): 111 | t1 = TimeStamp.objects.create() 112 | 113 | with time_machine.travel(datetime(2020, 1, 2, tzinfo=timezone.utc)): 114 | t1.save(update_fields=update_fields) 115 | self.assertEqual(t1.modified, datetime(2020, 1, 2, tzinfo=timezone.utc)) 116 | 117 | def test_save_is_skipped_for_empty_update_fields_iterable(self) -> None: 118 | tests: Iterable[Iterable[str]] = ( 119 | [], # list 120 | (), # tuple 121 | set(), # set 122 | ) 123 | 124 | for update_fields in tests: 125 | with self.subTest(update_fields=update_fields): 126 | with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc)): 127 | t1 = TimeStamp.objects.create() 128 | 129 | with time_machine.travel(datetime(2020, 1, 2, tzinfo=timezone.utc)): 130 | t1.test_field = 1 131 | t1.save(update_fields=update_fields) 132 | 133 | t1.refresh_from_db() 134 | self.assertEqual(t1.test_field, 0) 135 | self.assertEqual(t1.modified, datetime(2020, 1, 1, tzinfo=timezone.utc)) 136 | 137 | def test_save_updates_modified_value_when_update_fields_explicitly_set_to_none(self) -> None: 138 | with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc)): 139 | t1 = TimeStamp.objects.create() 140 | 141 | with time_machine.travel(datetime(2020, 1, 2, tzinfo=timezone.utc)): 142 | t1.save(update_fields=None) 143 | 144 | self.assertEqual(t1.modified, datetime(2020, 1, 2, tzinfo=timezone.utc)) 145 | 146 | def test_model_inherit_timestampmodel_and_statusmodel(self) -> None: 147 | with time_machine.travel(datetime(2020, 1, 1, tzinfo=timezone.utc)): 148 | t1 = TimeStampWithStatusModel.objects.create() 149 | 150 | with time_machine.travel(datetime(2020, 1, 2, tzinfo=timezone.utc)): 151 | t1.save(update_fields=['test_field', 'status']) 152 | 153 | self.assertEqual(t1.modified, datetime(2020, 1, 2, tzinfo=timezone.utc)) 154 | -------------------------------------------------------------------------------- /model_utils/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Literal, TypeVar, overload 4 | 5 | from django.core.exceptions import ImproperlyConfigured 6 | from django.db import models 7 | from django.db.models.functions import Now 8 | from django.utils.translation import gettext_lazy as _ 9 | 10 | from model_utils.fields import ( 11 | AutoCreatedField, 12 | AutoLastModifiedField, 13 | MonitorField, 14 | StatusField, 15 | UUIDField, 16 | ) 17 | from model_utils.managers import QueryManager, SoftDeletableManager 18 | 19 | ModelT = TypeVar('ModelT', bound=models.Model, covariant=True) 20 | 21 | now = Now() 22 | 23 | 24 | class TimeStampedModel(models.Model): 25 | """ 26 | An abstract base class model that provides self-updating 27 | ``created`` and ``modified`` fields. 28 | 29 | """ 30 | created = AutoCreatedField(_('created')) 31 | modified = AutoLastModifiedField(_('modified')) 32 | 33 | def save(self, *args: Any, **kwargs: Any) -> None: 34 | """ 35 | Overriding the save method in order to make sure that 36 | modified field is updated even if it is not given as 37 | a parameter to the update field argument. 38 | """ 39 | update_fields = kwargs.get('update_fields', None) 40 | if update_fields: 41 | kwargs['update_fields'] = set(update_fields).union({'modified'}) 42 | 43 | super().save(*args, **kwargs) 44 | 45 | class Meta: 46 | abstract = True 47 | 48 | 49 | class TimeFramedModel(models.Model): 50 | """ 51 | An abstract base class model that provides ``start`` 52 | and ``end`` fields to record a timeframe. 53 | 54 | """ 55 | start = models.DateTimeField(_('start'), null=True, blank=True) 56 | end = models.DateTimeField(_('end'), null=True, blank=True) 57 | 58 | class Meta: 59 | abstract = True 60 | 61 | 62 | class StatusModel(models.Model): 63 | """ 64 | An abstract base class model with a ``status`` field that 65 | automatically uses a ``STATUS`` class attribute of choices, a 66 | ``status_changed`` date-time field that records when ``status`` 67 | was last modified, and an automatically-added manager for each 68 | status that returns objects with that status only. 69 | 70 | """ 71 | status = StatusField(_('status')) 72 | status_changed = MonitorField(_('status changed'), monitor='status') 73 | 74 | def save(self, *args: Any, **kwargs: Any) -> None: 75 | """ 76 | Overriding the save method in order to make sure that 77 | status_changed field is updated even if it is not given as 78 | a parameter to the update field argument. 79 | """ 80 | update_fields = kwargs.get('update_fields', None) 81 | if update_fields and 'status' in update_fields: 82 | kwargs['update_fields'] = set(update_fields).union({'status_changed'}) 83 | 84 | super().save(*args, **kwargs) 85 | 86 | class Meta: 87 | abstract = True 88 | 89 | 90 | def add_status_query_managers(sender: type[models.Model], **kwargs: Any) -> None: 91 | """ 92 | Add a Querymanager for each status item dynamically. 93 | 94 | """ 95 | if not issubclass(sender, StatusModel): 96 | return 97 | 98 | default_manager = sender._meta.default_manager 99 | assert default_manager is not None 100 | 101 | for value, display in getattr(sender, 'STATUS', ()): 102 | if _field_exists(sender, value): 103 | raise ImproperlyConfigured( 104 | "StatusModel: Model '%s' has a field named '%s' which " 105 | "conflicts with a status of the same name." 106 | % (sender.__name__, value) 107 | ) 108 | sender.add_to_class(value, QueryManager(status=value)) 109 | 110 | sender._meta.default_manager_name = default_manager.name 111 | 112 | 113 | def add_timeframed_query_manager(sender: type[models.Model], **kwargs: Any) -> None: 114 | """ 115 | Add a QueryManager for a specific timeframe. 116 | 117 | """ 118 | if not issubclass(sender, TimeFramedModel): 119 | return 120 | if _field_exists(sender, 'timeframed'): 121 | raise ImproperlyConfigured( 122 | "Model '%s' has a field named 'timeframed' " 123 | "which conflicts with the TimeFramedModel manager." 124 | % sender.__name__ 125 | ) 126 | sender.add_to_class('timeframed', QueryManager( 127 | (models.Q(start__lte=now) | models.Q(start__isnull=True)) 128 | & (models.Q(end__gte=now) | models.Q(end__isnull=True)) 129 | )) 130 | 131 | 132 | models.signals.class_prepared.connect(add_status_query_managers) 133 | models.signals.class_prepared.connect(add_timeframed_query_manager) 134 | 135 | 136 | def _field_exists(model_class: type[models.Model], field_name: str) -> bool: 137 | return field_name in [f.attname for f in model_class._meta.local_fields] 138 | 139 | 140 | class SoftDeletableModel(models.Model): 141 | """ 142 | An abstract base class model with a ``is_removed`` field that 143 | marks entries that are not going to be used anymore, but are 144 | kept in db for any reason. 145 | Default manager returns only not-removed entries. 146 | """ 147 | is_removed = models.BooleanField(default=False) 148 | 149 | class Meta: 150 | abstract = True 151 | 152 | objects: models.Manager[SoftDeletableModel] = SoftDeletableManager(_emit_deprecation_warnings=True) 153 | available_objects: models.Manager[SoftDeletableModel] = SoftDeletableManager() 154 | all_objects = models.Manager() 155 | 156 | # Note that soft delete does not return anything, 157 | # which doesn't conform to Django's interface. 158 | # https://github.com/jazzband/django-model-utils/issues/541 159 | @overload # type: ignore[override] 160 | def delete( 161 | self, using: Any = None, *args: Any, soft: Literal[True] = True, **kwargs: Any 162 | ) -> None: 163 | ... 164 | 165 | @overload 166 | def delete( 167 | self, using: Any = None, *args: Any, soft: Literal[False], **kwargs: Any 168 | ) -> tuple[int, dict[str, int]]: 169 | ... 170 | 171 | def delete( 172 | self, using: Any = None, *args: Any, soft: bool = True, **kwargs: Any 173 | ) -> tuple[int, dict[str, int]] | None: 174 | """ 175 | Soft delete object (set its ``is_removed`` field to True). 176 | Actually delete object if setting ``soft`` to False. 177 | """ 178 | if soft: 179 | self.is_removed = True 180 | self.save(using=using) 181 | return None 182 | else: 183 | return super().delete(using, *args, **kwargs) 184 | 185 | 186 | class UUIDModel(models.Model): 187 | """ 188 | This abstract base class provides id field on any model that inherits from it 189 | which will be the primary key. 190 | """ 191 | id = UUIDField( 192 | primary_key=True, 193 | version=4, 194 | editable=False, 195 | ) 196 | 197 | class Meta: 198 | abstract = True 199 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ====== 3 | 4 | .. _StatusField: 5 | 6 | StatusField 7 | ----------- 8 | 9 | A simple convenience for giving a model a set of "states." 10 | ``StatusField`` is a ``CharField`` subclass that expects to find a 11 | class attribute called ``STATUS`` on its model or you can pass 12 | ``choices_name`` to use a different attribute name, and uses that as 13 | its ``choices``. Also sets a default ``max_length`` of 100, and sets 14 | its default value to the first item in the ``STATUS`` choices: 15 | 16 | .. code-block:: python 17 | 18 | from model_utils.fields import StatusField 19 | from model_utils import Choices 20 | 21 | class Article(models.Model): 22 | STATUS = Choices('draft', 'published') 23 | # ... 24 | status = StatusField() 25 | 26 | (The ``STATUS`` class attribute does not have to be a :ref:`Choices` 27 | instance, it can be an ordinary list of two-tuples). 28 | 29 | Using a different name for the model's choices class attribute 30 | 31 | .. code-block:: python 32 | 33 | from model_utils.fields import StatusField 34 | from model_utils import Choices 35 | 36 | class Article(models.Model): 37 | ANOTHER_CHOICES = Choices('draft', 'published') 38 | # ... 39 | another_field = StatusField(choices_name='ANOTHER_CHOICES') 40 | 41 | ``StatusField`` does not set ``db_index=True`` automatically; if you 42 | expect to frequently filter on your status field (and it will have 43 | enough selectivity to make an index worthwhile) you may want to add this 44 | yourself. 45 | 46 | 47 | .. _MonitorField: 48 | 49 | MonitorField 50 | ------------ 51 | 52 | A ``DateTimeField`` subclass that monitors another field on the model, 53 | and updates itself to the current date-time whenever the monitored 54 | field changes: 55 | 56 | .. code-block:: python 57 | 58 | from model_utils.fields import MonitorField, StatusField 59 | 60 | class Article(models.Model): 61 | STATUS = Choices('draft', 'published') 62 | 63 | status = StatusField() 64 | status_changed = MonitorField(monitor='status') 65 | 66 | (A ``MonitorField`` can monitor any type of field for changes, not only a 67 | ``StatusField``.) 68 | 69 | If a list is passed to the ``when`` parameter, the field will only 70 | update when it matches one of the specified values: 71 | 72 | .. code-block:: python 73 | 74 | from model_utils.fields import MonitorField, StatusField 75 | 76 | class Article(models.Model): 77 | STATUS = Choices('draft', 'published') 78 | 79 | status = StatusField() 80 | published_at = MonitorField(monitor='status', when=['published']) 81 | 82 | 83 | SplitField 84 | ---------- 85 | 86 | A ``TextField`` subclass that automatically pulls an excerpt out of 87 | its content (based on a "split here" marker or a default number of 88 | initial paragraphs) and stores both its content and excerpt values in 89 | the database. 90 | 91 | A ``SplitField`` is easy to add to any model definition: 92 | 93 | .. code-block:: python 94 | 95 | from django.db import models 96 | from model_utils.fields import SplitField 97 | 98 | class Article(models.Model): 99 | title = models.CharField(max_length=100) 100 | body = SplitField() 101 | 102 | ``SplitField`` automatically creates an extra non-editable field 103 | ``_body_excerpt`` to store the excerpt. This field doesn't need to be 104 | accessed directly; see below. 105 | 106 | 107 | Accessing a SplitField on a model 108 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 109 | 110 | When accessing an attribute of a model that was declared as a 111 | ``SplitField``, a ``SplitText`` object is returned. The ``SplitText`` 112 | object has three attributes: 113 | 114 | ``content``: 115 | The full field contents. 116 | ``excerpt``: 117 | The excerpt of ``content`` (read-only). 118 | ``has_more``: 119 | True if the excerpt and content are different, False otherwise. 120 | 121 | This object also has a ``__unicode__`` method that returns the full 122 | content, allowing ``SplitField`` attributes to appear in templates 123 | without having to access ``content`` directly. 124 | 125 | Assuming the ``Article`` model above: 126 | 127 | .. code-block:: pycon 128 | 129 | >>> a = Article.objects.all()[0] 130 | >>> a.body.content 131 | u'some text\n\n\n\nmore text' 132 | >>> a.body.excerpt 133 | u'some text\n' 134 | >>> unicode(a.body) 135 | u'some text\n\n\n\nmore text' 136 | 137 | Assignment to ``a.body`` is equivalent to assignment to 138 | ``a.body.content``. 139 | 140 | .. note:: 141 | 142 | a.body.excerpt is only updated when a.save() is called 143 | 144 | 145 | Customized excerpting 146 | ~~~~~~~~~~~~~~~~~~~~~ 147 | 148 | By default, ``SplitField`` looks for the marker ```` 149 | alone on a line and takes everything before that marker as the 150 | excerpt. This marker can be customized by setting the ``SPLIT_MARKER`` 151 | setting. 152 | 153 | If no marker is found in the content, the first two paragraphs (where 154 | paragraphs are blocks of text separated by a blank line) are taken to 155 | be the excerpt. This number can be customized by setting the 156 | ``SPLIT_DEFAULT_PARAGRAPHS`` setting. 157 | 158 | 159 | UUIDField 160 | ---------- 161 | 162 | A ``UUIDField`` subclass that provides an UUID field. You can 163 | add this field to any model definition. 164 | 165 | With the param ``primary_key`` you can set if this field is the 166 | primary key for the model, default is True. 167 | 168 | Param ``version`` is an integer that set default UUID version. 169 | Versions 1,3,4 and 5 are supported, default is 4. 170 | 171 | If ``editable`` is set to false the field will not be displayed in the admin 172 | or any other ModelForm, default is False. 173 | 174 | 175 | .. code-block:: python 176 | 177 | from django.db import models 178 | from model_utils.fields import UUIDField 179 | 180 | class MyAppModel(models.Model): 181 | uuid = UUIDField(primary_key=True, version=4, editable=False) 182 | 183 | 184 | UrlsafeTokenField 185 | ----------------- 186 | 187 | A ``CharField`` subclass that provides random token generating using 188 | python's ``secrets.token_urlsafe`` as default value. 189 | 190 | If ``editable`` is set to false the field will not be displayed in the admin 191 | or any other ModelForm, default is False. 192 | 193 | ``max_length`` specifies the maximum length of the token. The default value is 128. 194 | 195 | 196 | .. code-block:: python 197 | 198 | from django.db import models 199 | from model_utils.fields import UrlsafeTokenField 200 | 201 | 202 | class MyAppModel(models.Model): 203 | uuid = UrlsafeTokenField(editable=False, max_length=128) 204 | 205 | 206 | You can provide your custom token generator using the ``factory`` argument. 207 | ``factory`` should be callable. It will raise ``TypeError`` if it is not callable. 208 | ``factory`` is called with ``max_length`` argument to generate the token, and should 209 | return a string of specified maximum length. 210 | 211 | 212 | .. code-block:: python 213 | 214 | import uuid 215 | 216 | from django.db import models 217 | from model_utils.fields import UrlsafeTokenField 218 | 219 | 220 | def _token_factory(max_length): 221 | return uuid.uuid4().hex 222 | 223 | 224 | class MyAppModel(models.Model): 225 | uuid = UrlsafeTokenField(max_length=32, factory=_token_factory) 226 | -------------------------------------------------------------------------------- /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 https://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-model-utils.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-model-utils.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-model-utils" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-model-utils" 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/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.https://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-model-utils.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-model-utils.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/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # django-model-utils documentation build configuration file, created by 3 | # sphinx-quickstart on Wed Jul 31 22:27:07 2013. 4 | # 5 | # This file is execfile()d with the current directory set to its containing dir. 6 | # 7 | # Note that not all possible configuration values are present in this 8 | # autogenerated file. 9 | # 10 | # All configuration values have a default; values that are commented out 11 | # serve to show the default. 12 | 13 | import os 14 | import importlib.metadata 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = [] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = 'django-model-utils' 44 | copyright = '2015, Carl Meyer' 45 | 46 | parent_dir = os.path.dirname(os.path.dirname(__file__)) 47 | 48 | # The version info for the project you're documenting, acts as replacement for 49 | # |version| and |release|, also used in various other places throughout the 50 | # built documents. 51 | # 52 | release = importlib.metadata.version('django-model-utils') 53 | 54 | # for example take major/minor 55 | version = '.'.join(release.split('.')[:2]) 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | #language = None 60 | 61 | # There are two options for replacing |today|: either, you set today to some 62 | # non-false value, then it is used: 63 | #today = '' 64 | # Else, today_fmt is used as the format for a strftime call. 65 | #today_fmt = '%B %d, %Y' 66 | 67 | # List of patterns, relative to source directory, that match files and 68 | # directories to ignore when looking for source files. 69 | exclude_patterns = ['_build'] 70 | 71 | # The reST default role (used for this markup: `text`) to use for all documents. 72 | #default_role = None 73 | 74 | # If true, '()' will be appended to :func: etc. cross-reference text. 75 | #add_function_parentheses = True 76 | 77 | # If true, the current module name will be prepended to all description 78 | # unit titles (such as .. function::). 79 | #add_module_names = True 80 | 81 | # If true, sectionauthor and moduleauthor directives will be shown in the 82 | # output. They are ignored by default. 83 | #show_authors = False 84 | 85 | # The name of the Pygments (syntax highlighting) style to use. 86 | pygments_style = 'sphinx' 87 | 88 | # A list of ignored prefixes for module index sorting. 89 | #modindex_common_prefix = [] 90 | 91 | # If true, keep warnings as "system message" paragraphs in the built documents. 92 | #keep_warnings = False 93 | 94 | 95 | # -- Options for HTML output --------------------------------------------------- 96 | 97 | # The theme to use for HTML and HTML Help pages. See the documentation for 98 | # a list of builtin themes. 99 | html_theme = 'default' 100 | 101 | # Theme options are theme-specific and customize the look and feel of a theme 102 | # further. For a list of options available for each theme, see the 103 | # documentation. 104 | #html_theme_options = {} 105 | 106 | # Add any paths that contain custom themes here, relative to this directory. 107 | #html_theme_path = [] 108 | 109 | # The name for this set of Sphinx documents. If None, it defaults to 110 | # " v documentation". 111 | #html_title = None 112 | 113 | # A shorter title for the navigation bar. Default is the same as html_title. 114 | #html_short_title = None 115 | 116 | # The name of an image file (relative to this directory) to place at the top 117 | # of the sidebar. 118 | #html_logo = None 119 | 120 | # The name of an image file (within the static path) to use as favicon of the 121 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 122 | # pixels large. 123 | #html_favicon = None 124 | 125 | # Add any paths that contain custom static files (such as style sheets) here, 126 | # relative to this directory. They are copied after the builtin static files, 127 | # so a file named "default.css" will overwrite the builtin "default.css". 128 | html_static_path = ['_static'] 129 | 130 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 131 | # using the given strftime format. 132 | #html_last_updated_fmt = '%b %d, %Y' 133 | 134 | # If true, SmartyPants will be used to convert quotes and dashes to 135 | # typographically correct entities. 136 | #html_use_smartypants = True 137 | 138 | # Custom sidebar templates, maps document names to template names. 139 | #html_sidebars = {} 140 | 141 | # Additional templates that should be rendered to pages, maps page names to 142 | # template names. 143 | #html_additional_pages = {} 144 | 145 | # If false, no module index is generated. 146 | #html_domain_indices = True 147 | 148 | # If false, no index is generated. 149 | #html_use_index = True 150 | 151 | # If true, the index is split into individual pages for each letter. 152 | #html_split_index = False 153 | 154 | # If true, links to the reST sources are added to the pages. 155 | #html_show_sourcelink = True 156 | 157 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 158 | #html_show_sphinx = True 159 | 160 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 161 | #html_show_copyright = True 162 | 163 | # If true, an OpenSearch description file will be output, and all pages will 164 | # contain a tag referring to it. The value of this option must be the 165 | # base URL from which the finished HTML is served. 166 | #html_use_opensearch = '' 167 | 168 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 169 | #html_file_suffix = None 170 | 171 | # Output file base name for HTML help builder. 172 | htmlhelp_basename = 'django-model-utilsdoc' 173 | 174 | 175 | # -- Options for LaTeX output -------------------------------------------------- 176 | 177 | latex_elements = { 178 | # The paper size ('letterpaper' or 'a4paper'). 179 | #'papersize': 'letterpaper', 180 | 181 | # The font size ('10pt', '11pt' or '12pt'). 182 | #'pointsize': '10pt', 183 | 184 | # Additional stuff for the LaTeX preamble. 185 | #'preamble': '', 186 | } 187 | 188 | # Grouping the document tree into LaTeX files. List of tuples 189 | # (source start file, target name, title, author, documentclass [howto/manual]). 190 | latex_documents = [ 191 | ('index', 'django-model-utils.tex', 'django-model-utils Documentation', 192 | 'Carl Meyer', 'manual'), 193 | ] 194 | 195 | # The name of an image file (relative to this directory) to place at the top of 196 | # the title page. 197 | #latex_logo = None 198 | 199 | # For "manual" documents, if this is true, then toplevel headings are parts, 200 | # not chapters. 201 | #latex_use_parts = False 202 | 203 | # If true, show page references after internal links. 204 | #latex_show_pagerefs = False 205 | 206 | # If true, show URL addresses after external links. 207 | #latex_show_urls = False 208 | 209 | # Documents to append as an appendix to all manuals. 210 | #latex_appendices = [] 211 | 212 | # If false, no module index is generated. 213 | #latex_domain_indices = True 214 | 215 | 216 | # -- Options for manual page output -------------------------------------------- 217 | 218 | # One entry per manual page. List of tuples 219 | # (source start file, name, description, authors, manual section). 220 | man_pages = [ 221 | ('index', 'django-model-utils', 'django-model-utils Documentation', 222 | ['Carl Meyer'], 1) 223 | ] 224 | 225 | # If true, show URL addresses after external links. 226 | #man_show_urls = False 227 | 228 | 229 | # -- Options for Texinfo output ------------------------------------------------ 230 | 231 | # Grouping the document tree into Texinfo files. List of tuples 232 | # (source start file, target name, title, author, 233 | # dir menu entry, description, category) 234 | texinfo_documents = [ 235 | ('index', 'django-model-utils', 'django-model-utils Documentation', 236 | 'Carl Meyer', 'django-model-utils', 'One line description of project.', 237 | 'Miscellaneous'), 238 | ] 239 | 240 | # Documents to append as an appendix to all manuals. 241 | #texinfo_appendices = [] 242 | 243 | # If false, no module index is generated. 244 | #texinfo_domain_indices = True 245 | 246 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 247 | #texinfo_show_urls = 'footnote' 248 | 249 | # If true, do not generate a @detailmenu in the "Top" node's menu. 250 | #texinfo_no_detailmenu = False 251 | -------------------------------------------------------------------------------- /model_utils/choices.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import copy 4 | from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast, overload 5 | 6 | T = TypeVar("T") 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Iterable, Iterator, Sequence 10 | 11 | # The type aliases defined here are evaluated when the django-stubs mypy plugin 12 | # loads this module, so they must be able to execute under the lowest supported 13 | # Python VM: 14 | # - typing.List, typing.Tuple become obsolete in Pyton 3.9 15 | # - typing.Union becomes obsolete in Pyton 3.10 16 | from typing import List, Tuple, Union 17 | 18 | from django_stubs_ext import StrOrPromise 19 | 20 | # The type argument 'T' to 'Choices' is the database representation type. 21 | _Double = Tuple[T, StrOrPromise] 22 | _Triple = Tuple[T, str, StrOrPromise] 23 | _Group = Tuple[StrOrPromise, Sequence["_Choice[T]"]] 24 | _Choice = Union[_Double[T], _Triple[T], _Group[T]] 25 | # Choices can only be given as a single string if 'T' is 'str'. 26 | _GroupStr = Tuple[StrOrPromise, Sequence["_ChoiceStr"]] 27 | _ChoiceStr = Union[str, _Double[str], _Triple[str], _GroupStr] 28 | # Note that we only accept lists and tuples in groups, not arbitrary sequences. 29 | # However, annotating it as such causes many problems. 30 | 31 | _DoubleRead = Union[_Double[T], Tuple[StrOrPromise, Iterable["_DoubleRead[T]"]]] 32 | _DoubleCollector = List[Union[_Double[T], Tuple[StrOrPromise, "_DoubleCollector[T]"]]] 33 | _TripleCollector = List[Union[_Triple[T], Tuple[StrOrPromise, "_TripleCollector[T]"]]] 34 | 35 | 36 | class Choices(Generic[T]): 37 | """ 38 | A class to encapsulate handy functionality for lists of choices 39 | for a Django model field. 40 | 41 | Each argument to ``Choices`` is a choice, represented as either a 42 | string, a two-tuple, or a three-tuple. 43 | 44 | If a single string is provided, that string is used as the 45 | database representation of the choice as well as the 46 | human-readable presentation. 47 | 48 | If a two-tuple is provided, the first item is used as the database 49 | representation and the second the human-readable presentation. 50 | 51 | If a triple is provided, the first item is the database 52 | representation, the second a valid Python identifier that can be 53 | used as a readable label in code, and the third the human-readable 54 | presentation. This is most useful when the database representation 55 | must sacrifice readability for some reason: to achieve a specific 56 | ordering, to use an integer rather than a character field, etc. 57 | 58 | Regardless of what representation of each choice is originally 59 | given, when iterated over or indexed into, a ``Choices`` object 60 | behaves as the standard Django choices list of two-tuples. 61 | 62 | If the triple form is used, the Python identifier names can be 63 | accessed as attributes on the ``Choices`` object, returning the 64 | database representation. (If the single or two-tuple forms are 65 | used and the database representation happens to be a valid Python 66 | identifier, the database representation itself is available as an 67 | attribute on the ``Choices`` object, returning itself.) 68 | 69 | Option groups can also be used with ``Choices``; in that case each 70 | argument is a tuple consisting of the option group name and a list 71 | of options, where each option in the list is either a string, a 72 | two-tuple, or a triple as outlined above. 73 | 74 | """ 75 | 76 | @overload 77 | def __init__(self: Choices[str], *choices: _ChoiceStr): 78 | ... 79 | 80 | @overload 81 | def __init__(self, *choices: _Choice[T]): 82 | ... 83 | 84 | def __init__(self, *choices: _ChoiceStr | _Choice[T]): 85 | # list of choices expanded to triples - can include optgroups 86 | self._triples: _TripleCollector[T] = [] 87 | # list of choices as (db, human-readable) - can include optgroups 88 | self._doubles: _DoubleCollector[T] = [] 89 | # dictionary mapping db representation to human-readable 90 | self._display_map: dict[T, StrOrPromise | list[_Triple[T]]] = {} 91 | # dictionary mapping Python identifier to db representation 92 | self._identifier_map: dict[str, T] = {} 93 | # set of db representations 94 | self._db_values: set[T] = set() 95 | 96 | self._process(choices) 97 | 98 | def _store( 99 | self, 100 | triple: tuple[T, str, StrOrPromise], 101 | triple_collector: _TripleCollector[T], 102 | double_collector: _DoubleCollector[T] 103 | ) -> None: 104 | self._identifier_map[triple[1]] = triple[0] 105 | self._display_map[triple[0]] = triple[2] 106 | self._db_values.add(triple[0]) 107 | triple_collector.append(triple) 108 | double_collector.append((triple[0], triple[2])) 109 | 110 | def _process( 111 | self, 112 | choices: Iterable[_ChoiceStr | _Choice[T]], 113 | triple_collector: _TripleCollector[T] | None = None, 114 | double_collector: _DoubleCollector[T] | None = None 115 | ) -> None: 116 | if triple_collector is None: 117 | triple_collector = self._triples 118 | if double_collector is None: 119 | double_collector = self._doubles 120 | 121 | def store(c: tuple[Any, str, StrOrPromise]) -> None: 122 | self._store(c, triple_collector, double_collector) 123 | 124 | for choice in choices: 125 | # The type inference is not very accurate here: 126 | # - we lied in the type aliases, stating groups contain an arbitrary Sequence 127 | # rather than only list or tuple 128 | # - there is no way to express that _ChoiceStr is only used when T=str 129 | # - mypy 1.9.0 doesn't narrow types based on the value of len() 130 | if isinstance(choice, (list, tuple)): 131 | if len(choice) == 3: 132 | store(choice) 133 | elif len(choice) == 2: 134 | if isinstance(choice[1], (list, tuple)): 135 | # option group 136 | group_name = choice[0] 137 | subchoices = choice[1] 138 | tc: _TripleCollector[T] = [] 139 | triple_collector.append((group_name, tc)) 140 | dc: _DoubleCollector[T] = [] 141 | double_collector.append((group_name, dc)) 142 | self._process(subchoices, tc, dc) 143 | else: 144 | store((choice[0], cast(str, choice[0]), cast('StrOrPromise', choice[1]))) 145 | else: 146 | raise ValueError( 147 | "Choices can't take a list of length %s, only 2 or 3" 148 | % len(choice) 149 | ) 150 | else: 151 | store((choice, choice, choice)) 152 | 153 | def __len__(self) -> int: 154 | return len(self._doubles) 155 | 156 | def __iter__(self) -> Iterator[_DoubleRead[T]]: 157 | return iter(self._doubles) 158 | 159 | def __reversed__(self) -> Iterator[_DoubleRead[T]]: 160 | return reversed(self._doubles) 161 | 162 | def __getattr__(self, attname: str) -> T: 163 | try: 164 | return self._identifier_map[attname] 165 | except KeyError: 166 | raise AttributeError(attname) 167 | 168 | def __getitem__(self, key: T) -> StrOrPromise | Sequence[_Triple[T]]: 169 | return self._display_map[key] 170 | 171 | @overload 172 | def __add__(self: Choices[str], other: Choices[str] | Iterable[_ChoiceStr]) -> Choices[str]: 173 | ... 174 | 175 | @overload 176 | def __add__(self, other: Choices[T] | Iterable[_Choice[T]]) -> Choices[T]: 177 | ... 178 | 179 | def __add__(self, other: Choices[Any] | Iterable[_ChoiceStr | _Choice[Any]]) -> Choices[Any]: 180 | other_args: list[Any] 181 | if isinstance(other, self.__class__): 182 | other_args = other._triples 183 | else: 184 | other_args = list(other) 185 | return Choices(*(self._triples + other_args)) 186 | 187 | @overload 188 | def __radd__(self: Choices[str], other: Iterable[_ChoiceStr]) -> Choices[str]: 189 | ... 190 | 191 | @overload 192 | def __radd__(self, other: Iterable[_Choice[T]]) -> Choices[T]: 193 | ... 194 | 195 | def __radd__(self, other: Iterable[_ChoiceStr] | Iterable[_Choice[T]]) -> Choices[Any]: 196 | # radd is never called for matching types, so we don't check here 197 | other_args = list(other) 198 | # The exact type of 'other' depends on our type argument 'T', which 199 | # is expressed in the overloading, but lost within this method body. 200 | return Choices(*(other_args + self._triples)) # type: ignore[arg-type] 201 | 202 | def __eq__(self, other: object) -> bool: 203 | if isinstance(other, self.__class__): 204 | return self._triples == other._triples 205 | return False 206 | 207 | def __repr__(self) -> str: 208 | return '{}({})'.format( 209 | self.__class__.__name__, 210 | ', '.join("%s" % repr(i) for i in self._triples) 211 | ) 212 | 213 | def __contains__(self, item: T) -> bool: 214 | return item in self._db_values 215 | 216 | def __deepcopy__(self, memo: dict[int, Any] | None) -> Choices[T]: 217 | args: list[Any] = copy.deepcopy(self._triples, memo) 218 | return self.__class__(*args) 219 | 220 | def subset(self, *new_identifiers: str) -> Choices[T]: 221 | identifiers = set(self._identifier_map.keys()) 222 | 223 | if not identifiers.issuperset(new_identifiers): 224 | raise ValueError( 225 | 'The following identifiers are not present: %s' % 226 | identifiers.symmetric_difference(new_identifiers), 227 | ) 228 | 229 | args: list[Any] = [ 230 | choice for choice in self._triples 231 | if choice[1] in new_identifiers 232 | ] 233 | return self.__class__(*args) 234 | -------------------------------------------------------------------------------- /tests/test_choices.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Generic, TypeVar 4 | 5 | import pytest 6 | from django.test import TestCase 7 | 8 | from model_utils import Choices 9 | 10 | T = TypeVar("T") 11 | 12 | 13 | class ChoicesTestsMixin(Generic[T]): 14 | 15 | STATUS: Choices[T] 16 | 17 | def test_getattr(self) -> None: 18 | assert self.STATUS.DRAFT == 'DRAFT' 19 | 20 | def test_len(self) -> None: 21 | assert len(self.STATUS) == 2 22 | 23 | def test_repr(self) -> None: 24 | assert repr(self.STATUS) == "Choices" + repr(( 25 | ('DRAFT', 'DRAFT', 'DRAFT'), 26 | ('PUBLISHED', 'PUBLISHED', 'PUBLISHED'), 27 | )) 28 | 29 | def test_wrong_length_tuple(self) -> None: 30 | with pytest.raises(ValueError): 31 | Choices(('a',)) # type: ignore[arg-type] 32 | 33 | def test_deepcopy(self) -> None: 34 | import copy 35 | assert list(self.STATUS) == list(copy.deepcopy(self.STATUS)) 36 | 37 | def test_equality(self) -> None: 38 | assert self.STATUS == Choices('DRAFT', 'PUBLISHED') 39 | 40 | def test_inequality(self) -> None: 41 | assert self.STATUS != ['DRAFT', 'PUBLISHED'] 42 | assert self.STATUS != Choices('DRAFT') 43 | 44 | def test_composability(self) -> None: 45 | assert Choices('DRAFT') + Choices('PUBLISHED') == self.STATUS 46 | assert Choices('DRAFT') + ('PUBLISHED',) == self.STATUS 47 | assert ('DRAFT',) + Choices('PUBLISHED') == self.STATUS 48 | 49 | def test_option_groups(self) -> None: 50 | # Note: The implementation accepts any kind of sequence, but the type system can only 51 | # track per-index types for tuples. 52 | if TYPE_CHECKING: 53 | c = Choices(('group a', ['one', 'two']), ('group b', ('three',))) 54 | else: 55 | c = Choices(('group a', ['one', 'two']), ['group b', ('three',)]) 56 | assert list(c) == [ 57 | ('group a', [('one', 'one'), ('two', 'two')]), 58 | ('group b', [('three', 'three')]), 59 | ] 60 | 61 | 62 | class ChoicesTests(TestCase, ChoicesTestsMixin[str]): 63 | def setUp(self) -> None: 64 | self.STATUS = Choices('DRAFT', 'PUBLISHED') 65 | 66 | def test_indexing(self) -> None: 67 | self.assertEqual(self.STATUS['PUBLISHED'], 'PUBLISHED') 68 | 69 | def test_iteration(self) -> None: 70 | self.assertEqual(tuple(self.STATUS), 71 | (('DRAFT', 'DRAFT'), ('PUBLISHED', 'PUBLISHED'))) 72 | 73 | def test_reversed(self) -> None: 74 | self.assertEqual(tuple(reversed(self.STATUS)), 75 | (('PUBLISHED', 'PUBLISHED'), ('DRAFT', 'DRAFT'))) 76 | 77 | def test_contains_value(self) -> None: 78 | self.assertTrue('PUBLISHED' in self.STATUS) 79 | self.assertTrue('DRAFT' in self.STATUS) 80 | 81 | def test_doesnt_contain_value(self) -> None: 82 | self.assertFalse('UNPUBLISHED' in self.STATUS) 83 | 84 | 85 | class LabelChoicesTests(TestCase, ChoicesTestsMixin[str]): 86 | def setUp(self) -> None: 87 | self.STATUS = Choices( 88 | ('DRAFT', 'is draft'), 89 | ('PUBLISHED', 'is published'), 90 | 'DELETED', 91 | ) 92 | 93 | def test_iteration(self) -> None: 94 | self.assertEqual(tuple(self.STATUS), ( 95 | ('DRAFT', 'is draft'), 96 | ('PUBLISHED', 'is published'), 97 | ('DELETED', 'DELETED'), 98 | )) 99 | 100 | def test_reversed(self) -> None: 101 | self.assertEqual(tuple(reversed(self.STATUS)), ( 102 | ('DELETED', 'DELETED'), 103 | ('PUBLISHED', 'is published'), 104 | ('DRAFT', 'is draft'), 105 | )) 106 | 107 | def test_indexing(self) -> None: 108 | self.assertEqual(self.STATUS['PUBLISHED'], 'is published') 109 | 110 | def test_default(self) -> None: 111 | self.assertEqual(self.STATUS.DELETED, 'DELETED') 112 | 113 | def test_provided(self) -> None: 114 | self.assertEqual(self.STATUS.DRAFT, 'DRAFT') 115 | 116 | def test_len(self) -> None: 117 | self.assertEqual(len(self.STATUS), 3) 118 | 119 | def test_equality(self) -> None: 120 | self.assertEqual(self.STATUS, Choices( 121 | ('DRAFT', 'is draft'), 122 | ('PUBLISHED', 'is published'), 123 | 'DELETED', 124 | )) 125 | 126 | def test_inequality(self) -> None: 127 | self.assertNotEqual(self.STATUS, [ 128 | ('DRAFT', 'is draft'), 129 | ('PUBLISHED', 'is published'), 130 | 'DELETED' 131 | ]) 132 | self.assertNotEqual(self.STATUS, Choices('DRAFT')) 133 | 134 | def test_repr(self) -> None: 135 | self.assertEqual(repr(self.STATUS), "Choices" + repr(( 136 | ('DRAFT', 'DRAFT', 'is draft'), 137 | ('PUBLISHED', 'PUBLISHED', 'is published'), 138 | ('DELETED', 'DELETED', 'DELETED'), 139 | ))) 140 | 141 | def test_contains_value(self) -> None: 142 | self.assertTrue('PUBLISHED' in self.STATUS) 143 | self.assertTrue('DRAFT' in self.STATUS) 144 | # This should be True, because both the display value 145 | # and the internal representation are both DELETED. 146 | self.assertTrue('DELETED' in self.STATUS) 147 | 148 | def test_doesnt_contain_value(self) -> None: 149 | self.assertFalse('UNPUBLISHED' in self.STATUS) 150 | 151 | def test_doesnt_contain_display_value(self) -> None: 152 | self.assertFalse('is draft' in self.STATUS) 153 | 154 | def test_composability(self) -> None: 155 | self.assertEqual( 156 | Choices(('DRAFT', 'is draft',)) + Choices(('PUBLISHED', 'is published'), 'DELETED'), 157 | self.STATUS 158 | ) 159 | 160 | self.assertEqual( 161 | (('DRAFT', 'is draft',),) + Choices(('PUBLISHED', 'is published'), 'DELETED'), 162 | self.STATUS 163 | ) 164 | 165 | self.assertEqual( 166 | Choices(('DRAFT', 'is draft',)) + (('PUBLISHED', 'is published'), 'DELETED'), 167 | self.STATUS 168 | ) 169 | 170 | def test_option_groups(self) -> None: 171 | if TYPE_CHECKING: 172 | c = Choices[int]( 173 | ('group a', [(1, 'one'), (2, 'two')]), 174 | ('group b', ((3, 'three'),)) 175 | ) 176 | else: 177 | c = Choices( 178 | ('group a', [(1, 'one'), (2, 'two')]), 179 | ['group b', ((3, 'three'),)] 180 | ) 181 | self.assertEqual( 182 | list(c), 183 | [ 184 | ('group a', [(1, 'one'), (2, 'two')]), 185 | ('group b', [(3, 'three')]), 186 | ], 187 | ) 188 | 189 | 190 | class IdentifierChoicesTests(TestCase, ChoicesTestsMixin[int]): 191 | def setUp(self) -> None: 192 | self.STATUS = Choices( 193 | (0, 'DRAFT', 'is draft'), 194 | (1, 'PUBLISHED', 'is published'), 195 | (2, 'DELETED', 'is deleted')) 196 | 197 | def test_iteration(self) -> None: 198 | self.assertEqual(tuple(self.STATUS), ( 199 | (0, 'is draft'), 200 | (1, 'is published'), 201 | (2, 'is deleted'), 202 | )) 203 | 204 | def test_reversed(self) -> None: 205 | self.assertEqual(tuple(reversed(self.STATUS)), ( 206 | (2, 'is deleted'), 207 | (1, 'is published'), 208 | (0, 'is draft'), 209 | )) 210 | 211 | def test_indexing(self) -> None: 212 | self.assertEqual(self.STATUS[1], 'is published') 213 | 214 | def test_getattr(self) -> None: 215 | self.assertEqual(self.STATUS.DRAFT, 0) 216 | 217 | def test_len(self) -> None: 218 | self.assertEqual(len(self.STATUS), 3) 219 | 220 | def test_repr(self) -> None: 221 | self.assertEqual(repr(self.STATUS), "Choices" + repr(( 222 | (0, 'DRAFT', 'is draft'), 223 | (1, 'PUBLISHED', 'is published'), 224 | (2, 'DELETED', 'is deleted'), 225 | ))) 226 | 227 | def test_contains_value(self) -> None: 228 | self.assertTrue(0 in self.STATUS) 229 | self.assertTrue(1 in self.STATUS) 230 | self.assertTrue(2 in self.STATUS) 231 | 232 | def test_doesnt_contain_value(self) -> None: 233 | self.assertFalse(3 in self.STATUS) 234 | 235 | def test_doesnt_contain_display_value(self) -> None: 236 | self.assertFalse('is draft' in self.STATUS) # type: ignore[operator] 237 | 238 | def test_doesnt_contain_python_attr(self) -> None: 239 | self.assertFalse('PUBLISHED' in self.STATUS) # type: ignore[operator] 240 | 241 | def test_equality(self) -> None: 242 | self.assertEqual(self.STATUS, Choices( 243 | (0, 'DRAFT', 'is draft'), 244 | (1, 'PUBLISHED', 'is published'), 245 | (2, 'DELETED', 'is deleted') 246 | )) 247 | 248 | def test_inequality(self) -> None: 249 | self.assertNotEqual(self.STATUS, [ 250 | (0, 'DRAFT', 'is draft'), 251 | (1, 'PUBLISHED', 'is published'), 252 | (2, 'DELETED', 'is deleted') 253 | ]) 254 | self.assertNotEqual(self.STATUS, Choices('DRAFT')) 255 | 256 | def test_composability(self) -> None: 257 | self.assertEqual( 258 | Choices( 259 | (0, 'DRAFT', 'is draft'), 260 | (1, 'PUBLISHED', 'is published') 261 | ) + Choices( 262 | (2, 'DELETED', 'is deleted'), 263 | ), 264 | self.STATUS 265 | ) 266 | 267 | self.assertEqual( 268 | Choices( 269 | (0, 'DRAFT', 'is draft'), 270 | (1, 'PUBLISHED', 'is published') 271 | ) + ( 272 | (2, 'DELETED', 'is deleted'), 273 | ), 274 | self.STATUS 275 | ) 276 | 277 | self.assertEqual( 278 | ( 279 | (0, 'DRAFT', 'is draft'), 280 | (1, 'PUBLISHED', 'is published') 281 | ) + Choices( 282 | (2, 'DELETED', 'is deleted'), 283 | ), 284 | self.STATUS 285 | ) 286 | 287 | def test_option_groups(self) -> None: 288 | if TYPE_CHECKING: 289 | c = Choices[int]( 290 | ('group a', [(1, 'ONE', 'one'), (2, 'TWO', 'two')]), 291 | ('group b', ((3, 'THREE', 'three'),)) 292 | ) 293 | else: 294 | c = Choices( 295 | ('group a', [(1, 'ONE', 'one'), (2, 'TWO', 'two')]), 296 | ['group b', ((3, 'THREE', 'three'),)] 297 | ) 298 | self.assertEqual( 299 | list(c), 300 | [ 301 | ('group a', [(1, 'one'), (2, 'two')]), 302 | ('group b', [(3, 'three')]), 303 | ], 304 | ) 305 | 306 | 307 | class SubsetChoicesTest(TestCase): 308 | 309 | def setUp(self) -> None: 310 | self.choices = Choices[int]( 311 | (0, 'a', 'A'), 312 | (1, 'b', 'B'), 313 | ) 314 | 315 | def test_nonexistent_identifiers_raise(self) -> None: 316 | with self.assertRaises(ValueError): 317 | self.choices.subset('a', 'c') 318 | 319 | def test_solo_nonexistent_identifiers_raise(self) -> None: 320 | with self.assertRaises(ValueError): 321 | self.choices.subset('c') 322 | 323 | def test_empty_subset_passes(self) -> None: 324 | subset = self.choices.subset() 325 | 326 | self.assertEqual(subset, Choices()) 327 | 328 | def test_subset_returns_correct_subset(self) -> None: 329 | subset = self.choices.subset('a') 330 | 331 | self.assertEqual(subset, Choices((0, 'a', 'A'))) 332 | -------------------------------------------------------------------------------- /model_utils/fields.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import secrets 4 | import uuid 5 | from collections.abc import Sequence 6 | from typing import TYPE_CHECKING, Any, Union 7 | 8 | from django.conf import settings 9 | from django.core.exceptions import ValidationError 10 | from django.db import models 11 | from django.utils.timezone import now 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Callable, Iterable 15 | from datetime import date, datetime 16 | 17 | DateTimeFieldBase = models.DateTimeField[Union[str, datetime, date], datetime] 18 | else: 19 | DateTimeFieldBase = models.DateTimeField 20 | 21 | DEFAULT_CHOICES_NAME = 'STATUS' 22 | 23 | 24 | class AutoCreatedField(DateTimeFieldBase): 25 | """ 26 | A DateTimeField that automatically populates itself at 27 | object creation. 28 | 29 | By default, sets editable=False, default=datetime.now. 30 | 31 | """ 32 | 33 | def __init__(self, *args: Any, **kwargs: Any): 34 | kwargs.setdefault('editable', False) 35 | kwargs.setdefault('default', now) 36 | super().__init__(*args, **kwargs) 37 | 38 | 39 | class AutoLastModifiedField(AutoCreatedField): 40 | """ 41 | A DateTimeField that updates itself on each save() of the model. 42 | 43 | By default, sets editable=False and default=datetime.now. 44 | 45 | """ 46 | def get_default(self) -> datetime: 47 | """Return the default value for this field.""" 48 | if not hasattr(self, "_default"): 49 | self._default = super().get_default() 50 | return self._default 51 | 52 | def pre_save(self, model_instance: models.Model, add: bool) -> datetime: 53 | value = now() 54 | if add: 55 | current_value = getattr(model_instance, self.attname, self.get_default()) 56 | if current_value != self.get_default(): 57 | # when creating an instance and the modified date is set 58 | # don't change the value, assume the developer wants that 59 | # control. 60 | value = getattr(model_instance, self.attname) 61 | else: 62 | for field in model_instance._meta.get_fields(): 63 | if isinstance(field, AutoCreatedField): 64 | value = getattr(model_instance, field.name) 65 | break 66 | setattr(model_instance, self.attname, value) 67 | return value 68 | 69 | 70 | class StatusField(models.CharField): 71 | """ 72 | A CharField that looks for a ``STATUS`` class-attribute and 73 | automatically uses that as ``choices``. The first option in 74 | ``STATUS`` is set as the default. 75 | 76 | Also has a default max_length so you don't have to worry about 77 | setting that. 78 | 79 | Also features a ``no_check_for_status`` argument to make sure 80 | South can handle this field when it freezes a model. 81 | """ 82 | 83 | def __init__( 84 | self, 85 | *args: Any, 86 | no_check_for_status: bool = False, 87 | choices_name: str = DEFAULT_CHOICES_NAME, 88 | **kwargs: Any 89 | ): 90 | kwargs.setdefault('max_length', 100) 91 | self.check_for_status = not no_check_for_status 92 | self.choices_name = choices_name 93 | super().__init__(*args, **kwargs) 94 | 95 | def prepare_class(self, sender: type[models.Model], **kwargs: Any) -> None: 96 | if not sender._meta.abstract and self.check_for_status: 97 | assert hasattr(sender, self.choices_name), \ 98 | "To use StatusField, the model '%s' must have a %s choices class attribute." \ 99 | % (sender.__name__, self.choices_name) 100 | self.choices = getattr(sender, self.choices_name) 101 | if not self.has_default(): 102 | self.default = tuple(getattr(sender, self.choices_name))[0][0] # set first as default 103 | 104 | def contribute_to_class(self, cls: type[models.Model], name: str, *args: Any, **kwargs: Any) -> None: 105 | models.signals.class_prepared.connect(self.prepare_class, sender=cls) 106 | # we don't set the real choices until class_prepared (so we can rely on 107 | # the STATUS class attr being available), but we need to set some dummy 108 | # choices now so the super method will add the get_FOO_display method 109 | self.choices = [(0, 'dummy')] 110 | super().contribute_to_class(cls, name, *args, **kwargs) 111 | 112 | def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]: 113 | name, path, args, kwargs = super().deconstruct() 114 | kwargs['no_check_for_status'] = True 115 | return name, path, args, kwargs 116 | 117 | 118 | class MonitorField(DateTimeFieldBase): 119 | """ 120 | A DateTimeField that monitors another field on the same model and 121 | sets itself to the current date/time whenever the monitored field 122 | changes. 123 | 124 | """ 125 | 126 | def __init__(self, *args: Any, monitor: str, when: Iterable[Any] | None = None, **kwargs: Any): 127 | default = None if kwargs.get("null") else now 128 | kwargs.setdefault('default', default) 129 | self.monitor = monitor 130 | self.when = None if when is None else set(when) 131 | super().__init__(*args, **kwargs) 132 | 133 | def contribute_to_class(self, cls: type[models.Model], name: str, *args: Any, **kwargs: Any) -> None: 134 | self.monitor_attname = '_monitor_%s' % name 135 | models.signals.post_init.connect(self._save_initial, sender=cls) 136 | super().contribute_to_class(cls, name, *args, **kwargs) 137 | 138 | def get_monitored_value(self, instance: models.Model) -> Any: 139 | return getattr(instance, self.monitor) 140 | 141 | def _save_initial(self, sender: type[models.Model], instance: models.Model, **kwargs: Any) -> None: 142 | if self.monitor in instance.get_deferred_fields(): 143 | # Fix related to issue #241 to avoid recursive error on double monitor fields 144 | return 145 | setattr(instance, self.monitor_attname, self.get_monitored_value(instance)) 146 | 147 | def pre_save(self, model_instance: models.Model, add: bool) -> Any: 148 | value = now() 149 | previous = getattr(model_instance, self.monitor_attname, None) 150 | current = self.get_monitored_value(model_instance) 151 | if previous != current: 152 | if self.when is None or current in self.when: 153 | setattr(model_instance, self.attname, value) 154 | self._save_initial(model_instance.__class__, model_instance) 155 | return super().pre_save(model_instance, add) 156 | 157 | def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]: 158 | name, path, args, kwargs = super().deconstruct() 159 | kwargs['monitor'] = self.monitor 160 | if self.when is not None: 161 | kwargs['when'] = self.when 162 | return name, path, args, kwargs 163 | 164 | 165 | SPLIT_MARKER = getattr(settings, 'SPLIT_MARKER', '') 166 | 167 | # the number of paragraphs after which to split if no marker 168 | SPLIT_DEFAULT_PARAGRAPHS = getattr(settings, 'SPLIT_DEFAULT_PARAGRAPHS', 2) 169 | 170 | 171 | def _excerpt_field_name(name: str) -> str: 172 | return '_%s_excerpt' % name 173 | 174 | 175 | def get_excerpt(content: str) -> str: 176 | excerpt: list[str] = [] 177 | default_excerpt = [] 178 | paras_seen = 0 179 | for line in content.splitlines(): 180 | if not line.strip(): 181 | paras_seen += 1 182 | if paras_seen < SPLIT_DEFAULT_PARAGRAPHS: 183 | default_excerpt.append(line) 184 | if line.strip() == SPLIT_MARKER: 185 | return '\n'.join(excerpt) 186 | excerpt.append(line) 187 | 188 | return '\n'.join(default_excerpt) 189 | 190 | 191 | class SplitText: 192 | def __init__(self, instance: models.Model, field_name: str, excerpt_field_name: str): 193 | # instead of storing actual values store a reference to the instance 194 | # along with field names, this makes assignment possible 195 | self.instance = instance 196 | self.field_name = field_name 197 | self.excerpt_field_name = excerpt_field_name 198 | 199 | @property 200 | def content(self) -> str: 201 | return self.instance.__dict__[self.field_name] 202 | 203 | @content.setter 204 | def content(self, val: str) -> None: 205 | setattr(self.instance, self.field_name, val) 206 | 207 | @property 208 | def excerpt(self) -> str: 209 | return getattr(self.instance, self.excerpt_field_name) 210 | 211 | @property 212 | def has_more(self) -> bool: 213 | return self.excerpt.strip() != self.content.strip() 214 | 215 | def __str__(self) -> str: 216 | return self.content 217 | 218 | 219 | class SplitDescriptor: 220 | def __init__(self, field: SplitField): 221 | self.field = field 222 | self.excerpt_field_name = _excerpt_field_name(self.field.name) 223 | 224 | def __get__(self, instance: models.Model, owner: type[models.Model]) -> SplitText: 225 | if instance is None: 226 | raise AttributeError('Can only be accessed via an instance.') 227 | return SplitText(instance, self.field.name, self.excerpt_field_name) 228 | 229 | def __set__(self, obj: models.Model, value: SplitText | str) -> None: 230 | if isinstance(value, SplitText): 231 | obj.__dict__[self.field.name] = value.content 232 | setattr(obj, self.excerpt_field_name, value.excerpt) 233 | else: 234 | obj.__dict__[self.field.name] = value 235 | 236 | 237 | if TYPE_CHECKING: 238 | _SplitFieldBase = models.TextField[Union[SplitText, str], SplitText] 239 | else: 240 | _SplitFieldBase = models.TextField 241 | 242 | 243 | class SplitField(_SplitFieldBase): 244 | 245 | def contribute_to_class(self, cls: type[models.Model], name: str, *args: Any, **kwargs: Any) -> None: 246 | if not cls._meta.abstract: 247 | excerpt_field: models.TextField = models.TextField(editable=False) 248 | cls.add_to_class(_excerpt_field_name(name), excerpt_field) 249 | super().contribute_to_class(cls, name, *args, **kwargs) 250 | setattr(cls, self.name, SplitDescriptor(self)) 251 | 252 | def pre_save(self, model_instance: models.Model, add: bool) -> str: 253 | value: SplitText = super().pre_save(model_instance, add) 254 | excerpt = get_excerpt(value.content) 255 | setattr(model_instance, _excerpt_field_name(self.attname), excerpt) 256 | return value.content 257 | 258 | def value_to_string(self, obj: models.Model) -> str: 259 | value = self.value_from_object(obj) 260 | return value.content 261 | 262 | def get_prep_value(self, value: Any) -> str: 263 | try: 264 | return value.content 265 | except AttributeError: 266 | return value 267 | 268 | 269 | class UUIDField(models.UUIDField): 270 | """ 271 | A field for storing universally unique identifiers. Use Python UUID class. 272 | """ 273 | 274 | def __init__( 275 | self, 276 | primary_key: bool = True, 277 | version: int = 4, 278 | editable: bool = False, 279 | *args: Any, 280 | **kwargs: Any 281 | ): 282 | """ 283 | Parameters 284 | ---------- 285 | primary_key : bool 286 | If True, this field is the primary key for the model. 287 | version : int 288 | An integer that set default UUID version. 289 | editable : bool 290 | If False, the field will not be displayed in the admin or any other ModelForm, 291 | default is false. 292 | 293 | Raises 294 | ------ 295 | ValidationError 296 | UUID version 2 is not supported. 297 | """ 298 | 299 | if version == 2: 300 | raise ValidationError( 301 | 'UUID version 2 is not supported.') 302 | 303 | if version < 1 or version > 5: 304 | raise ValidationError( 305 | 'UUID version is not valid.') 306 | 307 | default: Callable[..., uuid.UUID] 308 | if version == 1: 309 | default = uuid.uuid1 310 | elif version == 3: 311 | default = uuid.uuid3 312 | elif version == 4: 313 | default = uuid.uuid4 314 | elif version == 5: 315 | default = uuid.uuid5 316 | 317 | kwargs.setdefault('primary_key', primary_key) 318 | kwargs.setdefault('editable', editable) 319 | kwargs.setdefault('default', default) 320 | super().__init__(*args, **kwargs) 321 | 322 | 323 | class UrlsafeTokenField(models.CharField): 324 | """ 325 | A field for storing a unique token in database. 326 | """ 327 | 328 | max_length: int 329 | 330 | def __init__( 331 | self, 332 | editable: bool = False, 333 | max_length: int = 128, 334 | factory: Callable[[int], str] | None = None, 335 | **kwargs: Any 336 | ): 337 | """ 338 | Parameters 339 | ---------- 340 | editable: bool 341 | If true token is editable. 342 | max_length: int 343 | Maximum length of the token. 344 | factory: callable 345 | If provided, called with max_length of the field instance to generate token. 346 | 347 | Raises 348 | ------ 349 | TypeError 350 | non-callable value for factory is not supported. 351 | """ 352 | 353 | if factory is not None and not callable(factory): 354 | raise TypeError("'factory' should either be a callable or 'None'") 355 | self._factory = factory 356 | 357 | kwargs.pop('default', None) # passing default value has not effect. 358 | 359 | super().__init__(editable=editable, max_length=max_length, **kwargs) 360 | 361 | def get_default(self) -> str: 362 | if self._factory is not None: 363 | return self._factory(self.max_length) 364 | # generate a token of length x1.33 approx. trim up to max length 365 | token = secrets.token_urlsafe(self.max_length)[:self.max_length] 366 | return token 367 | 368 | def deconstruct(self) -> tuple[str, str, Sequence[Any], dict[str, Any]]: 369 | name, path, args, kwargs = super().deconstruct() 370 | kwargs['factory'] = self._factory 371 | return name, path, args, kwargs 372 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, ClassVar, Iterable, TypeVar, overload 4 | 5 | from django.db import models 6 | from django.db.models import Manager 7 | from django.db.models.query import QuerySet 8 | from django.db.models.query_utils import DeferredAttribute 9 | from django.utils.translation import gettext_lazy as _ 10 | 11 | from model_utils import Choices 12 | from model_utils.fields import MonitorField, SplitField, StatusField, UUIDField 13 | from model_utils.managers import ( 14 | InheritanceManager, 15 | JoinQueryset, 16 | QueryManager, 17 | SoftDeletableManager, 18 | SoftDeletableQuerySet, 19 | ) 20 | from model_utils.models import ( 21 | SoftDeletableModel, 22 | StatusModel, 23 | TimeFramedModel, 24 | TimeStampedModel, 25 | UUIDModel, 26 | ) 27 | from model_utils.tracker import FieldInstanceTracker, FieldTracker, ModelTracker 28 | from tests.fields import MutableField 29 | 30 | ModelT = TypeVar('ModelT', bound=models.Model, covariant=True) 31 | 32 | 33 | class InheritanceManagerTestRelated(models.Model): 34 | pass 35 | 36 | 37 | class InheritanceManagerTestParent(models.Model): 38 | # FileField is just a handy descriptor-using field. Refs #6. 39 | non_related_field_using_descriptor = models.FileField(upload_to="test") 40 | related = models.ForeignKey( 41 | InheritanceManagerTestRelated, related_name="imtests", null=True, 42 | on_delete=models.CASCADE) 43 | normal_field = models.TextField() 44 | related_self = models.OneToOneField( 45 | "self", related_name="imtests_self", null=True, 46 | on_delete=models.CASCADE) 47 | objects: ClassVar[InheritanceManager[InheritanceManagerTestParent]] = InheritanceManager() 48 | 49 | def __str__(self) -> str: 50 | return "{}({})".format( 51 | self.__class__.__name__[len('InheritanceManagerTest'):], 52 | self.pk, 53 | ) 54 | 55 | 56 | class InheritanceManagerTestChild1(InheritanceManagerTestParent): 57 | non_related_field_using_descriptor_2 = models.FileField(upload_to="test") 58 | normal_field_2 = models.TextField() 59 | objects: ClassVar[InheritanceManager[InheritanceManagerTestParent]] = InheritanceManager() 60 | 61 | 62 | class InheritanceManagerTestGrandChild1(InheritanceManagerTestChild1): 63 | text_field = models.TextField() 64 | 65 | 66 | class InheritanceManagerTestGrandChild1_2(InheritanceManagerTestChild1): 67 | text_field = models.TextField() 68 | 69 | 70 | class InheritanceManagerTestChild2(InheritanceManagerTestParent): 71 | non_related_field_using_descriptor_2 = models.FileField(upload_to="test") 72 | normal_field_2 = models.TextField() 73 | 74 | 75 | class InheritanceManagerTestChild3(InheritanceManagerTestParent): 76 | parent_ptr = models.OneToOneField( 77 | InheritanceManagerTestParent, related_name='manual_onetoone', 78 | parent_link=True, on_delete=models.CASCADE) 79 | 80 | 81 | class InheritanceManagerTestChild3_1(InheritanceManagerTestParent): 82 | parent_ptr = models.OneToOneField( 83 | InheritanceManagerTestParent, db_column="custom_parent_ptr", 84 | parent_link=True, on_delete=models.CASCADE) 85 | 86 | 87 | class InheritanceManagerTestChild4(InheritanceManagerTestParent): 88 | other_onetoone = models.OneToOneField( 89 | InheritanceManagerTestParent, related_name='non_inheritance_relation', 90 | parent_link=False, on_delete=models.CASCADE) 91 | # The following is needed because of that Django bug: 92 | # https://code.djangoproject.com/ticket/29998 93 | parent_ptr = models.OneToOneField( 94 | InheritanceManagerTestParent, related_name='child4_onetoone', 95 | parent_link=True, on_delete=models.CASCADE) 96 | 97 | 98 | class TimeStamp(TimeStampedModel): 99 | test_field = models.PositiveSmallIntegerField(default=0) 100 | 101 | 102 | class TimeFrame(TimeFramedModel): 103 | pass 104 | 105 | 106 | class TimeFrameManagerAdded(TimeFramedModel): 107 | pass 108 | 109 | 110 | class Monitored(models.Model): 111 | name = models.CharField(max_length=25) 112 | name_changed = MonitorField(monitor="name") 113 | name_changed_nullable = MonitorField(monitor="name", null=True) 114 | 115 | 116 | class MonitorWhen(models.Model): 117 | name = models.CharField(max_length=25) 118 | name_changed = MonitorField(monitor="name", when=["Jose", "Maria"]) 119 | 120 | 121 | class MonitorWhenEmpty(models.Model): 122 | name = models.CharField(max_length=25) 123 | name_changed = MonitorField(monitor="name", when=[]) 124 | 125 | 126 | class DoubleMonitored(models.Model): 127 | name = models.CharField(max_length=25) 128 | name_changed = MonitorField(monitor="name") 129 | name2 = models.CharField(max_length=25) 130 | name_changed2 = MonitorField(monitor="name2") 131 | 132 | 133 | class Status(StatusModel): 134 | STATUS: Choices[str] = Choices( 135 | ("active", _("active")), 136 | ("deleted", _("deleted")), 137 | ("on_hold", _("on hold")), 138 | ) 139 | 140 | 141 | class StatusPlainTuple(StatusModel): 142 | STATUS = ( 143 | ("active", _("active")), 144 | ("deleted", _("deleted")), 145 | ("on_hold", _("on hold")), 146 | ) 147 | 148 | 149 | class StatusManagerAdded(StatusModel): 150 | STATUS = ( 151 | ("active", _("active")), 152 | ("deleted", _("deleted")), 153 | ("on_hold", _("on hold")), 154 | ) 155 | 156 | 157 | class StatusCustomManager(Manager): 158 | pass 159 | 160 | 161 | class AbstractCustomManagerStatusModel(StatusModel): 162 | """An abstract status model with a custom manager.""" 163 | 164 | STATUS = Choices( 165 | ("first_choice", _("First choice")), 166 | ("second_choice", _("Second choice")), 167 | ) 168 | 169 | objects = StatusCustomManager() 170 | 171 | class Meta: 172 | abstract = True 173 | 174 | 175 | class CustomManagerStatusModel(AbstractCustomManagerStatusModel): 176 | """A concrete status model with a custom manager.""" 177 | 178 | title = models.CharField(max_length=50) 179 | 180 | 181 | class Post(models.Model): 182 | published = models.BooleanField(default=False) 183 | confirmed = models.BooleanField(default=False) 184 | order = models.IntegerField() 185 | 186 | objects = models.Manager() 187 | public: ClassVar[QueryManager[Post]] = QueryManager(published=True) 188 | public_confirmed: ClassVar[QueryManager[Post]] = QueryManager( 189 | models.Q(published=True) & models.Q(confirmed=True)) 190 | public_reversed: ClassVar[QueryManager[Post]] = QueryManager( 191 | published=True).order_by("-order") 192 | 193 | class Meta: 194 | ordering = ("order",) 195 | 196 | 197 | class Article(models.Model): 198 | title = models.CharField(max_length=50) 199 | body = SplitField() 200 | 201 | 202 | class SplitFieldAbstractParent(models.Model): 203 | content = SplitField() 204 | 205 | class Meta: 206 | abstract = True 207 | 208 | 209 | class AbstractTracked(models.Model): 210 | 211 | class Meta: 212 | abstract = True 213 | 214 | 215 | class Tracked(models.Model): 216 | name = models.CharField(max_length=20) 217 | number = models.IntegerField() 218 | mutable = MutableField(default=None) 219 | 220 | tracker = FieldTracker() 221 | 222 | def save(self, *args: Any, **kwargs: Any) -> None: 223 | """ No-op save() to ensure that FieldTracker.patch_save() works. """ 224 | super().save(*args, **kwargs) 225 | 226 | 227 | class TrackerTimeStamped(TimeStampedModel): 228 | name = models.CharField(max_length=20) 229 | number = models.IntegerField() 230 | mutable = MutableField(default=None) 231 | 232 | tracker = FieldTracker() 233 | 234 | def save(self, *args: Any, **kwargs: Any) -> None: 235 | """ Automatically add "modified" to update_fields.""" 236 | update_fields = kwargs.get('update_fields') 237 | if update_fields is not None: 238 | kwargs['update_fields'] = set(update_fields) | {'modified'} 239 | super().save(*args, **kwargs) 240 | 241 | 242 | class TrackedFK(models.Model): 243 | fk = models.ForeignKey('Tracked', on_delete=models.CASCADE) 244 | 245 | tracker = FieldTracker() 246 | custom_tracker = FieldTracker(fields=['fk_id']) 247 | custom_tracker_without_id = FieldTracker(fields=['fk']) 248 | 249 | 250 | class TrackedAbstract(AbstractTracked): 251 | name = models.CharField(max_length=20) 252 | number = models.IntegerField() 253 | mutable = MutableField(default=None) 254 | 255 | tracker = ModelTracker() 256 | 257 | 258 | class TrackedNotDefault(models.Model): 259 | name = models.CharField(max_length=20) 260 | number = models.IntegerField() 261 | 262 | name_tracker = FieldTracker(fields=['name']) 263 | 264 | 265 | class TrackedNonFieldAttr(models.Model): 266 | number = models.FloatField() 267 | 268 | @property 269 | def rounded(self) -> int | None: 270 | return round(self.number) if self.number is not None else None 271 | 272 | tracker = FieldTracker(fields=['rounded']) 273 | 274 | 275 | class TrackedMultiple(models.Model): 276 | name = models.CharField(max_length=20) 277 | number = models.IntegerField() 278 | 279 | name_tracker = FieldTracker(fields=['name']) 280 | number_tracker = FieldTracker(fields=['number']) 281 | 282 | 283 | class LoopDetectionFieldInstanceTracker(FieldInstanceTracker): 284 | 285 | def set_saved_fields(self, fields: Iterable[str] | None = None) -> None: 286 | counter = getattr(self.__class__, '__loop_counter', 0) 287 | if counter > 50: 288 | raise AssertionError("Infinite Loop Detected!") 289 | setattr(self.__class__, '__loop_counter', counter + 1) 290 | super().set_saved_fields(fields) 291 | 292 | 293 | class LoopDetectionFieldTracker(FieldTracker): 294 | tracker_class = LoopDetectionFieldInstanceTracker 295 | 296 | 297 | class TrackedProtectedSelfRefFK(models.Model): 298 | fk = models.ForeignKey('Tracked', on_delete=models.PROTECT) 299 | self_ref = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True) 300 | 301 | tracker = LoopDetectionFieldTracker() 302 | custom_tracker = LoopDetectionFieldTracker(fields=['fk_id', 'self_ref_id']) 303 | custom_tracker_without_id = LoopDetectionFieldTracker(fields=['fk', 'self_ref']) 304 | 305 | 306 | class TrackedFileField(models.Model): 307 | some_file = models.FileField(upload_to='test_location') 308 | 309 | tracker = FieldTracker() 310 | 311 | 312 | class InheritedTracked(Tracked): 313 | name2 = models.CharField(max_length=20) 314 | 315 | 316 | class InheritedTrackedFK(TrackedFK): 317 | custom_tracker = FieldTracker(fields=['fk_id']) 318 | custom_tracker_without_id = FieldTracker(fields=['fk']) 319 | 320 | 321 | class ModelTracked(models.Model): 322 | name = models.CharField(max_length=20) 323 | number = models.IntegerField() 324 | mutable = MutableField(default=None) 325 | 326 | tracker = ModelTracker() 327 | 328 | 329 | class ModelTrackedFK(models.Model): 330 | fk = models.ForeignKey('ModelTracked', on_delete=models.CASCADE) 331 | 332 | tracker = ModelTracker() 333 | custom_tracker = ModelTracker(fields=['fk_id']) 334 | custom_tracker_without_id = ModelTracker(fields=['fk']) 335 | 336 | 337 | class ModelTrackedNotDefault(models.Model): 338 | name = models.CharField(max_length=20) 339 | number = models.IntegerField() 340 | 341 | name_tracker = ModelTracker(fields=['name']) 342 | 343 | 344 | class ModelTrackedMultiple(models.Model): 345 | name = models.CharField(max_length=20) 346 | number = models.IntegerField() 347 | 348 | name_tracker = ModelTracker(fields=['name']) 349 | number_tracker = ModelTracker(fields=['number']) 350 | 351 | 352 | class InheritedModelTracked(ModelTracked): 353 | name2 = models.CharField(max_length=20) 354 | 355 | 356 | class StatusFieldDefaultFilled(models.Model): 357 | STATUS = Choices((0, "no", "No"), (1, "yes", "Yes")) 358 | status = StatusField(default=STATUS.yes) 359 | 360 | 361 | class StatusFieldDefaultNotFilled(models.Model): 362 | STATUS = Choices((0, "no", "No"), (1, "yes", "Yes")) 363 | status = StatusField() 364 | 365 | 366 | class StatusFieldChoicesName(models.Model): 367 | NAMED_STATUS = Choices((0, "no", "No"), (1, "yes", "Yes")) 368 | status = StatusField(choices_name='NAMED_STATUS') 369 | 370 | 371 | class SoftDeletable(SoftDeletableModel): 372 | """ 373 | Test model with additional manager for full access to model 374 | instances. 375 | """ 376 | name = models.CharField(max_length=20) 377 | 378 | all_objects: ClassVar[Manager[SoftDeletable]] = models.Manager() 379 | 380 | 381 | class CustomSoftDeleteQuerySet(SoftDeletableQuerySet[ModelT]): 382 | def only_read(self) -> QuerySet[ModelT]: 383 | return self.filter(is_read=True) 384 | 385 | 386 | class CustomSoftDelete(SoftDeletableModel): 387 | is_read = models.BooleanField(default=False) 388 | 389 | available_objects = SoftDeletableManager.from_queryset(CustomSoftDeleteQuerySet)() 390 | 391 | 392 | class StringyDescriptor: 393 | """ 394 | Descriptor that returns a string version of the underlying integer value. 395 | """ 396 | def __init__(self, name: str): 397 | self.name = name 398 | 399 | @overload 400 | def __get__(self, obj: None, cls: type[models.Model] | None = None) -> StringyDescriptor: 401 | ... 402 | 403 | @overload 404 | def __get__(self, obj: models.Model, cls: type[models.Model]) -> str: 405 | ... 406 | 407 | def __get__(self, obj: models.Model | None, cls: type[models.Model] | None = None) -> StringyDescriptor | str: 408 | if obj is None: 409 | return self 410 | if self.name in obj.get_deferred_fields(): 411 | # This queries the database, and sets the value on the instance. 412 | assert cls is not None 413 | fields_map = {f.name: f for f in cls._meta.fields} 414 | field = fields_map[self.name] 415 | DeferredAttribute(field=field).__get__(obj, cls) 416 | return str(obj.__dict__[self.name]) 417 | 418 | def __set__(self, obj: object, value: str) -> None: 419 | obj.__dict__[self.name] = int(value) 420 | 421 | def __delete__(self, obj: object) -> None: 422 | del obj.__dict__[self.name] 423 | 424 | 425 | class CustomDescriptorField(models.IntegerField): 426 | def contribute_to_class(self, cls: type[models.Model], name: str, *args: Any, **kwargs: Any) -> None: 427 | super().contribute_to_class(cls, name, *args, **kwargs) 428 | setattr(cls, name, StringyDescriptor(name)) 429 | 430 | 431 | class ModelWithCustomDescriptor(models.Model): 432 | custom_field = CustomDescriptorField() 433 | tracked_custom_field = CustomDescriptorField() 434 | regular_field = models.IntegerField() 435 | tracked_regular_field = models.IntegerField() 436 | 437 | tracker = FieldTracker(fields=['tracked_custom_field', 'tracked_regular_field']) 438 | 439 | 440 | class BoxJoinModel(models.Model): 441 | name = models.CharField(max_length=32) 442 | objects = JoinQueryset.as_manager() 443 | 444 | 445 | class JoinItemForeignKey(models.Model): 446 | weight = models.IntegerField() 447 | belonging = models.ForeignKey( 448 | BoxJoinModel, 449 | null=True, 450 | on_delete=models.CASCADE 451 | ) 452 | objects = JoinQueryset.as_manager() 453 | 454 | 455 | class CustomUUIDModel(UUIDModel): 456 | pass 457 | 458 | 459 | class CustomNotPrimaryUUIDModel(models.Model): 460 | uuid = UUIDField(primary_key=False) 461 | 462 | 463 | class TimeStampWithStatusModel(TimeStampedModel, StatusModel): 464 | STATUS = Choices( 465 | ("active", _("active")), 466 | ("deleted", _("deleted")), 467 | ("on_hold", _("on hold")), 468 | ) 469 | 470 | test_field = models.PositiveSmallIntegerField(default=0) 471 | -------------------------------------------------------------------------------- /docs/utilities.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | Miscellaneous Utilities 3 | ======================= 4 | 5 | .. _Choices: 6 | 7 | ``Choices`` 8 | =========== 9 | 10 | .. note:: 11 | 12 | Django 3.0 adds `enumeration types `__. 13 | These provide most of the same features as ``Choices``. 14 | 15 | ``Choices`` provides some conveniences for setting ``choices`` on a Django model field: 16 | 17 | .. code-block:: python 18 | 19 | from model_utils import Choices 20 | 21 | class Article(models.Model): 22 | STATUS = Choices('draft', 'published') 23 | status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20) 24 | 25 | A ``Choices`` object is initialized with any number of choices. In the 26 | simplest case, each choice is a string; that string will be used both 27 | as the database representation of the choice, and the human-readable 28 | representation. Note that you can access options as attributes on the 29 | ``Choices`` object: ``STATUS.draft``. 30 | 31 | But you may want your human-readable versions translated, in which 32 | case you need to separate the human-readable version from the DB 33 | representation. In this case you can provide choices as two-tuples: 34 | 35 | .. code-block:: python 36 | 37 | from model_utils import Choices 38 | 39 | class Article(models.Model): 40 | STATUS = Choices(('draft', _('draft')), ('published', _('published'))) 41 | status = models.CharField(choices=STATUS, default=STATUS.draft, max_length=20) 42 | 43 | But what if your database representation of choices is constrained in 44 | a way that would hinder readability of your code? For instance, you 45 | may need to use an ``IntegerField`` rather than a ``CharField``, or 46 | you may want the database to order the values in your field in some 47 | specific way. In this case, you can provide your choices as triples, 48 | where the first element is the database representation, the second is 49 | a valid Python identifier you will use in your code as a constant, and 50 | the third is the human-readable version: 51 | 52 | .. code-block:: python 53 | 54 | from model_utils import Choices 55 | 56 | class Article(models.Model): 57 | STATUS = Choices((0, 'draft', _('draft')), (1, 'published', _('published'))) 58 | status = models.IntegerField(choices=STATUS, default=STATUS.draft) 59 | 60 | You can index into a ``Choices`` instance to translate a database 61 | representation to its display name: 62 | 63 | .. code-block:: python 64 | 65 | status_display = Article.STATUS[article.status] 66 | 67 | Option groups can also be used with ``Choices``; in that case each 68 | argument is a tuple consisting of the option group name and a list of 69 | options, where each option in the list is either a string, a two-tuple, 70 | or a triple as outlined above. For example: 71 | 72 | .. code-block:: python 73 | 74 | from model_utils import Choices 75 | 76 | class Article(models.Model): 77 | STATUS = Choices(('Visible', ['new', 'archived']), ('Invisible', ['draft', 'deleted'])) 78 | 79 | Choices can be concatenated with the ``+`` operator, both to other Choices 80 | instances and other iterable objects that could be converted into Choices: 81 | 82 | .. code-block:: python 83 | 84 | from model_utils import Choices 85 | 86 | GENERIC_CHOICES = Choices((0, 'draft', _('draft')), (1, 'published', _('published'))) 87 | 88 | class Article(models.Model): 89 | STATUS = GENERIC_CHOICES + [(2, 'featured', _('featured'))] 90 | status = models.IntegerField(choices=STATUS, default=STATUS.draft) 91 | 92 | Should you wish to provide a subset of choices for a field, for 93 | instance, you have a form class to set some model instance to a failed 94 | state, and only wish to show the user the failed outcomes from which to 95 | select, you can use the ``subset`` method: 96 | 97 | .. code-block:: python 98 | 99 | from model_utils import Choices 100 | 101 | OUTCOMES = Choices( 102 | (0, 'success', _('Successful')), 103 | (1, 'user_cancelled', _('Cancelled by the user')), 104 | (2, 'admin_cancelled', _('Cancelled by an admin')), 105 | ) 106 | FAILED_OUTCOMES = OUTCOMES.subset('user_cancelled', 'admin_cancelled') 107 | 108 | The ``choices`` attribute on the model field can then be set to 109 | ``FAILED_OUTCOMES``, thus allowing the subset to be defined in close 110 | proximity to the definition of all the choices, and reused elsewhere as 111 | required. 112 | 113 | 114 | Field Tracker 115 | ============= 116 | 117 | A ``FieldTracker`` can be added to a model to track changes in model fields. A 118 | ``FieldTracker`` allows querying for field changes since a model instance was 119 | last saved. An example of applying ``FieldTracker`` to a model: 120 | 121 | .. code-block:: python 122 | 123 | from django.db import models 124 | from model_utils import FieldTracker 125 | 126 | class Post(models.Model): 127 | title = models.CharField(max_length=100) 128 | body = models.TextField() 129 | 130 | tracker = FieldTracker() 131 | 132 | .. note:: 133 | 134 | ``django-model-utils`` 1.3.0 introduced the ``ModelTracker`` object for 135 | tracking changes to model field values. Unfortunately ``ModelTracker`` 136 | suffered from some serious flaws in its handling of ``ForeignKey`` fields, 137 | potentially resulting in many extra database queries if a ``ForeignKey`` 138 | field was tracked. In order to avoid breaking API backwards-compatibility, 139 | ``ModelTracker`` retains the previous behavior but is deprecated, and 140 | ``FieldTracker`` has been introduced to provide better ``ForeignKey`` 141 | handling. All uses of ``ModelTracker`` should be replaced by 142 | ``FieldTracker``. 143 | 144 | Summary of differences between ``ModelTracker`` and ``FieldTracker``: 145 | 146 | * The previous value returned for a tracked ``ForeignKey`` field will now 147 | be the raw ID rather than the full object (avoiding extra database 148 | queries). (GH-43) 149 | 150 | * The ``changed()`` method no longer returns the empty dictionary for all 151 | unsaved instances; rather, ``None`` is considered to be the initial value 152 | of all fields if the model has never been saved, thus ``changed()`` on an 153 | unsaved instance will return a dictionary containing all fields whose 154 | current value is not ``None``. 155 | 156 | * The ``has_changed()`` method no longer crashes after an object's first 157 | save. (GH-53). 158 | 159 | 160 | Accessing a field tracker 161 | ------------------------- 162 | 163 | There are multiple methods available for checking for changes in model fields. 164 | 165 | 166 | previous 167 | ~~~~~~~~ 168 | Returns the value of the given field during the last save: 169 | 170 | .. code-block:: pycon 171 | 172 | >>> a = Post.objects.create(title='First Post') 173 | >>> a.title = 'Welcome' 174 | >>> a.tracker.previous('title') 175 | u'First Post' 176 | 177 | Returns ``None`` when the model instance isn't saved yet. 178 | 179 | If a field is `deferred`_, calling ``previous()`` will load the previous value from the database. 180 | 181 | .. _deferred: https://docs.djangoproject.com/en/2.0/ref/models/querysets/#defer 182 | 183 | 184 | has_changed 185 | ~~~~~~~~~~~ 186 | Returns ``True`` if the given field has changed since the last save. The ``has_changed`` method expects a single field: 187 | 188 | .. code-block:: pycon 189 | 190 | >>> a = Post.objects.create(title='First Post') 191 | >>> a.title = 'Welcome' 192 | >>> a.tracker.has_changed('title') 193 | True 194 | >>> a.tracker.has_changed('body') 195 | False 196 | 197 | The ``has_changed`` method relies on ``previous`` to determine whether a 198 | field's values has changed. 199 | 200 | If a field is `deferred`_ and has been assigned locally, calling ``has_changed()`` 201 | will load the previous value from the database to perform the comparison. 202 | 203 | changed 204 | ~~~~~~~ 205 | Returns a dictionary of all fields that have been changed since the last save 206 | and the values of the fields during the last save: 207 | 208 | .. code-block:: pycon 209 | 210 | >>> a = Post.objects.create(title='First Post') 211 | >>> a.title = 'Welcome' 212 | >>> a.body = 'First post!' 213 | >>> a.tracker.changed() 214 | {'title': 'First Post', 'body': ''} 215 | 216 | The ``changed`` method relies on ``has_changed`` to determine which fields 217 | have changed. 218 | 219 | 220 | Tracking specific fields 221 | ------------------------ 222 | 223 | A fields parameter can be given to ``FieldTracker`` to limit tracking to 224 | specific fields: 225 | 226 | .. code-block:: python 227 | 228 | from django.db import models 229 | from model_utils import FieldTracker 230 | 231 | class Post(models.Model): 232 | title = models.CharField(max_length=100) 233 | body = models.TextField() 234 | 235 | title_tracker = FieldTracker(fields=['title']) 236 | 237 | An example using the model specified above: 238 | 239 | .. code-block:: pycon 240 | 241 | >>> a = Post.objects.create(title='First Post') 242 | >>> a.body = 'First post!' 243 | >>> a.title_tracker.changed() 244 | {'title': None} 245 | 246 | 247 | Tracking Foreign Key Fields 248 | --------------------------- 249 | 250 | It should be noted that a generic FieldTracker tracks Foreign Keys by db_column name, rather than model field name, and would be accessed as follows: 251 | 252 | .. code-block:: python 253 | 254 | from django.db import models 255 | from model_utils import FieldTracker 256 | 257 | class Parent(models.Model): 258 | name = models.CharField(max_length=64) 259 | 260 | class Child(models.Model): 261 | name = models.CharField(max_length=64) 262 | parent = models.ForeignKey(Parent) 263 | tracker = FieldTracker() 264 | 265 | .. code-block:: pycon 266 | 267 | >>> p = Parent.objects.create(name='P') 268 | >>> c = Child.objects.create(name='C', parent=p) 269 | >>> c.tracker.has_changed('parent_id') 270 | 271 | 272 | To find the db_column names of your model (using the above example): 273 | 274 | .. code-block:: pycon 275 | 276 | >>> for field in Child._meta.fields: 277 | field.get_attname_column() 278 | ('id', 'id') 279 | ('name', 'name') 280 | ('parent_id', 'parent_id') 281 | 282 | 283 | The model field name *may* be used when tracking with a specific tracker: 284 | 285 | .. code-block:: python 286 | 287 | specific_tracker = FieldTracker(fields=['parent']) 288 | 289 | But according to issue #195 this is not recommended for accessing Foreign Key Fields. 290 | 291 | 292 | Checking changes using signals 293 | ------------------------------ 294 | 295 | The field tracker methods may also be used in ``pre_save`` and ``post_save`` 296 | signal handlers to identify field changes on model save. 297 | 298 | .. NOTE:: 299 | 300 | Due to the implementation of ``FieldTracker``, ``post_save`` signal 301 | handlers relying on field tracker methods should only be registered after 302 | model creation. 303 | 304 | FieldTracker implementation details 305 | ----------------------------------- 306 | 307 | .. code-block:: python 308 | 309 | from django.db import models 310 | from model_utils import FieldTracker, TimeStampedModel 311 | 312 | class MyModel(TimeStampedModel): 313 | name = models.CharField(max_length=64) 314 | tracker = FieldTracker() 315 | 316 | def save(self, *args, **kwargs): 317 | """ Automatically add "modified" to update_fields.""" 318 | update_fields = kwargs.get('update_fields') 319 | if update_fields is not None: 320 | kwargs['update_fields'] = set(update_fields) | {'modified'} 321 | super().save(*args, **kwargs) 322 | 323 | # [...] 324 | 325 | instance = MyModel.objects.first() 326 | instance.name = 'new' 327 | instance.save(update_fields={'name'}) 328 | 329 | This is how ``FieldTracker`` tracks field changes on ``instance.save`` call. 330 | 331 | 1. In ``class_prepared`` handler ``FieldTracker`` patches ``save_base``, 332 | ``refresh_from_db`` and ``__init__`` methods to reset initial state for tracked fields. 333 | 2. In the patched ``__init__`` method ``FieldTracker`` saves initial values for tracked 334 | fields. 335 | 3. ``MyModel.save`` changes ``update_fields`` in order to store auto updated 336 | ``modified`` timestamp. Complete list of saved fields is now known. 337 | 4. ``Model.save`` does nothing interesting except calling ``save_base``. 338 | 5. Decorated ``save_base()`` method calls ``super().save_base`` and all fields 339 | that have values different to initial are considered as changed. 340 | 6. ``Model.save_base`` sends ``pre_save`` signal, saves instance to database and 341 | sends ``post_save`` signal. All ``pre_save/post_save`` receivers can query 342 | ``instance.tracker`` for a set of changed fields etc. 343 | 7. After ``Model.save_base`` return ``FieldTracker`` resets initial state for 344 | updated fields (if no ``update_fields`` passed - whole initial state is 345 | reset). 346 | 8. ``instance.refresh_from_db()`` call causes initial state reset like for 347 | ``save_base()``. 348 | 349 | When FieldTracker resets fields state 350 | ------------------------------------- 351 | 352 | By the definition: 353 | 354 | .. NOTE:: 355 | * Field value *is changed* if it differs from current database value. 356 | * Field value *was changed* if value has changed in database and field state didn't reset. 357 | 358 | .. code-block:: python 359 | 360 | instance = Tracked.objects.get(pk=1) 361 | # name not changed 362 | instance.name += '_changed' 363 | # name is changed 364 | instance.save() 365 | # name is not changed again 366 | 367 | Current implementation resets fields state after ``post_save`` signals emitting. This is convenient for "outer" code 368 | like in example above, but does not help when model ``save`` method is overridden. 369 | 370 | .. code-block:: python 371 | 372 | class MyModel(models.Model) 373 | name = models.CharField(max_length=64) 374 | tracker = FieldTracker() 375 | 376 | def save(self): # erroneous implementation 377 | self.name = self.name.replace(' ', '_') 378 | name_changed = self.tracker.has_changed('name') 379 | super().save() 380 | # changed state has been reset here, so we need to store previous state somewhere else 381 | if name_changed: 382 | do_something_about_it() 383 | 384 | ``FieldTracker`` provides a context manager interface to postpone fields state reset in complicate situations. 385 | 386 | * Fields state resets after exiting from outer-most context 387 | * By default, all fields are reset, but field list can be provided 388 | * Fields are counted separately depending on field list passed to context managers 389 | * Tracker can be used as decorator 390 | * Different instances have their own context state 391 | * Different trackers in same instance have separate context state 392 | 393 | .. code-block:: python 394 | 395 | class MyModel(models.Model) 396 | name = models.CharField(max_length=64) 397 | tracker = FieldTracker() 398 | 399 | def save(self): # correct implementation 400 | self.name = self.name.replace(' ', '_') 401 | 402 | with self.tracker: 403 | super().save() 404 | # changed state reset is postponed 405 | if self.tracker.has_changed('name'): 406 | do_something_about_it() 407 | 408 | # Decorator example 409 | @tracker 410 | def save(self): ... 411 | 412 | # Restrict a set of fields to reset here 413 | @tracker(fields=('name')) 414 | def save(self): ... 415 | 416 | # Context manager with field list 417 | def save(self): 418 | with self.tracker('name'): 419 | ... 420 | 421 | -------------------------------------------------------------------------------- /model_utils/tracker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from copy import deepcopy 4 | from functools import wraps 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Any, 8 | Generic, 9 | Iterable, 10 | Protocol, 11 | TypeVar, 12 | cast, 13 | overload, 14 | ) 15 | 16 | from django.core.exceptions import FieldError 17 | from django.db import models 18 | from django.db.models.fields.files import FieldFile 19 | 20 | if TYPE_CHECKING: 21 | from collections.abc import Callable, Mapping 22 | from types import TracebackType 23 | 24 | class _AugmentedModel(models.Model): 25 | _instance_initialized: bool 26 | _deferred_fields: set[str] 27 | 28 | T = TypeVar("T") 29 | 30 | 31 | class Descriptor(Protocol[T]): 32 | def __get__(self, instance: object, owner: type[object]) -> T: 33 | ... 34 | 35 | def __set__(self, instance: object, value: T) -> None: 36 | ... 37 | 38 | 39 | class FullDescriptor(Descriptor[T]): 40 | def __delete__(self, instance: object) -> None: 41 | ... 42 | 43 | 44 | class LightStateFieldFile(FieldFile): 45 | """ 46 | FieldFile subclass with the only aim to remove the instance from the state. 47 | 48 | The change introduced in Django 3.1 on FieldFile subclasses results in pickling the 49 | whole instance for every field tracked. 50 | As this is done on the initialization of objects, a simple queryset evaluation on 51 | Django 3.1+ can make the app unusable, as CPU and memory usage gets easily 52 | multiplied by magnitudes. 53 | """ 54 | def __getstate__(self) -> dict[str, Any]: 55 | """ 56 | We don't need to deepcopy the instance, so nullify if provided. 57 | """ 58 | state = super().__getstate__() 59 | if 'instance' in state: 60 | state['instance'] = None 61 | return state 62 | 63 | 64 | def lightweight_deepcopy(value: T) -> T: 65 | """ 66 | Use our lightweight class to avoid copying the instance on a FieldFile deepcopy. 67 | """ 68 | if isinstance(value, FieldFile): 69 | value = cast(T, LightStateFieldFile( 70 | instance=value.instance, 71 | field=value.field, 72 | name=value.name, 73 | )) 74 | return deepcopy(value) 75 | 76 | 77 | class DescriptorWrapper(Generic[T]): 78 | 79 | def __init__(self, field_name: str, descriptor: Descriptor[T], tracker_attname: str): 80 | self.field_name = field_name 81 | self.descriptor = descriptor 82 | self.tracker_attname = tracker_attname 83 | 84 | @overload 85 | def __get__(self, instance: None, owner: type[models.Model]) -> DescriptorWrapper[T]: 86 | ... 87 | 88 | @overload 89 | def __get__(self, instance: models.Model, owner: type[models.Model]) -> T: 90 | ... 91 | 92 | def __get__(self, instance: models.Model | None, owner: type[models.Model]) -> DescriptorWrapper[T] | T: 93 | if instance is None: 94 | return self 95 | was_deferred = self.field_name in instance.get_deferred_fields() 96 | value = self.descriptor.__get__(instance, owner) 97 | if was_deferred: 98 | tracker_instance = getattr(instance, self.tracker_attname) 99 | tracker_instance.saved_data[self.field_name] = lightweight_deepcopy(value) 100 | return value 101 | 102 | def __set__(self, instance: models.Model, value: T) -> None: 103 | initialized = hasattr(instance, '_instance_initialized') 104 | was_deferred = self.field_name in instance.get_deferred_fields() 105 | 106 | # Sentinel attribute to detect whether we are already trying to 107 | # set the attribute higher up the stack. This prevents infinite 108 | # recursion when retrieving deferred values from the database. 109 | recursion_sentinel_attname = '_setting_' + self.field_name 110 | already_setting = hasattr(instance, recursion_sentinel_attname) 111 | 112 | if initialized and was_deferred and not already_setting: 113 | setattr(instance, recursion_sentinel_attname, True) 114 | try: 115 | # Retrieve the value to set the saved_data value. 116 | # This will undefer the field 117 | getattr(instance, self.field_name) 118 | finally: 119 | instance.__dict__.pop(recursion_sentinel_attname, None) 120 | if hasattr(self.descriptor, '__set__'): 121 | self.descriptor.__set__(instance, value) 122 | else: 123 | instance.__dict__[self.field_name] = value 124 | 125 | def __getattr__(self, attr: str) -> T: 126 | return getattr(self.descriptor, attr) 127 | 128 | @staticmethod 129 | def cls_for_descriptor(descriptor: Descriptor[T]) -> type[DescriptorWrapper[T]]: 130 | if hasattr(descriptor, '__delete__'): 131 | return FullDescriptorWrapper 132 | else: 133 | return DescriptorWrapper 134 | 135 | 136 | class FullDescriptorWrapper(DescriptorWrapper[T]): 137 | """ 138 | Wrapper for descriptors with all three descriptor methods. 139 | """ 140 | def __delete__(self, obj: models.Model) -> None: 141 | cast(FullDescriptor[T], self.descriptor).__delete__(obj) 142 | 143 | 144 | class FieldsContext: 145 | """ 146 | A context manager for tracking nested reset fields contexts. 147 | 148 | If tracked fields is mentioned in more than one FieldsContext, it's state 149 | is being reset on exiting last context that mentions that field. 150 | 151 | >>> with fields_context(obj.tracker, 'f1', state=state): 152 | ... with fields_context(obj.tracker, 'f1', 'f2', state=state): 153 | ... obj.do_something_useful() 154 | ... # f2 is reset after inner context exit 155 | ... obj.do_something_else() 156 | ... # f1 is reset after outer context exit 157 | >>> 158 | 159 | * Note that fields are counted by passing same state dict 160 | * FieldsContext is instantiated using FieldInstanceTracker (`obj.tracker`) 161 | * Different objects has own state stack 162 | 163 | """ 164 | 165 | def __init__( 166 | self, 167 | tracker: FieldInstanceTracker, 168 | *fields: str, 169 | state: dict[str, int] | None = None 170 | ): 171 | """ 172 | :param tracker: FieldInstanceTracker instance to be reset after 173 | context exit 174 | :param fields: a list of field names to be tracked in current context 175 | :param state: shared state dict used to count number of field 176 | occurrences in context stack. 177 | 178 | On context enter each field mentioned in `fields` has +1 in shared 179 | state, and on exit it receives -1. Fields that have zero after context 180 | exit are reset in tracker instance. 181 | """ 182 | if state is None: 183 | state = {} 184 | self.tracker = tracker 185 | self.fields = fields 186 | self.state = state 187 | 188 | def __enter__(self) -> FieldsContext: 189 | """ 190 | Increments tracked fields occurrences count in shared state. 191 | """ 192 | for f in self.fields: 193 | self.state.setdefault(f, 0) 194 | self.state[f] += 1 195 | return self 196 | 197 | def __exit__( 198 | self, 199 | exc_type: type[BaseException] | None, 200 | exc_val: BaseException | None, 201 | exc_tb: TracebackType | None 202 | ) -> None: 203 | """ 204 | Decrements tracked fields occurrences count in shared state. 205 | 206 | If any field has no more occurrences in shared state, this field is 207 | being reset by tracker. 208 | """ 209 | reset_fields = [] 210 | for f in self.fields: 211 | self.state[f] -= 1 212 | if self.state[f] == 0: 213 | reset_fields.append(f) 214 | del self.state[f] 215 | if reset_fields: 216 | self.tracker.set_saved_fields(fields=reset_fields) 217 | 218 | 219 | class FieldInstanceTracker: 220 | def __init__(self, instance: models.Model, fields: Iterable[str], field_map: Mapping[str, str]): 221 | self.instance = cast('_AugmentedModel', instance) 222 | self.fields = fields 223 | self.field_map = field_map 224 | self.context = FieldsContext(self, *self.fields) 225 | 226 | def __enter__(self) -> FieldsContext: 227 | return self.context.__enter__() 228 | 229 | def __exit__( 230 | self, 231 | exc_type: type[BaseException] | None, 232 | exc_val: BaseException | None, 233 | exc_tb: TracebackType | None 234 | ) -> None: 235 | return self.context.__exit__(exc_type, exc_val, exc_tb) 236 | 237 | def __call__(self, *fields: str) -> FieldsContext: 238 | return FieldsContext(self, *fields, state=self.context.state) 239 | 240 | @property 241 | def deferred_fields(self) -> set[str]: 242 | return self.instance.get_deferred_fields() 243 | 244 | def get_field_value(self, field: str) -> Any: 245 | return getattr(self.instance, self.field_map[field]) 246 | 247 | def set_saved_fields(self, fields: Iterable[str] | None = None) -> None: 248 | if not self.instance.pk: 249 | self.saved_data = {} 250 | elif fields is None: 251 | self.saved_data = self.current() 252 | else: 253 | self.saved_data.update(**self.current(fields=fields)) 254 | 255 | # preventing mutable fields side effects 256 | for field, field_value in self.saved_data.items(): 257 | self.saved_data[field] = lightweight_deepcopy(field_value) 258 | 259 | def current(self, fields: Iterable[str] | None = None) -> dict[str, Any]: 260 | """Returns dict of current values for all tracked fields""" 261 | if fields is None: 262 | deferred_fields = self.deferred_fields 263 | if deferred_fields: 264 | fields = [ 265 | field for field in self.fields 266 | if self.field_map[field] not in deferred_fields 267 | ] 268 | else: 269 | fields = self.fields 270 | 271 | return {f: self.get_field_value(f) for f in fields} 272 | 273 | def has_changed(self, field: str) -> bool: 274 | """Returns ``True`` if field has changed from currently saved value""" 275 | if field in self.fields: 276 | # deferred fields haven't changed 277 | if field in self.deferred_fields and field not in self.instance.__dict__: 278 | return False 279 | prev: object = self.previous(field) 280 | curr: object = self.get_field_value(field) 281 | return prev != curr 282 | else: 283 | raise FieldError('field "%s" not tracked' % field) 284 | 285 | def previous(self, field: str) -> Any: 286 | """Returns currently saved value of given field""" 287 | 288 | # handle deferred fields that have not yet been loaded from the database 289 | if self.instance.pk and field in self.deferred_fields and field not in self.saved_data: 290 | 291 | # if the field has not been assigned locally, simply fetch and un-defer the value 292 | if field not in self.instance.__dict__: 293 | self.get_field_value(field) 294 | 295 | # if the field has been assigned locally, store the local value, fetch the database value, 296 | # store database value to saved_data, and restore the local value 297 | else: 298 | current_value = self.get_field_value(field) 299 | self.instance.refresh_from_db(fields=[field]) 300 | self.saved_data[field] = lightweight_deepcopy(self.get_field_value(field)) 301 | setattr(self.instance, self.field_map[field], current_value) 302 | 303 | return self.saved_data.get(field) 304 | 305 | def changed(self) -> dict[str, Any]: 306 | """Returns dict of fields that changed since save (with old values)""" 307 | return { 308 | field: self.previous(field) 309 | for field in self.fields 310 | if self.has_changed(field) 311 | } 312 | 313 | 314 | class FieldTracker: 315 | 316 | tracker_class = FieldInstanceTracker 317 | 318 | def __init__(self, fields: Iterable[str] | None = None): 319 | # finalize_class() will replace None; pretend it is never None. 320 | self.fields = cast(Iterable[str], fields) 321 | 322 | @overload 323 | def __call__( 324 | self, 325 | func: None = None, 326 | fields: Iterable[str] | None = None 327 | ) -> Callable[[Callable[..., T]], Callable[..., T]]: 328 | ... 329 | 330 | @overload 331 | def __call__( 332 | self, 333 | func: Callable[..., T], 334 | fields: Iterable[str] | None = None 335 | ) -> Callable[..., T]: 336 | ... 337 | 338 | def __call__( 339 | self, 340 | func: Callable[..., T] | None = None, 341 | fields: Iterable[str] | None = None 342 | ) -> Callable[[Callable[..., T]], Callable[..., T]] | Callable[..., T]: 343 | def decorator(f: Callable[..., T]) -> Callable[..., T]: 344 | @wraps(f) 345 | def inner(obj: models.Model, *args: object, **kwargs: object) -> T: 346 | tracker = getattr(obj, self.attname) 347 | field_list = tracker.fields if fields is None else fields 348 | with tracker(*field_list): 349 | return f(obj, *args, **kwargs) 350 | 351 | return inner 352 | if func is None: 353 | return decorator 354 | return decorator(func) 355 | 356 | def get_field_map(self, cls: type[models.Model]) -> dict[str, str]: 357 | """Returns dict mapping fields names to model attribute names""" 358 | field_map = {field: field for field in self.fields} 359 | all_fields = {f.name: f.attname for f in cls._meta.fields} 360 | field_map.update(**{k: v for (k, v) in all_fields.items() 361 | if k in field_map}) 362 | return field_map 363 | 364 | def contribute_to_class(self, cls: type[models.Model], name: str) -> None: 365 | self.name = name 366 | self.attname = '_%s' % name 367 | models.signals.class_prepared.connect(self.finalize_class, sender=cls) 368 | 369 | def finalize_class(self, sender: type[models.Model], **kwargs: object) -> None: 370 | if self.fields is None or TYPE_CHECKING: 371 | self.fields = (field.attname for field in sender._meta.fields) 372 | self.fields = set(self.fields) 373 | for field_name in self.fields: 374 | descriptor: models.Field[Any, Any] = getattr(sender, field_name) 375 | wrapper_cls = DescriptorWrapper.cls_for_descriptor(descriptor) 376 | wrapped_descriptor = wrapper_cls(field_name, descriptor, self.attname) 377 | setattr(sender, field_name, wrapped_descriptor) 378 | self.field_map = self.get_field_map(sender) 379 | self.patch_init(sender) 380 | self.model_class = sender 381 | setattr(sender, self.name, self) 382 | self.patch_save(sender) 383 | 384 | def initialize_tracker( 385 | self, 386 | sender: type[models.Model], 387 | instance: models.Model, 388 | **kwargs: object 389 | ) -> None: 390 | if not isinstance(instance, self.model_class): 391 | return # Only init instances of given model (including children) 392 | tracker = self.tracker_class(instance, self.fields, self.field_map) 393 | setattr(instance, self.attname, tracker) 394 | tracker.set_saved_fields() 395 | cast('_AugmentedModel', instance)._instance_initialized = True 396 | 397 | def patch_init(self, model: type[models.Model]) -> None: 398 | original = getattr(model, '__init__') 399 | 400 | @wraps(original) 401 | def inner(instance: models.Model, *args: Any, **kwargs: Any) -> None: 402 | original(instance, *args, **kwargs) 403 | self.initialize_tracker(model, instance) 404 | 405 | setattr(model, '__init__', inner) 406 | 407 | def patch_save(self, model: type[models.Model]) -> None: 408 | self._patch(model, 'save_base', 'update_fields') 409 | self._patch(model, 'refresh_from_db', 'fields') 410 | 411 | def _patch(self, model: type[models.Model], method: str, fields_kwarg: str) -> None: 412 | original = getattr(model, method) 413 | 414 | @wraps(original) 415 | def inner(instance: models.Model, *args: object, **kwargs: Any) -> object: 416 | update_fields: Iterable[str] | None = kwargs.get(fields_kwarg) 417 | if update_fields is None: 418 | fields = self.fields 419 | else: 420 | fields = ( 421 | field for field in update_fields if 422 | field in self.fields 423 | ) 424 | tracker = getattr(instance, self.attname) 425 | with tracker(*fields): 426 | return original(instance, *args, **kwargs) 427 | 428 | setattr(model, method, inner) 429 | 430 | @overload 431 | def __get__(self, instance: None, owner: type[models.Model]) -> FieldTracker: 432 | ... 433 | 434 | @overload 435 | def __get__(self, instance: models.Model, owner: type[models.Model]) -> FieldInstanceTracker: 436 | ... 437 | 438 | def __get__(self, instance: models.Model | None, owner: type[models.Model]) -> FieldTracker | FieldInstanceTracker: 439 | if instance is None: 440 | return self 441 | else: 442 | return getattr(instance, self.attname) 443 | 444 | 445 | class ModelInstanceTracker(FieldInstanceTracker): 446 | 447 | def has_changed(self, field: str) -> bool: 448 | """Returns ``True`` if field has changed from currently saved value""" 449 | if not self.instance.pk: 450 | return True 451 | elif field in self.saved_data: 452 | prev: object = self.previous(field) 453 | curr: object = self.get_field_value(field) 454 | return prev != curr 455 | else: 456 | raise FieldError('field "%s" not tracked' % field) 457 | 458 | def changed(self) -> dict[str, Any]: 459 | """Returns dict of fields that changed since save (with old values)""" 460 | if not self.instance.pk: 461 | return {} 462 | saved = self.saved_data.items() 463 | current = self.current() 464 | return {k: v for k, v in saved if v != current[k]} 465 | 466 | 467 | class ModelTracker(FieldTracker): 468 | tracker_class = ModelInstanceTracker 469 | 470 | def get_field_map(self, cls: type[models.Model]) -> dict[str, str]: 471 | return {field: field for field in self.fields} 472 | --------------------------------------------------------------------------------