├── .github └── workflows │ └── tests.yml ├── .gitignore ├── AUTHORS ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── django_enumfield ├── __init__.py ├── contrib │ ├── __init__.py │ └── drf.py ├── db │ ├── __init__.py │ └── fields.py ├── enum.py ├── exceptions.py ├── forms │ ├── __init__.py │ └── fields.py ├── models.py ├── py.typed ├── tests │ ├── __init__.py │ ├── models.py │ ├── test_contrib.py │ ├── test_enum.py │ ├── test_settings.py │ ├── test_validators.py │ └── urls.py └── validators.py ├── docs └── migrate-to-20.md ├── mypy.ini ├── run_tests.py ├── setup.cfg ├── setup.py └── tox.ini /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - python: "3.7" 17 | tox_env: py37-django22 18 | - python: "3.7" 19 | tox_env: py37-django30 20 | - python: "3.7" 21 | tox_env: py37-django31 22 | - python: "3.7" 23 | tox_env: py37-django32 24 | - python: "3.8" 25 | tox_env: py38-django22 26 | - python: "3.8" 27 | tox_env: py38-django30 28 | - python: "3.8" 29 | tox_env: py38-django31 30 | - python: "3.8" 31 | tox_env: py38-django32 32 | - python: "3.8" 33 | tox_env: py38-django40 34 | - python: "3.8" 35 | tox_env: py38-django41 36 | - python: "3.9" 37 | tox_env: py39-django22 38 | - python: "3.9" 39 | tox_env: py39-django30 40 | - python: "3.9" 41 | tox_env: py39-django31 42 | - python: "3.9" 43 | tox_env: py39-django32 44 | - python: "3.9" 45 | tox_env: py39-django40 46 | - python: "3.9" 47 | tox_env: py39-django41 48 | - python: "3.10" 49 | tox_env: py310-django32 50 | - python: "3.10" 51 | tox_env: py310-django40 52 | - python: "3.10" 53 | tox_env: py310-django41 54 | - python: "3.11" 55 | tox_env: py311-django41 56 | - python: "3.7" 57 | tox_env: checks 58 | name: ${{ matrix.tox_env }} 59 | steps: 60 | - uses: actions/checkout@v3 61 | - uses: actions/setup-python@v4 62 | with: 63 | python-version: ${{ matrix.python }} 64 | - run: pip install tox 65 | - name: Run ${{ matrix.tox_env }} job 66 | run: tox -e ${{ matrix.tox_env }} 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | migrations/ 163 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | https://github.com/5monkeys/django-enumfield/contributors 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [unreleased] 4 | 5 | ## [3.1.0] 6 | 7 | - Support Python 3.11 and Django 4.1 (by [@vitaliyf](https://github.com/vitaliyf)) 8 | 9 | ## [3.0.0] 10 | 11 | - Move CI to GitHub Actions 12 | - Dropped support for Python < 3.7 13 | - Dropped support for Django < 2.2 14 | - Added support for Django 3.2 15 | - Added support for Python 3.10 16 | - Added support for Django 4.0 17 | 18 | ## [2.0.2] 19 | 20 | - Added Django 3.1 support. (Pull #63) 21 | 22 | ## [2.0.1] 23 | 24 | - Fixed get_FIELD_display to handle `None`. (Pull #59) 25 | 26 | ## [2.0.0] 27 | 28 | **Many breaking changes this release.** 29 | 30 | - The ``enumfield.enum.Enum`` class is now a subclass of the native `IntEnum` 31 | shipped with Python 3.4 (uses the ``enum34`` package on previous versions of Python) 32 | - Renamed `labels` to `__labels__` 33 | - Renamed `_transitions` to `__transitions__` 34 | - Added aliases for the classmethods `Enum.name()` as `Enum.get_name()` and 35 | `Enum.label()` as `Enum.get_label()`. Access the old way 36 | (`Enum.name()` and `Enum.label()`) is still supported though, but the new names 37 | are easier to be discovered by IDEs for example. 38 | - `Enum.get_label()` and `Enum.get_name()` now return None if the enum value was 39 | not found instead of raising `AttributeError` 40 | - `EnumField` does not automatically set a default which is the first enum value anymore. 41 | Use `Enum.__default__ = VALUE` or pass it explicitly to `EnumField` 42 | - Converted README.rst to markdown (README.md) 43 | - Added Django 2.2 support 44 | - Added Django 3.0b1 support 45 | - Dropped support for Django < 1.11 46 | - Added limited mypy support 47 | 48 | ## [1.5.0] 49 | 50 | - Added Django 2.1 support 51 | - Added Python 3.7 support 52 | - Dropped Python 3.3 support 53 | 54 | ## [1.4.0] 55 | 56 | - Added Django 1.11 support (from [#43](https://github.com/5monkeys/django-enumfield/pull/43)) 57 | - Added Python 3.6 support 58 | - Dropped support for Django < 1.8 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012 5 Monkeys Agency AB 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft django_enumfield 2 | graft docs 3 | include AUTHORS 4 | include CHANGELOG.md 5 | include LICENSE 6 | include README.md 7 | include run_tests.py 8 | global-exclude *.py[cod] __pycache__ *.so 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | python setup.py test 4 | 5 | .PHONY: flake8 6 | flake8: 7 | flake8 django_enumfield 8 | 9 | .PHONY: mypy 10 | mypy: 11 | mypy django_enumfield 12 | 13 | .PHONY: isort 14 | isort: 15 | isort -rc django_enumfield run_tests.py setup.py 16 | 17 | .PHONY: black 18 | black: 19 | black django_enumfield run_tests.py setup.py 20 | 21 | .PHONY: black-check 22 | black-check: 23 | black --check django_enumfield run_tests.py setup.py 24 | 25 | .PHONY: checks 26 | checks: mypy flake8 black-check 27 | 28 | .PHONY: format 29 | format: black isort 30 | 31 | .PHONY: install 32 | install: 33 | python setup.py install 34 | 35 | .PHONY: develop 36 | develop: 37 | python setup.py develop 38 | 39 | .PHONY: coverage 40 | coverage: 41 | coverage run --include=django_enumfield/* setup.py test 42 | 43 | .PHONY: clean 44 | clean: 45 | rm -rf build dist .tox/ *.egg *.egg-info .coverage* .eggs 46 | find . -name *.pyc -type f -delete 47 | find . -name __pycache__ -type d -delete 48 | 49 | .PHONY: build 50 | build: clean 51 | python -m pip install --upgrade pip 52 | python -m pip install --upgrade wheel 53 | python setup.py sdist bdist_wheel 54 | 55 | .PHONY: release 56 | release: build 57 | python -m pip install --upgrade twine 58 | python -m twine check dist/* 59 | python -m twine upload dist/* 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-enumfield 2 | 3 | Provides an enumeration Django model field (using `IntegerField`) with reusable enums and transition validation. 4 | 5 | [![Build Status](https://github.com/5monkeys/django-enumfield/workflows/Test/badge.svg)](https://github.com/5monkeys/django-enumfield/actions) 6 | [![PyPi Version](https://img.shields.io/pypi/v/django-enumfield.svg)](https://pypi.python.org/pypi/django-enumfield) 7 | [![License](https://img.shields.io/pypi/l/django-enumfield.svg)](https://pypi.python.org/pypi/django-enumfield) 8 | [![Python Versions](https://img.shields.io/pypi/pyversions/django-enumfield.svg)](https://pypi.python.org/pypi/django-enumfield) 9 | [![Wheel](https://img.shields.io/pypi/wheel/django-enumfield.svg)](https://pypi.python.org/pypi/django-enumfield) 10 | ![Coveralls github](https://img.shields.io/coveralls/github/5monkeys/django-enumfield) 11 | 12 | Installation 13 | ------------ 14 | 15 | Currently, [we test](https://github.com/5monkeys/django-enumfield/actions) Django versions 2.2-4.1 and Python versions 3.7-3.11. 16 | 17 | Install `django-enumfield` in your Python environment: 18 | 19 | ```sh 20 | $ pip install django-enumfield 21 | ``` 22 | 23 | **Upgrading from django-enumfield 1.x?** [See the migration guide](docs/migrate-to-20.md) 24 | 25 | For use with Django versions prior to 1.8 use version 26 | [`1.2.1`](https://github.com/5monkeys/django-enumfield/tree/1.2.1) 27 | 28 | For use with Django versions prior to 1.11 use version 29 | [`1.5`](https://github.com/5monkeys/django-enumfield/tree/1.5) 30 | 31 | Usage 32 | ----- 33 | 34 | Create an `Enum`-class and pass it as first argument to the Django model `EnumField`. 35 | 36 | ```python 37 | from django.db import models 38 | from django_enumfield import enum 39 | 40 | 41 | class BeerStyle(enum.Enum): 42 | LAGER = 0 43 | STOUT = 1 44 | WEISSBIER = 2 45 | 46 | 47 | class Beer(models.Model): 48 | style = enum.EnumField(BeerStyle, default=BeerStyle.LAGER) 49 | 50 | 51 | # Use .get to get enum values from either name or ints 52 | print(BeerStyle.get("LAGER")) # 53 | print(BeerStyle.get(1)) # 54 | print(BeerStyle.get(BeerStyle.WEISSBIER)) # 55 | 56 | # It's also possible to use the normal enum way to get the value 57 | print(BeerStyle(1)) # 58 | print(BeerStyle["LAGER"]) # 59 | 60 | # The enum value has easy access to their value and name 61 | print(BeerStyle.LAGER.value) # 0 62 | print(BeerStyle.LAGER.name) # "LAGER" 63 | ``` 64 | 65 | For more information about Python 3 enums 66 | (which our `Enum` inherits, `IntEnum` to be specific) 67 | checkout the [docs](https://docs.python.org/3/library/enum.html). 68 | 69 | 70 | ### Setting the default value 71 | 72 | You can also set default value on your enum class using `__default__` 73 | attribute 74 | 75 | ```python 76 | from django.db import models 77 | from django_enumfield import enum 78 | 79 | 80 | class BeerStyle(enum.Enum): 81 | LAGER = 0 82 | STOUT = 1 83 | WEISSBIER = 2 84 | 85 | __default__ = LAGER 86 | 87 | 88 | class BeerStyleNoDefault(enum.Enum): 89 | LAGER = 0 90 | 91 | 92 | class Beer(models.Model): 93 | style_default_lager = enum.EnumField(BeerStyle) 94 | style_default_stout = enum.EnumField(BeerStyle, default=BeerStyle.STOUT) 95 | style_default_null = enum.EnumField(BeerStyleNoDefault, null=True, blank=True) 96 | 97 | 98 | # When you set __default__ attribute, you can access default value via 99 | # `.default()` method of your enum class 100 | assert BeerStyle.default() == BeerStyle.LAGER 101 | 102 | beer = Beer.objects.create() 103 | assert beer.style_default_larger == BeerStyle.LAGER 104 | assert beer.style_default_stout == BeerStyle.STOUT 105 | assert beer.style_default_null is None 106 | ``` 107 | 108 | ### Labels 109 | 110 | You can use your own labels for `Enum` items 111 | 112 | ```python 113 | from django.utils.translation import gettext_lazy 114 | from django_enumfield import enum 115 | 116 | 117 | class Animals(enum.Enum): 118 | CAT = 1 119 | DOG = 2 120 | SHARK = 3 121 | 122 | __labels__ = { 123 | CAT: gettext_lazy("Cat"), 124 | DOG: gettext_lazy("Dog"), 125 | } 126 | 127 | 128 | print(Animals.CAT.label) # "Cat" 129 | print(Animals.SHARK.label) # "SHARK" 130 | 131 | # There's also classmethods for getting the label 132 | print(Animals.get_label(2)) # "Dog" 133 | print(Animals.get_label("DOG")) # "Dog" 134 | ``` 135 | 136 | ### Validate transitions 137 | 138 | The `Enum`-class provides the possibility to use transition validation. 139 | 140 | ```python 141 | from django.db import models 142 | from django_enumfield import enum 143 | from django_enumfield.exceptions import InvalidStatusOperationError 144 | 145 | 146 | class PersonStatus(enum.Enum): 147 | ALIVE = 1 148 | DEAD = 2 149 | REANIMATED = 3 150 | 151 | __transitions__ = { 152 | DEAD: (ALIVE,), # Can go from ALIVE to DEAD 153 | REANIMATED: (DEAD,) # Can go from DEAD to REANIMATED 154 | } 155 | 156 | 157 | class Person(models.Model): 158 | status = enum.EnumField(PersonStatus) 159 | 160 | # These transitions state that a PersonStatus can only go to DEAD from ALIVE and to REANIMATED from DEAD. 161 | person = Person.objects.create(status=PersonStatus.ALIVE) 162 | try: 163 | person.status = PersonStatus.REANIMATED 164 | except InvalidStatusOperationError: 165 | print("Person status can not go from ALIVE to REANIMATED") 166 | else: 167 | # All good 168 | person.save() 169 | ``` 170 | 171 | ### In forms 172 | 173 | The `Enum`-class can also be used without the `EnumField`. This is very useful in Django form `ChoiceField`s. 174 | 175 | ```python 176 | from django import forms 177 | from django_enumfield import enum 178 | from django_enumfield.forms.fields import EnumChoiceField 179 | 180 | 181 | class GenderEnum(enum.Enum): 182 | MALE = 1 183 | FEMALE = 2 184 | 185 | __labels__ = { 186 | MALE: "Male", 187 | FEMALE: "Female", 188 | } 189 | 190 | 191 | class PersonForm(forms.Form): 192 | gender = EnumChoiceField(GenderEnum) 193 | ``` 194 | 195 | Rendering `PersonForm` in a template will generate a select-box with "Male" and "Female" as option labels for the gender field. 196 | 197 | 198 | Local Development Environment 199 | ----------------------------- 200 | 201 | Make sure black and isort is installed in your env with `pip install -e .[dev]`. 202 | 203 | Before committing run `make format` to apply black and isort to all files. 204 | -------------------------------------------------------------------------------- /django_enumfield/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (3, 1, 0, "final", 0) 2 | 3 | 4 | def get_version(version=None): 5 | """Derives a PEP386-compliant version number from VERSION.""" 6 | if version is None: 7 | version = VERSION 8 | assert len(version) == 5 9 | assert version[3] in ("alpha", "beta", "rc", "final") 10 | 11 | # Now build the two parts of the version number: 12 | # main = X.Y[.Z] 13 | # sub = .devN - for pre-alpha releases 14 | # | {a|b|c}N - for alpha, beta and rc releases 15 | 16 | parts = 2 if version[2] == 0 else 3 17 | main = ".".join(str(x) for x in version[:parts]) 18 | 19 | sub = "" 20 | if version[3] != "final": # pragma: no cover 21 | mapping = {"alpha": "a", "beta": "b", "rc": "c"} 22 | sub = mapping[version[3]] + str(version[4]) 23 | 24 | return main + sub 25 | 26 | 27 | __version__ = get_version() 28 | -------------------------------------------------------------------------------- /django_enumfield/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5monkeys/django-enumfield/5d7c958e657b57d21e6f549090dccf71c2e21393/django_enumfield/contrib/__init__.py -------------------------------------------------------------------------------- /django_enumfield/contrib/drf.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from rest_framework import serializers 3 | 4 | 5 | class EnumField(serializers.ChoiceField): 6 | default_error_messages = {"invalid_choice": _('"{input}" is not a valid choice.')} 7 | 8 | def __init__(self, enum, **kwargs): 9 | self.enum = enum 10 | choices = ( 11 | (self.get_choice_value(enum_value), enum_value.label) 12 | for _, enum_value in enum.choices() 13 | ) 14 | super(EnumField, self).__init__(choices, **kwargs) 15 | 16 | def get_choice_value(self, enum_value): 17 | return enum_value.value 18 | 19 | def to_internal_value(self, data): 20 | if isinstance(data, str) and data.isdigit(): 21 | data = int(data) 22 | 23 | try: 24 | value = self.enum.get(data).value 25 | except AttributeError: # .get() returned None 26 | if not self.required: 27 | raise serializers.SkipField() 28 | self.fail("invalid_choice", input=data) 29 | 30 | return value 31 | 32 | def to_representation(self, value): 33 | enum_value = self.enum.get(value) 34 | if enum_value is not None: 35 | return self.get_choice_value(enum_value) 36 | 37 | 38 | class NamedEnumField(EnumField): 39 | def get_choice_value(self, enum_value): 40 | return enum_value.name 41 | 42 | class Meta: 43 | swagger_schema_fields = {"type": "string"} 44 | -------------------------------------------------------------------------------- /django_enumfield/db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5monkeys/django-enumfield/5d7c958e657b57d21e6f549090dccf71c2e21393/django_enumfield/db/__init__.py -------------------------------------------------------------------------------- /django_enumfield/db/fields.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from functools import partial 3 | from typing import Any, Callable # noqa: F401 4 | 5 | from django import forms 6 | from django.db import models 7 | from django.utils.encoding import force_str 8 | from django.utils.translation import gettext 9 | 10 | from django_enumfield.exceptions import InvalidStatusOperationError 11 | from django_enumfield.forms.fields import EnumChoiceField 12 | 13 | from .. import validators 14 | 15 | try: 16 | from functools import partialmethod as _partialmethod 17 | 18 | class partialishmethod(_partialmethod): 19 | """Workaround for https://github.com/python/cpython/issues/99152""" 20 | 21 | def __get__(self, obj, cls=None): 22 | return self._make_unbound_method().__get__(obj, cls) 23 | 24 | except ImportError: # pragma: no cover 25 | # This path can be dropped after support for Django 2.2 has been removed. 26 | from django.utils.functional import curry # type: ignore[attr-defined] 27 | 28 | def partialishmethod(method): # type: ignore[no-redef] 29 | return curry(method) 30 | 31 | 32 | class EnumField(models.IntegerField): 33 | """EnumField is a convenience field to automatically handle validation of transition 34 | between Enum values and set field choices from the enum. 35 | EnumField(MyEnum, default=MyEnum.INITIAL) 36 | """ 37 | 38 | default_error_messages = models.IntegerField.default_error_messages # type: ignore 39 | 40 | def __init__(self, enum, *args, **kwargs): 41 | kwargs.setdefault("choices", enum.choices()) 42 | if enum.default() is not None: 43 | kwargs.setdefault("default", enum.default()) 44 | self.enum = enum 45 | super(EnumField, self).__init__(*args, **kwargs) 46 | 47 | def get_default(self): 48 | if self.has_default() and callable(self.default): 49 | return self.default() 50 | return self.default 51 | 52 | def get_internal_type(self): 53 | return "IntegerField" 54 | 55 | def contribute_to_class( 56 | self, cls, name, private_only=False, virtual_only=models.NOT_PROVIDED 57 | ): 58 | super(EnumField, self).contribute_to_class(cls, name) 59 | if self.choices: 60 | setattr( 61 | cls, 62 | "get_%s_display" % self.name, 63 | partialishmethod(self._get_FIELD_display), 64 | ) 65 | models.signals.class_prepared.connect(self._setup_validation, sender=cls) 66 | 67 | def _get_FIELD_display(self, cls): 68 | value = getattr(cls, self.attname) 69 | if value is None: 70 | return value 71 | return force_str(value.label, strings_only=True) 72 | 73 | def get_prep_value(self, value): 74 | value = super(EnumField, self).get_prep_value(value) 75 | if value is None: 76 | return value 77 | 78 | if isinstance(value, Enum): 79 | return value.value 80 | return int(value) 81 | 82 | def from_db_value(self, value, *_): 83 | if value is not None: 84 | return self.enum.get(value) 85 | 86 | return value 87 | 88 | def to_python(self, value): 89 | if value is not None: 90 | if isinstance(value, str) and value.isdigit(): 91 | value = int(value) 92 | return self.enum.get(value) 93 | 94 | def _setup_validation(self, sender, **kwargs): 95 | """ 96 | User a customer setter for the field to validate new value against the old one. 97 | The current value is set as '_enum_[att_name]' on the model instance. 98 | """ 99 | att_name = self.get_attname() 100 | private_att_name = "_enum_%s" % att_name 101 | enum = self.enum 102 | 103 | def set_enum(self, new_value): 104 | if new_value is models.NOT_PROVIDED: 105 | new_value = None 106 | if hasattr(self, private_att_name): 107 | # Fetch previous value from private enum attribute. 108 | old_value = getattr(self, private_att_name) 109 | else: 110 | # First setattr no previous value on instance. 111 | old_value = new_value 112 | # Update private enum attribute with new value 113 | if new_value is not None and not isinstance(new_value, enum): 114 | if isinstance(new_value, Enum): 115 | raise TypeError( 116 | "Invalid Enum class passed. Passed {}, expected {}".format( 117 | new_value.__class__.__name__, enum.__name__ 118 | ) 119 | ) 120 | try: 121 | new_value = enum(new_value) 122 | except ValueError: 123 | raise InvalidStatusOperationError( 124 | gettext( 125 | "{value!r} is not one of the available choices " 126 | "for enum {enum}." 127 | ).format(value=new_value, enum=enum) 128 | ) 129 | setattr(self, private_att_name, new_value) 130 | self.__dict__[att_name] = new_value 131 | # Run validation for new value. 132 | validators.validate_valid_transition(enum, old_value, new_value) 133 | 134 | def get_enum(self): 135 | return getattr(self, private_att_name) 136 | 137 | def delete_enum(self): 138 | self.__dict__[att_name] = None 139 | return setattr(self, private_att_name, None) 140 | 141 | if not sender._meta.abstract: 142 | setattr(sender, att_name, property(get_enum, set_enum, delete_enum)) 143 | 144 | def validate(self, value, model_instance): 145 | super(EnumField, self).validate(value, model_instance) 146 | validators.validate_valid_transition( 147 | self.enum, self.value_from_object(model_instance), value 148 | ) 149 | 150 | def formfield(self, **kwargs): 151 | enum_form_class = partial(EnumChoiceField, enum=self.enum) 152 | defaults = { 153 | "widget": forms.Select, 154 | "form_class": enum_form_class, 155 | "choices_form_class": enum_form_class, 156 | "choices": self.enum.choices(blank=self.blank), 157 | } 158 | defaults.update(kwargs) 159 | return super(EnumField, self).formfield(**defaults) 160 | 161 | def deconstruct(self): 162 | name, path, args, kwargs = super(EnumField, self).deconstruct() 163 | kwargs["enum"] = self.enum 164 | if "choices" in kwargs: 165 | del kwargs["choices"] 166 | if "verbose_name" in kwargs: 167 | del kwargs["verbose_name"] 168 | if "default" in kwargs and isinstance(kwargs["default"], self.enum): 169 | # The enum value cannot be deconstructed properly 170 | # for migrations (on django <= 1.8). 171 | # So we send the int value instead. 172 | kwargs["default"] = kwargs["default"].value 173 | 174 | return name, path, args, kwargs 175 | -------------------------------------------------------------------------------- /django_enumfield/enum.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import logging 4 | import enum 5 | from typing import ( 6 | Any, 7 | List, 8 | Optional, 9 | Sequence, 10 | Tuple, 11 | TypeVar, 12 | Union, 13 | cast, 14 | Mapping, 15 | TYPE_CHECKING, 16 | ) 17 | from django.utils.encoding import force_str 18 | 19 | if TYPE_CHECKING: 20 | from django.utils.functional import _StrOrPromise as StrOrPromise 21 | 22 | try: 23 | from django.utils.functional import classproperty # type: ignore 24 | except ImportError: 25 | # Pre-Django 3.1 26 | from django.utils.decorators import classproperty 27 | 28 | from django_enumfield.db.fields import EnumField 29 | 30 | __all__ = ("Enum", "EnumField") 31 | 32 | logger = logging.getLogger(__name__) 33 | RAISE = object() 34 | 35 | 36 | class BlankEnum(enum.Enum): 37 | BLANK = "" 38 | 39 | @property 40 | def label(self): 41 | return "" 42 | 43 | 44 | def classdispatcher(class_method): 45 | class _classdispatcher(object): 46 | def __init__(self, method=None): 47 | self.fget = method 48 | 49 | def __get__(self, instance, cls=None): 50 | if instance is None: 51 | return getattr(cls, class_method) 52 | return self.fget(instance) 53 | 54 | return _classdispatcher 55 | 56 | 57 | Default = TypeVar("Default") 58 | T = TypeVar("T", bound="Enum") 59 | 60 | 61 | class Enum(enum.IntEnum): 62 | """A container for holding and restoring enum values""" 63 | 64 | __labels__ = {} # type: Mapping[int, StrOrPromise] 65 | __default__ = None # type: Optional[int] 66 | __transitions__ = {} # type: Mapping[int, Sequence[int]] 67 | 68 | def __str__(self): 69 | return self.label 70 | 71 | @classdispatcher("get_name") 72 | def name(self): 73 | # type: () -> str 74 | return self._name_ 75 | 76 | @classdispatcher("get_label") 77 | def label(self): 78 | # type: () -> str 79 | """Get human readable label for the matching Enum.Value. 80 | :return: label for value 81 | :rtype: str 82 | """ 83 | labels = self.__class__.__labels__ 84 | return force_str(labels.get(self.value, self.name)) 85 | 86 | @classproperty # type: ignore[arg-type] 87 | def do_not_call_in_templates(cls): 88 | # type: () -> bool 89 | # Fix for Django templates so that any lookups of enums won't fail 90 | # More info: https://stackoverflow.com/questions/35953132/how-to-access-enum-types-in-django-templates # noqa: E501 91 | return True 92 | 93 | @classproperty # type: ignore[arg-type] 94 | def values(cls): 95 | # type: () -> Mapping[int, Enum] 96 | return {member.value: member for member in cls} # type: ignore[attr-defined] 97 | 98 | def deconstruct(self): 99 | """ 100 | See "Adding a deconstruct() method" in 101 | https://docs.djangoproject.com/en/1.8/topics/migrations/ 102 | """ 103 | c = self.__class__ 104 | path = "{}.{}".format(c.__module__, c.__name__) 105 | return path, [self.value], {} 106 | 107 | @classmethod 108 | def items(cls): 109 | # type: () -> List[Tuple[str, int]] 110 | """ 111 | :return: List of tuples consisting of every enum value in the form 112 | [('NAME', value), ...] 113 | """ 114 | items = [(member.name, member.value) for member in cls] 115 | return sorted(items, key=lambda x: x[1]) 116 | 117 | @classmethod 118 | def choices(cls, blank=False): 119 | # type: (bool) -> List[Tuple[Union[int, str], enum.Enum]] 120 | """Choices for Enum 121 | :return: List of tuples (, ) 122 | """ 123 | choices = sorted( 124 | [(member.value, member) for member in cls], key=lambda x: x[0] 125 | ) # type: List[Tuple[Union[str, int], enum.Enum]] 126 | if blank: 127 | choices.insert(0, (BlankEnum.BLANK.value, BlankEnum.BLANK)) 128 | return choices 129 | 130 | @classmethod 131 | def default(cls): 132 | # type: () -> Optional[Enum] 133 | """Default Enum value. Set default value to `__default__` attribute 134 | of your enum class or override this method if you need another 135 | default value. 136 | Usage: 137 | IntegerField(choices=my_enum.choices(), default=my_enum.default(), ... 138 | :return Default value, if set. 139 | """ 140 | if cls.__default__ is not None: 141 | return cast(Enum, cls(cls.__default__)) 142 | return None 143 | 144 | @classmethod 145 | def field(cls, **kwargs): 146 | # type: (Any) -> EnumField 147 | """A shortcut for field declaration 148 | Usage: 149 | class MyModelStatuses(Enum): 150 | UNKNOWN = 0 151 | 152 | class MyModel(Model): 153 | status = MyModelStatuses.field() 154 | 155 | :param kwargs: Arguments passed in EnumField.__init__() 156 | :rtype: EnumField 157 | """ 158 | return EnumField(cls, **kwargs) 159 | 160 | @classmethod 161 | def get( 162 | cls, 163 | name_or_numeric, # type: Union[str, int, T] 164 | default=None, # type: Optional[Default] 165 | ): 166 | # type: (...) -> Union[Enum, Optional[Default]] 167 | """Get Enum.Value object matching the value argument. 168 | :param name_or_numeric: Integer value or attribute name 169 | :param default: The default to return if the value passed is not 170 | a valid enum value 171 | """ 172 | if isinstance(name_or_numeric, cls): 173 | return name_or_numeric 174 | 175 | if isinstance(name_or_numeric, int): 176 | try: 177 | return cls(name_or_numeric) 178 | except ValueError: 179 | pass 180 | elif isinstance(name_or_numeric, str): 181 | try: 182 | return cls[name_or_numeric] 183 | except KeyError: 184 | pass 185 | 186 | return default 187 | 188 | @classmethod 189 | def get_name(cls, name_or_numeric): 190 | # type: (Union[str, int, T]) -> Optional[str] 191 | """Get Enum.Value name matching the value argument. 192 | :param name_or_numeric: Integer value or attribute name 193 | :return: The name or None if not found 194 | """ 195 | value = cls.get(name_or_numeric) 196 | if value is not None: 197 | return value.name 198 | return None 199 | 200 | @classmethod 201 | def get_label(cls, name_or_numeric): 202 | # type: (Union[str, int, Enum]) -> Optional[str] 203 | """Get Enum.Value label matching the value argument. 204 | :param name_or_numeric: Integer value or attribute name 205 | :return: The label or None if not found 206 | """ 207 | value = cls.get(name_or_numeric) 208 | if value is not None: 209 | return value.label 210 | return None 211 | 212 | @classmethod 213 | def is_valid_transition(cls, from_value, to_value): 214 | # type: (Union[int, Enum], Union[int, Enum]) -> bool 215 | """Will check if to_value is a valid transition from from_value. 216 | Returns true if it is a valid transition. 217 | 218 | :param from_value: Start transition point 219 | :param to_value: End transition point 220 | :return: Success flag 221 | """ 222 | if isinstance(from_value, cls): 223 | from_value = from_value.value 224 | if isinstance(to_value, cls): 225 | to_value = to_value.value 226 | 227 | return ( 228 | from_value == to_value 229 | or not cls.__transitions__ 230 | or (from_value in cls.transition_origins(to_value)) 231 | ) 232 | 233 | @classmethod 234 | def transition_origins(cls, to_value): 235 | # type: (Union[int, T]) -> Sequence[int] 236 | """Returns all values the to_value can make a transition from. 237 | :param to_value End transition point 238 | """ 239 | if isinstance(to_value, cls): 240 | to_value = to_value.value 241 | 242 | return cls.__transitions__.get(to_value, []) 243 | -------------------------------------------------------------------------------- /django_enumfield/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | 3 | 4 | class InvalidStatusOperationError(ValidationError): 5 | pass 6 | -------------------------------------------------------------------------------- /django_enumfield/forms/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5monkeys/django-enumfield/5d7c958e657b57d21e6f549090dccf71c2e21393/django_enumfield/forms/__init__.py -------------------------------------------------------------------------------- /django_enumfield/forms/fields.py: -------------------------------------------------------------------------------- 1 | from enum import Enum as NativeEnum 2 | 3 | from django import forms 4 | 5 | 6 | class EnumChoiceField(forms.TypedChoiceField): 7 | def __init__(self, enum, **kwargs): 8 | kwargs.setdefault( 9 | "choices", enum.choices(blank=not kwargs.get("required", True)) 10 | ) 11 | kwargs.setdefault("coerce", int) 12 | super(EnumChoiceField, self).__init__(**kwargs) 13 | self.enum = enum 14 | 15 | def prepare_value(self, value): 16 | if isinstance(value, NativeEnum): 17 | return value.value 18 | return value 19 | 20 | def clean(self, value): 21 | value = super(EnumChoiceField, self).clean(value) 22 | if value == self.empty_value: 23 | return value 24 | return self.enum(value) 25 | -------------------------------------------------------------------------------- /django_enumfield/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5monkeys/django-enumfield/5d7c958e657b57d21e6f549090dccf71c2e21393/django_enumfield/models.py -------------------------------------------------------------------------------- /django_enumfield/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5monkeys/django-enumfield/5d7c958e657b57d21e6f549090dccf71c2e21393/django_enumfield/py.typed -------------------------------------------------------------------------------- /django_enumfield/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/5monkeys/django-enumfield/5d7c958e657b57d21e6f549090dccf71c2e21393/django_enumfield/tests/__init__.py -------------------------------------------------------------------------------- /django_enumfield/tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from django_enumfield.db.fields import EnumField 5 | from django_enumfield.enum import Enum 6 | 7 | 8 | class LampState(Enum): 9 | OFF = 0 10 | ON = 1 11 | 12 | __default__ = OFF 13 | 14 | 15 | class Lamp(models.Model): 16 | state = EnumField(LampState, verbose_name="stately_state") 17 | 18 | 19 | class PersonStatus(Enum): 20 | UNBORN = 0 21 | ALIVE = 1 22 | DEAD = 2 23 | REANIMATED = 3 24 | VOID = 4 25 | 26 | __transitions__ = { 27 | UNBORN: (VOID,), 28 | ALIVE: (UNBORN,), 29 | DEAD: (UNBORN, ALIVE), 30 | REANIMATED: (DEAD,), 31 | } 32 | 33 | 34 | class PersonStatusDefault(Enum): 35 | UNBORN = 0 36 | ALIVE = 1 37 | DEAD = 2 38 | REANIMATED = 3 39 | VOID = 4 40 | 41 | __default__ = UNBORN 42 | 43 | 44 | class Person(models.Model): 45 | example = models.CharField(max_length=100, default="foo") 46 | status = EnumField(PersonStatus, default=PersonStatus.ALIVE) 47 | 48 | def save(self, *args, **kwargs): 49 | super(Person, self).save(*args, **kwargs) 50 | return "Person.save" 51 | 52 | 53 | class BeerStyle(Enum): 54 | LAGER = 0 55 | STOUT = 1 56 | WEISSBIER = 2 57 | 58 | __default__ = LAGER 59 | 60 | 61 | class BeerState(Enum): 62 | FIZZY = 0 63 | STALE = 1 64 | EMPTY = 2 65 | 66 | __default__ = FIZZY 67 | 68 | 69 | class LabelBeer(Enum): 70 | STELLA = 0 71 | JUPILER = 1 72 | TYSKIE = 2 73 | 74 | __labels__ = {STELLA: _("Stella Artois"), TYSKIE: _("Browar Tyskie")} 75 | 76 | 77 | def get_default_beer_label(): 78 | return LabelBeer.JUPILER 79 | 80 | 81 | class Beer(models.Model): 82 | style = EnumField(BeerStyle) 83 | state = EnumField(BeerState, null=True, blank=True) 84 | label = EnumField(LabelBeer, default=get_default_beer_label) 85 | -------------------------------------------------------------------------------- /django_enumfield/tests/test_contrib.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from rest_framework.exceptions import ValidationError 3 | from rest_framework.fields import SkipField 4 | 5 | from django_enumfield.contrib.drf import EnumField, NamedEnumField 6 | from django_enumfield.tests.models import BeerState, LampState 7 | 8 | 9 | class DRFTestCase(TestCase): 10 | def test_enum_field(self): 11 | field = EnumField(BeerState) 12 | self.assertEqual(field.to_internal_value("0"), BeerState.FIZZY) 13 | self.assertEqual( 14 | field.to_internal_value(BeerState.EMPTY.value), BeerState.EMPTY 15 | ) 16 | self.assertEqual( 17 | field.to_representation(BeerState.FIZZY), BeerState.FIZZY.value 18 | ) 19 | 20 | def test_enum_field__validation_fail(self): 21 | field = EnumField(BeerState) 22 | with self.assertRaises(ValidationError): 23 | field.to_internal_value("3") 24 | 25 | nonrequired_field = EnumField(LampState, required=False) 26 | with self.assertRaises(SkipField): 27 | self.assertEqual(nonrequired_field.to_internal_value("3"), 1) 28 | 29 | def test_named_enum_field(self): 30 | field = NamedEnumField(LampState) 31 | self.assertEqual(field.to_internal_value("1"), LampState.ON) 32 | self.assertEqual(field.to_representation(LampState.OFF), "OFF") 33 | -------------------------------------------------------------------------------- /django_enumfield/tests/test_enum.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from os.path import abspath, dirname, exists, join 3 | 4 | from django import forms 5 | from django.core.management import call_command 6 | from django.db import IntegrityError, connection 7 | from django.db.backends.sqlite3.base import DatabaseWrapper 8 | from django.db.models.fields import NOT_PROVIDED 9 | from django.test import TestCase 10 | from django.test.client import RequestFactory 11 | 12 | from django_enumfield.db.fields import EnumField 13 | from django_enumfield.enum import BlankEnum, Enum 14 | from django_enumfield.exceptions import InvalidStatusOperationError 15 | from django_enumfield.forms.fields import EnumChoiceField 16 | from django_enumfield.tests.models import ( 17 | Beer, 18 | BeerState, 19 | BeerStyle, 20 | LabelBeer, 21 | Lamp, 22 | LampState, 23 | Person, 24 | PersonStatus, 25 | PersonStatusDefault, 26 | ) 27 | 28 | 29 | def _mock_disable_constraint_checking(self): 30 | self.cursor().execute("PRAGMA foreign_keys = OFF") 31 | return True 32 | 33 | 34 | def _mock_enable_constraint_checking(self): 35 | self.needs_rollback, needs_rollback = False, self.needs_rollback 36 | try: 37 | self.cursor().execute("PRAGMA foreign_keys = ON") 38 | finally: 39 | self.needs_rollback = needs_rollback 40 | 41 | 42 | @contextmanager 43 | def patch_sqlite_connection(): 44 | if connection.vendor != "sqlite": # pragma: no cover 45 | yield 46 | return 47 | 48 | # Patch sqlite3 connection to drop foreign key constraints before 49 | # running migration 50 | old_enable = DatabaseWrapper.enable_constraint_checking 51 | old_disable = DatabaseWrapper.disable_constraint_checking 52 | DatabaseWrapper.enable_constraint_checking = _mock_enable_constraint_checking 53 | DatabaseWrapper.disable_constraint_checking = _mock_disable_constraint_checking 54 | 55 | try: 56 | yield 57 | finally: 58 | DatabaseWrapper.enable_constraint_checking = old_enable 59 | DatabaseWrapper.disable_constraint_checking = old_disable 60 | 61 | 62 | class PersonForm(forms.ModelForm): 63 | class Meta: 64 | model = Person 65 | fields = ("status",) 66 | 67 | 68 | class EnumFieldTest(TestCase): 69 | def test_enum_field_init(self): 70 | for enum, default in { 71 | PersonStatus: NOT_PROVIDED, 72 | PersonStatusDefault: PersonStatusDefault.UNBORN, 73 | }.items(): 74 | field = EnumField(enum) 75 | self.assertEqual(field.default, default) 76 | self.assertEqual(len(enum.choices()), len(field.choices)) 77 | field = EnumField(enum, default=enum.ALIVE) 78 | self.assertEqual(field.default, enum.ALIVE) 79 | field = EnumField(enum, default=None) 80 | self.assertEqual(field.default, None) 81 | 82 | def test_enum_field_save(self): 83 | # Test model with EnumField WITHOUT __transitions__ 84 | 85 | lamp = Lamp.objects.create() 86 | self.assertEqual(lamp.state, LampState.OFF) 87 | lamp.state = LampState.ON 88 | lamp.save() 89 | self.assertEqual(lamp.state, LampState.ON) 90 | self.assertEqual(lamp.state, 1) 91 | 92 | self.assertRaises(InvalidStatusOperationError, setattr, lamp, "state", 99) 93 | 94 | # Test model with EnumField WITH __transitions__ 95 | person = Person.objects.create() 96 | pk = person.pk 97 | self.assertEqual(person.status, PersonStatus.ALIVE) 98 | person.status = PersonStatus.DEAD 99 | person.save() 100 | self.assertTrue(isinstance(person.status, PersonStatus)) 101 | self.assertEqual(person.status, PersonStatus.DEAD) 102 | 103 | person = Person.objects.get(pk=pk) 104 | self.assertEqual(person.status, PersonStatus.DEAD) 105 | self.assertTrue(isinstance(person.status, int)) 106 | self.assertTrue(isinstance(person.status, PersonStatus)) 107 | 108 | self.assertRaises(InvalidStatusOperationError, setattr, person, "status", 99) 109 | 110 | person = Person.objects.create(status=PersonStatus.ALIVE) 111 | self.assertRaises( 112 | InvalidStatusOperationError, setattr, person, "status", PersonStatus.UNBORN 113 | ) 114 | 115 | person.status = PersonStatus.DEAD 116 | self.assertEqual(person.save(), "Person.save") 117 | 118 | with self.assertRaises(InvalidStatusOperationError): 119 | person.status = PersonStatus.VOID 120 | person.save() 121 | 122 | self.assertTrue(Person.objects.filter(status=PersonStatus.DEAD).exists()) 123 | beer = Beer.objects.create() 124 | beer.style = BeerStyle.LAGER 125 | self.assertEqual(beer.state, BeerState.FIZZY) 126 | beer.save() 127 | 128 | def test_enum_field_refresh_from_db(self): 129 | lamp = Lamp.objects.create(state=LampState.OFF) 130 | lamp2 = Lamp.objects.get(pk=lamp.id) 131 | 132 | lamp.state = LampState.ON 133 | lamp.save() 134 | 135 | self.assertEqual(lamp.state, LampState.ON) 136 | self.assertEqual(lamp2.state, LampState.OFF) 137 | 138 | lamp2.refresh_from_db() 139 | self.assertEqual(lamp2.state, LampState.ON) 140 | 141 | def test_magic_model_properties(self): 142 | beer = Beer.objects.create(style=BeerStyle.WEISSBIER) 143 | self.assertEqual(getattr(beer, "get_style_display")(), "WEISSBIER") 144 | 145 | def test_enum_field_del(self): 146 | lamp = Lamp.objects.create() 147 | del lamp.state 148 | self.assertEqual(lamp.state, None) 149 | self.assertRaises(IntegrityError, lamp.save) 150 | 151 | def test_enum_field_del_save(self): 152 | beer = Beer.objects.create() 153 | beer.style = BeerStyle.STOUT 154 | beer.state = None 155 | beer.save() 156 | self.assertEqual(beer.state, None) 157 | self.assertEqual(beer.style, BeerStyle.STOUT) 158 | 159 | def test_enum_field_modelform_create(self): 160 | request_factory = RequestFactory() 161 | request = request_factory.post("", data={"status": "2"}) 162 | form = PersonForm(request.POST) 163 | self.assertTrue(isinstance(form.fields["status"], forms.TypedChoiceField)) 164 | self.assertTrue(form.is_valid()) 165 | person = form.save() 166 | self.assertTrue(person.status, PersonStatus.DEAD) 167 | 168 | request = request_factory.post("", data={"status": "99"}) 169 | form = PersonForm(request.POST, instance=person) 170 | self.assertFalse(form.is_valid()) 171 | 172 | def test_enum_field_modelform(self): 173 | person = Person.objects.create() 174 | 175 | request_factory = RequestFactory() 176 | request = request_factory.post("", data={"status": "2"}) 177 | form = PersonForm(request.POST, instance=person) 178 | self.assertTrue(isinstance(form.fields["status"], forms.TypedChoiceField)) 179 | self.assertTrue(form.is_valid()) 180 | form.save() 181 | self.assertTrue(person.status, PersonStatus.DEAD) 182 | 183 | request = request_factory.post("", data={"status": "99"}) 184 | form = PersonForm(request.POST, instance=person) 185 | self.assertFalse(form.is_valid()) 186 | 187 | def test_enum_field_modelform_initial(self): 188 | person = Person.objects.create() 189 | form = PersonForm(instance=person) 190 | self.assertEqual(form.fields["status"].initial, PersonStatus.ALIVE.value) 191 | self.assertIn( 192 | '