├── .gitignore ├── .travis.yml ├── CHANGES.md ├── LICENSE ├── MANIFEST.in ├── README.rst ├── enumfields ├── __init__.py ├── admin.py ├── drf │ ├── __init__.py │ ├── fields.py │ └── serializers.py ├── enums.py ├── fields.py └── forms.py ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── admin.py ├── enums.py ├── models.py ├── settings.py ├── test_checks.py ├── test_django_admin.py ├── test_django_models.py ├── test_enums.py ├── test_form_fields.py ├── test_issue_60.py ├── test_misc.py ├── test_serializers.py └── urls.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg 3 | .DS_Store 4 | /.env 5 | /build 6 | /dist 7 | /MANIFEST 8 | /venv* 9 | *.db 10 | *.egg-info 11 | htmlcov/ 12 | .tox/ 13 | .coverage 14 | .cache 15 | .idea 16 | .eggs 17 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | dist: xenial 4 | matrix: 5 | include: 6 | - python: '3.6' 7 | env: TOXENV=py36-django22 8 | - python: '3.7' 9 | env: TOXENV=py37-django22 10 | - python: '3.8' 11 | env: TOXENV=py38-django22 12 | - python: '3.9' 13 | env: TOXENV=py39-django22 14 | 15 | - python: '3.6' 16 | env: TOXENV=py36-django30 17 | - python: '3.7' 18 | env: TOXENV=py37-django30 19 | - python: '3.8' 20 | env: TOXENV=py38-django30 21 | - python: '3.9' 22 | env: TOXENV=py39-django30 23 | 24 | - python: '3.6' 25 | env: TOXENV=py36-django31 26 | - python: '3.7' 27 | env: TOXENV=py37-django31 28 | - python: '3.8' 29 | env: TOXENV=py38-django31 30 | - python: '3.9' 31 | env: TOXENV=py39-django31 32 | 33 | install: 34 | - pip install -U pip wheel setuptools 35 | - pip install tox 36 | script: tox 37 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # 2.1.1 (released 2021-02-23) 2 | 3 | * Fix string-to-enum conversion regression mistakenly 4 | introduced in 2.1.0 5 | 6 | # 2.1.0 (released 2021-02-22) 7 | 8 | * Drop support for Django 2.0 9 | * Drop support for Django 2.1 10 | * Add support for Django 3.1 11 | * Add support for Python 3.9.2+ 12 | 13 | # 2.0.0 (released 2020-01-18) 14 | 15 | ## Version support changes (possibly breaking) 16 | 17 | * Drop support for Python 2.7 18 | * Drop support for Python 3.4 19 | * Drop support for Django 1.8 20 | * Drop support for Django 1.10 21 | * Add support for Django 2.1 22 | * Add support for Django 2.2 23 | * Add support for Django 3.0 24 | * Add support for Python 3.7 25 | * Add support for Python 3.8 26 | 27 | ## Additions and bugfixes 28 | 29 | * Bug: Fix EnumSupportSerializerMixin for non-editable fields 30 | * Docs: Readme improvements 31 | * Feature: Warn when some values of an enum won't fit in the backing database field 32 | * Packaging: PEP-396 compliant `__version__` attribute 33 | * Packaging: Tests are now packaged with the source distribution 34 | 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright © 2013-2020 Hirshorn Zuckerman Design Group, Inc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the “Software”), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | 16 | 17 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | FOR A PARTICULAR PURPOSE AND NONINFRINDGMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILTY, WHETHER IN 21 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | recursive-include tests *.py 4 | include tox.ini 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This package lets you use real Python (PEP435_-style) enums with Django. 2 | 3 | .. image:: https://travis-ci.org/hzdg/django-enumfields.svg?branch=master 4 | :target: https://travis-ci.org/hzdg/django-enumfields 5 | 6 | .. image:: https://img.shields.io/pypi/v/django-enumfields.svg 7 | :target: https://pypi.python.org/pypi/django-enumfields 8 | 9 | .. image:: https://img.shields.io/pypi/pyversions/django-enumfields.svg 10 | :target: https://pypi.python.org/pypi/django-enumfields/ 11 | 12 | Installation 13 | ------------ 14 | 15 | 1. ``pip install django-enumfields`` 16 | 17 | 18 | Included Tools 19 | -------------- 20 | 21 | 22 | EnumField, EnumIntegerField 23 | ``````````````````````````` 24 | 25 | .. code-block:: python 26 | 27 | from enumfields import EnumField 28 | from enum import Enum 29 | 30 | class Color(Enum): 31 | RED = 'r' 32 | GREEN = 'g' 33 | BLUE = 'b' 34 | 35 | class MyModel(models.Model): 36 | 37 | color = EnumField(Color, max_length=1) 38 | 39 | Elsewhere: 40 | 41 | .. code-block:: python 42 | 43 | m = MyModel.objects.filter(color=Color.RED) 44 | 45 | ``EnumIntegerField`` works identically, but the underlying storage mechanism is 46 | an ``IntegerField`` instead of a ``CharField``. 47 | 48 | 49 | Usage in Forms 50 | ~~~~~~~~~~~~~~ 51 | 52 | Call the ``formfield`` method to use an ``EnumField`` directly in a ``Form``. 53 | 54 | .. code-block:: python 55 | 56 | class MyForm(forms.Form): 57 | 58 | color = EnumField(Color, max_length=1).formfield() 59 | 60 | Enum 61 | ```` 62 | 63 | Normally, you just use normal PEP435_-style enums, however, django-enumfields 64 | also includes its own version of Enum with a few extra bells and whistles. 65 | Namely, the smart definition of labels which are used, for example, in admin 66 | dropdowns. By default, it will create labels by title-casing your constant 67 | names. You can provide custom labels with a nested "Labels" class. 68 | 69 | .. code-block:: python 70 | 71 | from enumfields import EnumField, Enum # Our own Enum class 72 | 73 | class Color(Enum): 74 | RED = 'r' 75 | GREEN = 'g' 76 | BLUE = 'b' 77 | 78 | class Labels: 79 | RED = 'A custom label' 80 | 81 | class MyModel(models.Model): 82 | color = EnumField(Color, max_length=1) 83 | 84 | assert Color.GREEN.label == 'Green' 85 | assert Color.RED.label == 'A custom label' 86 | 87 | 88 | .. _PEP435: http://www.python.org/dev/peps/pep-0435/ 89 | 90 | 91 | EnumFieldListFilter 92 | ``````````````````` 93 | 94 | ``enumfields.admin.EnumFieldListFilter`` is provided to allow using enums in 95 | ``list_filter``. 96 | 97 | 98 | .. code-block:: python 99 | 100 | from enumfields.admin import EnumFieldListFilter 101 | 102 | class MyModelAdmin(admin.ModelAdmin): 103 | list_filter = [('color', EnumFieldListFilter)] 104 | 105 | Django Rest Framework integration 106 | ````````````````````````````````` 107 | 108 | ``EnumSupportSerializerMixin`` mixin allows you to use enums in DRF serializers. 109 | 110 | 111 | .. code-block:: python 112 | 113 | # models.py 114 | from enumfields import EnumField 115 | from enum import Enum 116 | 117 | class Color(Enum): 118 | RED = 'r' 119 | GREEN = 'g' 120 | BLUE = 'b' 121 | 122 | class MyModel(models.Model): 123 | color = EnumField(Color, max_length=1) 124 | 125 | 126 | # serializers.py 127 | from enumfields.drf.serializers import EnumSupportSerializerMixin 128 | from rest_framework import serializers 129 | from .models import MyModel 130 | 131 | class MyModelSerializer(EnumSupportSerializerMixin, serializers.ModelSerializer): 132 | class Meta: 133 | model = MyModel 134 | fields = '__all__' 135 | -------------------------------------------------------------------------------- /enumfields/__init__.py: -------------------------------------------------------------------------------- 1 | from .enums import Enum, IntEnum 2 | from .fields import EnumField, EnumIntegerField 3 | 4 | __version__ = "2.1.1" 5 | -------------------------------------------------------------------------------- /enumfields/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin.filters import ChoicesFieldListFilter 2 | from django.utils.encoding import force_str 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | 6 | class EnumFieldListFilter(ChoicesFieldListFilter): 7 | def choices(self, cl): 8 | yield { 9 | 'selected': self.lookup_val is None, 10 | 'query_string': cl.get_query_string({}, [self.lookup_kwarg]), 11 | 'display': _('All'), 12 | } 13 | for enum_value in self.field.enum: 14 | str_value = force_str(enum_value.value) 15 | yield { 16 | 'selected': (str_value == self.lookup_val), 17 | 'query_string': cl.get_query_string({self.lookup_kwarg: str_value}), 18 | 'display': getattr(enum_value, 'label', None) or force_str(enum_value), 19 | } 20 | 21 | def queryset(self, request, queryset): 22 | try: 23 | self.field.enum(self.lookup_val) 24 | except ValueError: 25 | # since `used_parameters` will always contain strings, 26 | # for non-string-valued enums we'll need to fall back to attempt a slower 27 | # linear stringly-typed lookup. 28 | for enum_value in self.field.enum: 29 | if force_str(enum_value.value) == self.lookup_val: 30 | self.used_parameters[self.lookup_kwarg] = enum_value 31 | break 32 | return super().queryset(request, queryset) 33 | -------------------------------------------------------------------------------- /enumfields/drf/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import EnumField 2 | from .serializers import EnumSupportSerializerMixin 3 | -------------------------------------------------------------------------------- /enumfields/drf/fields.py: -------------------------------------------------------------------------------- 1 | from django.utils.encoding import force_str 2 | 3 | from rest_framework.fields import ChoiceField 4 | 5 | 6 | class EnumField(ChoiceField): 7 | def __init__(self, enum, lenient=False, ints_as_names=False, **kwargs): 8 | """ 9 | :param enum: The enumeration class. 10 | :param lenient: Whether to allow lenient parsing (case-insensitive, by value or name) 11 | :type lenient: bool 12 | :param ints_as_names: Whether to serialize integer-valued enums by their name, not the integer value 13 | :type ints_as_names: bool 14 | """ 15 | self.enum = enum 16 | self.lenient = lenient 17 | self.ints_as_names = ints_as_names 18 | kwargs['choices'] = tuple((e.value, getattr(e, 'label', e.name)) for e in self.enum) 19 | super().__init__(**kwargs) 20 | 21 | def to_representation(self, instance): 22 | if instance in ('', None): 23 | return instance 24 | try: 25 | if not isinstance(instance, self.enum): 26 | instance = self.enum(instance) # Try to cast it 27 | if self.ints_as_names and isinstance(instance.value, int): 28 | # If the enum value is an int, assume the name is more representative 29 | return instance.name.lower() 30 | return instance.value 31 | except ValueError: 32 | raise ValueError('Invalid value [{!r}] of enum {}'.format(instance, self.enum.__name__)) 33 | 34 | def to_internal_value(self, data): 35 | if isinstance(data, self.enum): 36 | return data 37 | try: 38 | # Convert the value using the same mechanism DRF uses 39 | converted_value = self.choice_strings_to_values[str(data)] 40 | return self.enum(converted_value) 41 | except (ValueError, KeyError): 42 | pass 43 | 44 | if self.lenient: 45 | # Normal logic: 46 | for choice in self.enum: 47 | if choice.name == data or choice.value == data: 48 | return choice 49 | 50 | # Case-insensitive logic: 51 | l_data = force_str(data).lower() 52 | for choice in self.enum: 53 | if choice.name.lower() == l_data or force_str(choice.value).lower() == l_data: 54 | return choice 55 | 56 | # Fallback (will likely just raise): 57 | return super().to_internal_value(data) 58 | -------------------------------------------------------------------------------- /enumfields/drf/serializers.py: -------------------------------------------------------------------------------- 1 | from enumfields.drf.fields import EnumField as EnumSerializerField 2 | from enumfields.fields import EnumFieldMixin 3 | from rest_framework.fields import CharField, ChoiceField, IntegerField 4 | 5 | 6 | class EnumSupportSerializerMixin: 7 | enumfield_options = {} 8 | enumfield_classes_to_replace = (ChoiceField, CharField, IntegerField) 9 | 10 | def build_standard_field(self, field_name, model_field): 11 | field_class, field_kwargs = ( 12 | super().build_standard_field(field_name, model_field) 13 | ) 14 | if isinstance(model_field, EnumFieldMixin) and field_class in self.enumfield_classes_to_replace: 15 | field_class = EnumSerializerField 16 | field_kwargs['enum'] = model_field.enum 17 | field_kwargs.update(self.enumfield_options) 18 | return field_class, field_kwargs 19 | -------------------------------------------------------------------------------- /enumfields/enums.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import inspect 3 | from enum import _EnumDict 4 | from enum import Enum as BaseEnum 5 | from enum import EnumMeta as BaseEnumMeta 6 | 7 | from django.utils.encoding import force_str 8 | 9 | 10 | class EnumMeta(BaseEnumMeta): 11 | def __new__(mcs, name, bases, attrs): 12 | Labels = attrs.get('Labels') 13 | 14 | if Labels is not None and inspect.isclass(Labels): 15 | del attrs['Labels'] 16 | if hasattr(attrs, '_member_names'): 17 | attrs._member_names.remove('Labels') 18 | 19 | if sys.version_info >= (3, 9, 2): 20 | attrs._cls_name = name 21 | 22 | obj = BaseEnumMeta.__new__(mcs, name, bases, attrs) 23 | for m in obj: 24 | try: 25 | m.label = getattr(Labels, m.name) 26 | except AttributeError: 27 | m.label = m.name.replace('_', ' ').title() 28 | 29 | return obj 30 | 31 | 32 | class Enum(EnumMeta('Enum', (BaseEnum,), _EnumDict())): 33 | @classmethod 34 | def choices(cls): 35 | """ 36 | Returns a list formatted for use as field choices. 37 | (See https://docs.djangoproject.com/en/dev/ref/models/fields/#choices) 38 | """ 39 | return tuple((m.value, m.label) for m in cls) 40 | 41 | def __str__(self): 42 | """ 43 | Show our label when Django uses the Enum for displaying in a view 44 | """ 45 | return force_str(self.label) 46 | 47 | 48 | class IntEnum(int, Enum): 49 | def __str__(self): # See Enum.__str__ 50 | return force_str(self.label) 51 | -------------------------------------------------------------------------------- /enumfields/fields.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from django.core import checks 4 | from django.core.exceptions import ValidationError 5 | from django.db import models 6 | from django.db.models.fields import BLANK_CHOICE_DASH 7 | from django.utils.functional import cached_property 8 | from django.utils.module_loading import import_string 9 | 10 | from .forms import EnumChoiceField 11 | 12 | 13 | class CastOnAssignDescriptor: 14 | """ 15 | A property descriptor which ensures that `field.to_python()` is called on _every_ assignment to the field. 16 | 17 | This used to be provided by the `django.db.models.subclassing.Creator` class, which in turn 18 | was used by the deprecated-in-Django-1.10 `SubfieldBase` class, hence the reimplementation here. 19 | """ 20 | 21 | def __init__(self, field): 22 | self.field = field 23 | 24 | def __get__(self, obj, type=None): 25 | if obj is None: 26 | return self 27 | return obj.__dict__[self.field.name] 28 | 29 | def __set__(self, obj, value): 30 | obj.__dict__[self.field.name] = self.field.to_python(value) 31 | 32 | 33 | class EnumFieldMixin: 34 | def __init__(self, enum, **options): 35 | if isinstance(enum, str): 36 | self.enum = import_string(enum) 37 | else: 38 | self.enum = enum 39 | 40 | if "choices" not in options: 41 | options["choices"] = [ # choices for the TypedChoiceField 42 | (i, getattr(i, 'label', i.name)) 43 | for i in self.enum 44 | ] 45 | 46 | super().__init__(**options) 47 | 48 | def contribute_to_class(self, cls, name): 49 | super().contribute_to_class(cls, name) 50 | setattr(cls, name, CastOnAssignDescriptor(self)) 51 | 52 | def to_python(self, value): 53 | if value is None or value == '': 54 | return None 55 | if isinstance(value, self.enum): 56 | return value 57 | for m in self.enum: 58 | if value == m: 59 | return m 60 | if value == m.value or str(value) == str(m.value) or str(value) == str(m): 61 | return m 62 | raise ValidationError('{} is not a valid value for enum {}'.format(value, self.enum), code="invalid_enum_value") 63 | 64 | def get_prep_value(self, value): 65 | if value is None: 66 | return None 67 | if isinstance(value, self.enum): # Already the correct type -- fast path 68 | return value.value 69 | return self.enum(value).value 70 | 71 | def from_db_value(self, value, expression, connection, *args): 72 | return self.to_python(value) 73 | 74 | def value_to_string(self, obj): 75 | """ 76 | This method is needed to support proper serialization. While its name is value_to_string() 77 | the real meaning of the method is to convert the value to some serializable format. 78 | Since most of the enum values are strings or integers we WILL NOT convert it to string 79 | to enable integers to be serialized natively. 80 | """ 81 | value = self.value_from_object(obj) 82 | return value.value if value else None 83 | 84 | def get_default(self): 85 | if self.has_default(): 86 | if self.default is None: 87 | return None 88 | 89 | if isinstance(self.default, Enum): 90 | return self.default 91 | 92 | return self.enum(self.default) 93 | 94 | return super().get_default() 95 | 96 | def deconstruct(self): 97 | name, path, args, kwargs = super().deconstruct() 98 | kwargs['enum'] = self.enum 99 | kwargs.pop('choices', None) 100 | if 'default' in kwargs: 101 | if hasattr(kwargs["default"], "value"): 102 | kwargs["default"] = kwargs["default"].value 103 | 104 | return name, path, args, kwargs 105 | 106 | def get_choices(self, include_blank=True, blank_choice=BLANK_CHOICE_DASH): 107 | # Force enum fields' options to use the `value` of the enumeration 108 | # member as the `value` of SelectFields and similar. 109 | return [ 110 | (i.value if isinstance(i, Enum) else i, display) 111 | for (i, display) 112 | in super(EnumFieldMixin, self).get_choices(include_blank, blank_choice) 113 | ] 114 | 115 | def formfield(self, form_class=None, choices_form_class=None, **kwargs): 116 | if not choices_form_class: 117 | choices_form_class = EnumChoiceField 118 | 119 | return super().formfield( 120 | form_class=form_class, 121 | choices_form_class=choices_form_class, 122 | **kwargs 123 | ) 124 | 125 | 126 | class EnumField(EnumFieldMixin, models.CharField): 127 | def __init__(self, enum, **kwargs): 128 | kwargs.setdefault("max_length", 10) 129 | super().__init__(enum, **kwargs) 130 | self.validators = [] 131 | 132 | def check(self, **kwargs): 133 | return [ 134 | *super().check(**kwargs), 135 | *self._check_max_length_fit(**kwargs), 136 | ] 137 | 138 | def _check_max_length_fit(self, **kwargs): 139 | if isinstance(self.max_length, int): 140 | unfit_values = [e for e in self.enum if len(str(e.value)) > self.max_length] 141 | if unfit_values: 142 | fit_max_length = max([len(str(e.value)) for e in self.enum]) 143 | message = ( 144 | "Values {unfit_values} of {enum} won't fit in " 145 | "the backing CharField (max_length={max_length})." 146 | ).format( 147 | unfit_values=unfit_values, 148 | enum=self.enum, 149 | max_length=self.max_length, 150 | ) 151 | hint = "Setting max_length={fit_max_length} will resolve this.".format( 152 | fit_max_length=fit_max_length, 153 | ) 154 | return [ 155 | checks.Warning(message, hint=hint, obj=self, id="enumfields.max_length_fit"), 156 | ] 157 | return [] 158 | 159 | 160 | class EnumIntegerField(EnumFieldMixin, models.IntegerField): 161 | @cached_property 162 | def validators(self): 163 | # Skip IntegerField validators, since they will fail with 164 | # TypeError: unorderable types: TheEnum() < int() 165 | # when used database reports min_value or max_value from 166 | # connection.ops.integer_field_range method. 167 | next = super(models.IntegerField, self) 168 | return next.validators 169 | 170 | def get_prep_value(self, value): 171 | if value is None: 172 | return None 173 | 174 | if isinstance(value, Enum): 175 | return value.value 176 | 177 | try: 178 | return int(value) 179 | except ValueError: 180 | return self.to_python(value).value 181 | -------------------------------------------------------------------------------- /enumfields/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import TypedChoiceField 2 | from django.forms.fields import TypedMultipleChoiceField 3 | from django.utils.encoding import force_str 4 | 5 | from .enums import Enum 6 | 7 | __all__ = ["EnumChoiceField", "EnumMultipleChoiceField"] 8 | 9 | 10 | class EnumChoiceFieldMixin: 11 | def prepare_value(self, value): 12 | # Widgets expect to get strings as values. 13 | 14 | if value is None: 15 | return '' 16 | if hasattr(value, "value"): 17 | value = value.value 18 | return force_str(value) 19 | 20 | def to_python(self, value): 21 | if isinstance(value, Enum): 22 | value = value.value 23 | return super().to_python(value) 24 | 25 | 26 | class EnumChoiceField(EnumChoiceFieldMixin, TypedChoiceField): 27 | pass 28 | 29 | 30 | class EnumMultipleChoiceField(EnumChoiceFieldMixin, TypedMultipleChoiceField): 31 | pass 32 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | python-tag = py3 3 | 4 | [pep8] 5 | max-line-length = 120 6 | exclude = *migrations* 7 | ignore = E309 8 | 9 | [flake8] 10 | exclude = migrations 11 | max-line-length = 120 12 | max-complexity = 10 13 | 14 | [tool:pytest] 15 | DJANGO_SETTINGS_MODULE = tests.settings 16 | norecursedirs = .git .tox .eggs .cache htmlcov venv* 17 | doctest_optionflags = NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL ALLOW_UNICODE 18 | 19 | [isort] 20 | atomic=true 21 | combine_as_imports=false 22 | indent=4 23 | known_standard_library=token,tokenize,enum,importlib 24 | known_third_party=django,six 25 | length_sort=false 26 | line_length=120 27 | multi_line_output=5 28 | not_skip=__init__.py 29 | order_by_type=false 30 | skip=migrations 31 | wrap_length=120 32 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | def read(fname): 10 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 11 | 12 | 13 | def read_version(fname): 14 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", read(fname)).group(1) 15 | 16 | 17 | setup( 18 | name='django-enumfields', 19 | version=read_version('enumfields/__init__.py'), 20 | author='HZDG', 21 | author_email='webmaster@hzdg.com', 22 | description='Real Python Enums for Django.', 23 | license='MIT', 24 | url='https://github.com/hzdg/django-enumfields', 25 | long_description=(read('README.rst')), 26 | packages=find_packages(exclude=['tests*']), 27 | zip_safe=False, 28 | classifiers=[ 29 | 'License :: OSI Approved :: MIT License', 30 | 'Environment :: Web Environment', 31 | 'Framework :: Django', 32 | 'Framework :: Django :: 2.2', 33 | 'Framework :: Django :: 3.0', 34 | 'Framework :: Django :: 3.1', 35 | 'Intended Audience :: Developers', 36 | 'Operating System :: OS Independent', 37 | 'Programming Language :: Python', 38 | 'Programming Language :: Python :: 3', 39 | 'Programming Language :: Python :: 3.5', 40 | 'Programming Language :: Python :: 3.6', 41 | 'Programming Language :: Python :: 3.7', 42 | 'Programming Language :: Python :: 3.8', 43 | 'Programming Language :: Python :: 3.9', 44 | 'Topic :: Internet :: WWW/HTTP', 45 | ], 46 | ) 47 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hzdg/django-enumfields/bda0a461657a87601d235d78176add73a8875832/tests/__init__.py -------------------------------------------------------------------------------- /tests/admin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from django.contrib import admin 4 | 5 | from enumfields.admin import EnumFieldListFilter 6 | 7 | from .models import MyModel 8 | 9 | 10 | class MyModelAdmin(admin.ModelAdmin): 11 | model = MyModel 12 | list_filter = [ 13 | ('color', EnumFieldListFilter), 14 | ('taste', EnumFieldListFilter), 15 | ('int_enum', EnumFieldListFilter), 16 | ] 17 | 18 | 19 | admin.site.register(MyModel, MyModelAdmin) 20 | -------------------------------------------------------------------------------- /tests/enums.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy 2 | 3 | from enumfields import Enum, IntEnum 4 | 5 | 6 | class Color(Enum): 7 | __order__ = 'RED GREEN BLUE' 8 | 9 | RED = 'r' 10 | GREEN = 'g' 11 | BLUE = 'b' 12 | 13 | class Labels: 14 | RED = 'Reddish' 15 | BLUE = gettext_lazy('bluë') 16 | 17 | 18 | class Taste(Enum): 19 | SWEET = 1 20 | SOUR = 2 21 | BITTER = 3 22 | SALTY = 4 23 | UMAMI = 5 24 | 25 | 26 | class ZeroEnum(Enum): 27 | ZERO = 0 28 | ONE = 1 29 | 30 | 31 | class IntegerEnum(IntEnum): 32 | A = 0 33 | B = 1 34 | 35 | class Labels: 36 | A = 'foo' 37 | 38 | 39 | class LabeledEnum(Enum): 40 | FOO = 'foo' 41 | BAR = 'bar' 42 | FOOBAR = 'foobar' 43 | 44 | class Labels: 45 | FOO = 'Foo' 46 | BAR = 'Bar' 47 | # this is intentional. see test_nonunique_label 48 | FOOBAR = 'Foo' 49 | -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from enumfields import EnumField, EnumIntegerField 4 | 5 | from .enums import Color, IntegerEnum, LabeledEnum, Taste, ZeroEnum 6 | 7 | 8 | class MyModel(models.Model): 9 | color = EnumField(Color, max_length=1) 10 | color_not_editable = EnumField(Color, max_length=1, editable=False, null=True) 11 | 12 | taste = EnumField(Taste, default=Taste.SWEET) 13 | taste_not_editable = EnumField(Taste, default=Taste.SWEET, editable=False) 14 | taste_null_default = EnumField(Taste, null=True, blank=True, default=None) 15 | taste_int = EnumIntegerField(Taste, default=Taste.SWEET) 16 | 17 | default_none = EnumIntegerField(Taste, default=None, null=True, blank=True) 18 | nullable = EnumIntegerField(Taste, null=True, blank=True) 19 | 20 | random_code = models.TextField(null=True, blank=True) 21 | 22 | zero = EnumIntegerField(ZeroEnum, default=ZeroEnum.ZERO) 23 | zero2 = EnumIntegerField(ZeroEnum, default=0, blank=True) 24 | 25 | int_enum = EnumIntegerField(IntegerEnum, null=True, default=None, blank=True) 26 | int_enum_not_editable = EnumIntegerField(IntegerEnum, default=IntegerEnum.A, editable=False) 27 | 28 | labeled_enum = EnumField(LabeledEnum, blank=True, null=True) 29 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = 'SEKRIT' 2 | 3 | INSTALLED_APPS = ( 4 | 'django.contrib.contenttypes', 5 | 'django.contrib.sessions', 6 | 'django.contrib.auth', 7 | 'django.contrib.admin', 8 | 'tests', 9 | ) 10 | 11 | ROOT_URLCONF = 'tests.urls' 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'django.db.backends.sqlite3', 15 | 'NAME': 'enumfields.db', 16 | 'TEST_NAME': 'enumfields.db', 17 | }, 18 | } 19 | 20 | MIDDLEWARE = ( 21 | 'django.contrib.sessions.middleware.SessionMiddleware', 22 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 23 | 'django.contrib.messages.middleware.MessageMiddleware', 24 | ) 25 | 26 | # Speed up tests by using a deliberately weak hasher instead of pbkdf/bcrypt: 27 | PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] 28 | 29 | DEBUG = True 30 | 31 | STATIC_URL = "/static/" 32 | 33 | TEMPLATES = [ 34 | { 35 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 36 | 'DIRS': [], 37 | 'APP_DIRS': True, 38 | 'OPTIONS': { 39 | 'context_processors': [ 40 | 'django.contrib.auth.context_processors.auth', 41 | 'django.template.context_processors.debug', 42 | 'django.template.context_processors.i18n', 43 | 'django.template.context_processors.media', 44 | 'django.template.context_processors.static', 45 | 'django.template.context_processors.tz', 46 | 'django.contrib.messages.context_processors.messages', 47 | ], 48 | }, 49 | }, 50 | ] 51 | -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from enumfields import EnumField 2 | from django.db import models 3 | from tests.enums import LabeledEnum 4 | 5 | 6 | def test_shortness_check(): 7 | class TestModel(models.Model): 8 | f = EnumField(LabeledEnum, max_length=3, blank=True, null=True) 9 | f2 = EnumField(LabeledEnum, blank=True, null=True) 10 | assert any([m.id == 'enumfields.max_length_fit' for m in TestModel.check()]) 11 | -------------------------------------------------------------------------------- /tests/test_django_admin.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | 4 | import pytest 5 | from django.urls import reverse 6 | from enumfields import EnumIntegerField 7 | 8 | from .enums import Color, IntegerEnum, Taste, ZeroEnum 9 | from .models import MyModel 10 | 11 | 12 | @pytest.mark.django_db 13 | @pytest.mark.urls('tests.urls') 14 | def test_model_admin_post(admin_client): 15 | url = reverse("admin:tests_mymodel_add") 16 | secret_uuid = str(uuid.uuid4()) 17 | post_data = { 18 | 'color': Color.RED.value, 19 | 'taste': Taste.UMAMI.value, 20 | 'taste_int': Taste.SWEET.value, 21 | 'random_code': secret_uuid, 22 | 'zero': ZeroEnum.ZERO.value, 23 | } 24 | response = admin_client.post(url, follow=True, data=post_data) 25 | response.render() 26 | text = response.content 27 | 28 | assert b"This field is required" not in text 29 | assert b"Select a valid choice" not in text 30 | inst = MyModel.objects.get(random_code=secret_uuid) 31 | assert inst.color == Color.RED, "Redness not assured" 32 | assert inst.taste == Taste.UMAMI, "Umami not there" 33 | assert inst.taste_int == Taste.SWEET, "Not sweet enough" 34 | 35 | 36 | @pytest.mark.django_db 37 | @pytest.mark.urls('tests.urls') 38 | @pytest.mark.parametrize('q_color', (None, Color.BLUE, Color.RED)) 39 | @pytest.mark.parametrize('q_taste', (None, Taste.SWEET, Taste.SOUR)) 40 | @pytest.mark.parametrize('q_int_enum', (None, IntegerEnum.A, IntegerEnum.B)) 41 | def test_model_admin_filter(admin_client, q_color, q_taste, q_int_enum): 42 | """ 43 | Test that various combinations of Enum filters seem to do the right thing in the change list. 44 | """ 45 | 46 | # Create a bunch of objects... 47 | MyModel.objects.create(color=Color.RED) 48 | for taste in Taste: 49 | MyModel.objects.create(color=Color.BLUE, taste=taste) 50 | MyModel.objects.create(color=Color.BLUE, taste=Taste.UMAMI, int_enum=IntegerEnum.A) 51 | MyModel.objects.create(color=Color.GREEN, int_enum=IntegerEnum.B) 52 | 53 | # Build a Django lookup... 54 | lookup = {k: v for (k, v) in { 55 | 'color': q_color, 56 | 'taste': q_taste, 57 | 'int_enum': q_int_enum, 58 | }.items() if v is not None} 59 | # Build the query string (this is assuming things, sort of) 60 | qs = {'%s__exact' % k: v.value for (k, v) in lookup.items()} 61 | # Run the request! 62 | response = admin_client.get(reverse('admin:tests_mymodel_changelist'), data=qs) 63 | response.render() 64 | 65 | # Look for the paginator line that lists how many results we found... 66 | count = int(re.search(r'(\d+) my model', response.content.decode('utf8')).group(1)) 67 | # and compare it to what we expect. 68 | assert count == MyModel.objects.filter(**lookup).count() 69 | 70 | 71 | def test_django_admin_lookup_value_for_integer_enum_field(): 72 | field = EnumIntegerField(Taste) 73 | 74 | assert field.get_prep_value(str(Taste.BITTER)) == 3, "get_prep_value should be able to convert from strings" 75 | -------------------------------------------------------------------------------- /tests/test_django_models.py: -------------------------------------------------------------------------------- 1 | from django.db import connection 2 | 3 | import pytest 4 | 5 | from .enums import Color, IntegerEnum, LabeledEnum, Taste, ZeroEnum 6 | from .models import MyModel 7 | 8 | 9 | @pytest.mark.django_db 10 | def test_field_value(): 11 | m = MyModel(color=Color.RED) 12 | m.save() 13 | assert m.color == Color.RED 14 | 15 | m = MyModel.objects.filter(color=Color.RED)[0] 16 | assert m.color == Color.RED 17 | 18 | # Passing the value should work the same way as passing the enum 19 | assert Color.RED.value == 'r' 20 | m = MyModel.objects.filter(color='r')[0] 21 | assert m.color == Color.RED 22 | 23 | with pytest.raises(ValueError): 24 | MyModel.objects.filter(color='xx')[0] 25 | 26 | 27 | def test_descriptor(): 28 | assert MyModel.color.field.enum is Color 29 | 30 | 31 | @pytest.mark.django_db 32 | def test_db_value(): 33 | m = MyModel(color=Color.RED) 34 | m.save() 35 | cursor = connection.cursor() 36 | cursor.execute('SELECT color FROM %s WHERE id = %%s' % MyModel._meta.db_table, [m.pk]) 37 | assert cursor.fetchone()[0] == Color.RED.value 38 | 39 | 40 | @pytest.mark.django_db 41 | def test_enum_int_field_validators(): 42 | if not hasattr(connection.ops, 'integer_field_range'): 43 | return pytest.skip('Needs connection.ops.integer_field_range') 44 | 45 | # Make sure that integer_field_range returns a range. 46 | # This is needed to make SQLite emulate a "real" db 47 | orig_method = connection.ops.integer_field_range 48 | connection.ops.integer_field_range = (lambda *args: (-100, 100)) 49 | 50 | m = MyModel(color=Color.RED) 51 | 52 | # Uncache validators property of taste_int 53 | for f in m._meta.fields: 54 | if f.name == 'taste_int': 55 | if 'validators' in f.__dict__: 56 | del f.__dict__['validators'] 57 | 58 | # Run the validators 59 | m.full_clean() 60 | 61 | # Revert integer_field_range method 62 | connection.ops.integer_field_range = orig_method 63 | 64 | 65 | @pytest.mark.django_db 66 | def test_zero_enum_loads(): 67 | # Verifies that we can save and load enums with the value of 0 (zero). 68 | m = MyModel(zero=ZeroEnum.ZERO, color=Color.GREEN) 69 | m.save() 70 | assert m.zero == ZeroEnum.ZERO 71 | assert m.zero2 == ZeroEnum.ZERO 72 | 73 | m = MyModel.objects.get(id=m.id) 74 | assert m.zero == ZeroEnum.ZERO 75 | assert m.zero2 == ZeroEnum.ZERO 76 | 77 | 78 | @pytest.mark.django_db 79 | def test_int_enum(): 80 | m = MyModel(int_enum=IntegerEnum.A, color=Color.RED) 81 | m.save() 82 | 83 | m = MyModel.objects.get(id=m.id) 84 | assert m.int_enum == IntegerEnum.A 85 | assert isinstance(m.int_enum, IntegerEnum) 86 | 87 | 88 | def test_serialization(): 89 | from django.core.serializers.python import Serializer as PythonSerializer 90 | m = MyModel(color=Color.RED, taste=Taste.SALTY) 91 | ser = PythonSerializer() 92 | ser.serialize([m]) 93 | fields = ser.getvalue()[0]["fields"] 94 | assert fields["color"] == m.color.value 95 | assert fields["taste"] == m.taste.value 96 | 97 | 98 | @pytest.mark.django_db 99 | def test_nonunique_label(): 100 | obj = MyModel.objects.create( 101 | color=Color.BLUE, 102 | labeled_enum=LabeledEnum.FOOBAR 103 | ) 104 | assert obj.labeled_enum is LabeledEnum.FOOBAR 105 | 106 | obj = MyModel.objects.get(pk=obj.pk) 107 | assert obj.labeled_enum is LabeledEnum.FOOBAR 108 | -------------------------------------------------------------------------------- /tests/test_enums.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.forms import BaseForm 3 | 4 | import pytest 5 | from enumfields import EnumField 6 | 7 | from .enums import Color, IntegerEnum 8 | 9 | 10 | def test_choice_ordering(): 11 | EXPECTED_CHOICES = ( 12 | ('r', 'Reddish'), 13 | ('g', 'Green'), 14 | ('b', 'bluë'), 15 | ) 16 | for ((ex_key, ex_val), (key, val)) in zip(EXPECTED_CHOICES, Color.choices()): 17 | assert key == ex_key 18 | assert str(val) == str(ex_val) 19 | 20 | 21 | def test_custom_labels(): 22 | # Custom label 23 | assert Color.RED.label == 'Reddish' 24 | assert str(Color.RED) == 'Reddish' 25 | assert str(IntegerEnum.A) == 'foo' 26 | 27 | 28 | def test_automatic_labels(): 29 | # Automatic label 30 | assert Color.GREEN.label == 'Green' 31 | assert str(Color.GREEN) == 'Green' 32 | assert str(IntegerEnum.B) == 'B' 33 | 34 | 35 | def test_lazy_labels(): 36 | # Lazy label 37 | assert isinstance(str(Color.BLUE), str) 38 | assert str(Color.BLUE) == 'bluë' 39 | 40 | 41 | def test_formfield_labels(): 42 | # Formfield choice label 43 | form_field = EnumField(Color).formfield() 44 | expectations = {val.value: str(val) for val in Color} 45 | for value, text in form_field.choices: 46 | if value: 47 | assert text == expectations[value] 48 | 49 | 50 | def test_formfield_functionality(): 51 | form_cls = type("FauxForm", (BaseForm,), { 52 | "base_fields": {"color": EnumField(Color).formfield()} 53 | }) 54 | form = form_cls(data={"color": "r"}) 55 | assert not form.errors 56 | assert form.cleaned_data["color"] == Color.RED 57 | 58 | 59 | def test_invalid_to_python_fails(): 60 | with pytest.raises(ValidationError) as ve: 61 | EnumField(Color).to_python("invalid") 62 | assert ve.value.code == "invalid_enum_value" 63 | 64 | 65 | def test_import_by_string(): 66 | assert EnumField("tests.test_enums.Color").enum == Color 67 | -------------------------------------------------------------------------------- /tests/test_form_fields.py: -------------------------------------------------------------------------------- 1 | from django.db.models import BLANK_CHOICE_DASH 2 | from django.forms.models import model_to_dict, modelform_factory 3 | 4 | import pytest 5 | 6 | from .enums import Color, ZeroEnum 7 | from .models import MyModel 8 | 9 | 10 | def get_form(**kwargs): 11 | instance = MyModel(color=Color.RED) 12 | FormClass = modelform_factory(MyModel, fields=("color", "zero", "int_enum")) 13 | return FormClass(instance=instance, **kwargs) 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_unbound_form_with_instance(): 18 | form = get_form() 19 | assert 'value="r" selected' in str(form["color"]) 20 | 21 | 22 | @pytest.mark.django_db 23 | def test_bound_form_with_instance(): 24 | form = get_form(data={"color": "g"}) 25 | assert 'value="g" selected' in str(form["color"]) 26 | 27 | 28 | @pytest.mark.django_db 29 | def test_bound_form_with_instance_empty(): 30 | form = get_form(data={"color": None}) 31 | assert 'value="" selected' in str(form["color"]) 32 | 33 | 34 | def test_choices(): 35 | form = get_form() 36 | assert form.base_fields["zero"].choices == [(0, 'Zero'), (1, 'One')] 37 | assert form.base_fields["int_enum"].choices == BLANK_CHOICE_DASH + [(0, 'foo'), (1, 'B')] 38 | 39 | 40 | def test_validation(): 41 | form = get_form(data={"color": Color.GREEN, "zero": ZeroEnum.ZERO}) 42 | assert form.is_valid(), form.errors 43 | 44 | instance = MyModel(color=Color.RED, zero=ZeroEnum.ZERO) 45 | data = model_to_dict(instance, fields=("color", "zero", "int_enum")) 46 | form = get_form(data=data) 47 | assert form.is_valid(), form.errors 48 | -------------------------------------------------------------------------------- /tests/test_issue_60.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from .enums import Color 4 | from .models import MyModel 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_fields_value_is_enum_when_unsaved(): 9 | obj = MyModel(color='r') 10 | assert Color.RED == obj.color 11 | 12 | 13 | @pytest.mark.django_db 14 | def test_fields_value_is_enum_when_saved(): 15 | obj = MyModel(color='r') 16 | obj.save() 17 | assert Color.RED == obj.color 18 | 19 | 20 | @pytest.mark.django_db 21 | def test_fields_value_is_enum_when_created(): 22 | obj = MyModel.objects.create(color='r') 23 | assert Color.RED == obj.color 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_fields_value_is_enum_when_retrieved(): 28 | MyModel.objects.create(color='r') 29 | obj = MyModel.objects.all()[:1][0] # .first() not available on all Djangoes 30 | assert Color.RED == obj.color 31 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import enumfields 2 | 3 | 4 | def test_version(): 5 | assert isinstance(enumfields.__version__, str) 6 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import pytest 4 | from enumfields.drf import EnumField 5 | from enumfields.drf.serializers import EnumSupportSerializerMixin 6 | from rest_framework import serializers 7 | 8 | from .enums import Color, IntegerEnum, Taste 9 | from .models import MyModel 10 | 11 | 12 | class MySerializer(EnumSupportSerializerMixin, serializers.ModelSerializer): 13 | class Meta: 14 | model = MyModel 15 | fields = '__all__' 16 | 17 | 18 | class LenientIntNameSerializer(MySerializer): 19 | enumfield_options = { 20 | 'lenient': True, 21 | 'ints_as_names': True, 22 | } 23 | 24 | 25 | @pytest.mark.parametrize('int_names', (False, True)) 26 | def test_serialize(int_names): 27 | inst = MyModel( 28 | color=Color.BLUE, 29 | color_not_editable=Color.BLUE, 30 | taste=Taste.UMAMI, 31 | taste_not_editable=Taste.UMAMI, 32 | int_enum=IntegerEnum.B, 33 | int_enum_not_editable=IntegerEnum.B 34 | ) 35 | data = (LenientIntNameSerializer if int_names else MySerializer)(inst).data 36 | assert data['color'] == data['color_not_editable'] == Color.BLUE.value 37 | assert Color.BLUE.value 38 | if int_names: 39 | assert data['taste'] == data['taste_not_editable'] == 'umami' 40 | assert data['int_enum'] == data['int_enum_not_editable'] == 'b' 41 | else: 42 | assert data['taste'] == data['taste_not_editable'] == Taste.UMAMI.value 43 | assert data['int_enum'] == data['int_enum_not_editable'] == IntegerEnum.B.value 44 | 45 | 46 | @pytest.mark.parametrize('instance, representation', [ 47 | ('', ''), 48 | (None, None), 49 | ('r', 'r'), 50 | ('g', 'g'), 51 | ('b', 'b'), 52 | ]) 53 | def test_enumfield_to_representation(instance, representation): 54 | assert EnumField(Color).to_representation(instance) == representation 55 | 56 | 57 | def test_invalid_enumfield_to_representation(): 58 | with pytest.raises(ValueError, match=r"Invalid value.*"): 59 | assert EnumField(Color).to_representation('INVALID_ENUM_STRING') 60 | 61 | 62 | @pytest.mark.django_db 63 | @pytest.mark.parametrize('lenient_serializer', (False, True)) 64 | @pytest.mark.parametrize('lenient_data', (False, True)) 65 | def test_deserialize(lenient_data, lenient_serializer): 66 | secret_uuid = str(uuid.uuid4()) 67 | data = { 68 | 'color': Color.BLUE, 69 | 'taste': Taste.UMAMI.value, 70 | 'int_enum': IntegerEnum.B.value, 71 | 'random_code': secret_uuid, 72 | } 73 | if lenient_data: 74 | data.update({ 75 | 'color': 'b', 76 | 'taste': 'Umami', 77 | 'int_enum': 'B', 78 | }) 79 | serializer_cls = (LenientIntNameSerializer if lenient_serializer else MySerializer) 80 | serializer = serializer_cls(data=data) 81 | if lenient_data and not lenient_serializer: 82 | assert not serializer.is_valid() 83 | return 84 | assert serializer.is_valid(), serializer.errors 85 | 86 | validated_data = serializer.validated_data 87 | assert validated_data['color'] == Color.BLUE 88 | assert validated_data['taste'] == Taste.UMAMI 89 | assert validated_data['int_enum'] == IntegerEnum.B 90 | 91 | inst = serializer.save() 92 | assert inst.color == Color.BLUE 93 | assert inst.taste == Taste.UMAMI 94 | assert inst.int_enum == IntegerEnum.B 95 | 96 | inst = MyModel.objects.get(random_code=secret_uuid) # will raise if fails 97 | assert inst.color == Color.BLUE 98 | assert inst.taste == Taste.UMAMI 99 | assert inst.int_enum == IntegerEnum.B 100 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.urls import re_path 3 | from django.contrib import admin 4 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 5 | 6 | admin.autodiscover() 7 | 8 | urlpatterns = [ 9 | re_path(r'^admin/', admin.site.urls), 10 | ] 11 | 12 | if settings.DEBUG: 13 | urlpatterns = staticfiles_urlpatterns() + urlpatterns 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{36,37,38,39}-django{22,30,31} 4 | 5 | [testenv] 6 | setenv = PYTHONPATH = {toxinidir} 7 | commands = py.test -s tests --cov=enumfields --cov-report=term-missing 8 | deps = 9 | djangorestframework>=3.7 10 | pytest-django 11 | pytest-coverage 12 | django22: Django>=2.2,<2.3 13 | django30: Django>=3.0,<3.1 14 | django31: Django>=3.1,<3.2 15 | --------------------------------------------------------------------------------