├── .circleci └── config.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── django_enum_choices ├── __init__.py ├── __version__.py ├── admin.py ├── choice_builders.py ├── exceptions.py ├── fields.py ├── filters.py ├── forms.py ├── serializers.py ├── tests │ ├── __init__.py │ ├── e2e │ │ ├── e2e │ │ │ ├── e2e │ │ │ │ ├── __init__.py │ │ │ │ ├── settings.py │ │ │ │ ├── urls.py │ │ │ │ └── wsgi.py │ │ │ ├── manage.py │ │ │ └── test_models │ │ │ │ ├── __init__.py │ │ │ │ ├── admin.py │ │ │ │ ├── apps.py │ │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ │ ├── models.py │ │ │ │ ├── tests.py │ │ │ │ └── views.py │ │ └── tests.py │ ├── settings.py │ ├── test_admin_filter.py │ ├── test_filters.py │ ├── test_filterset_integrations.py │ ├── test_form_fields.py │ ├── test_form_integrations.py │ ├── test_model_fields.py │ ├── test_model_integrations.py │ ├── test_serializer_fields.py │ ├── test_serializer_integrations.py │ └── testapp │ │ ├── apps.py │ │ ├── database_routers.py │ │ ├── enumerations.py │ │ └── models.py ├── utils.py └── validators.py ├── examples ├── examples │ ├── __init__.py │ ├── choice_builders.py │ ├── enumerations.py │ ├── filters.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_customreadablevalueenummodel.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── settings.py │ ├── urls.py │ ├── usage.py │ └── wsgi.py └── manage.py ├── pytest.ini ├── setup.cfg ├── setup.py └── tox.ini /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: ~/django-enum-choices 5 | docker: 6 | - image: circleci/python:3.6.7-stretch 7 | environment: 8 | DATABASE_URL: postgresql://root@localhost/test_django_enum_choices?sslmode=disable 9 | - image: circleci/postgres:11.4 10 | environment: 11 | POSTGRES_USER: root 12 | POSTGRES_DB: test_django_enum_choices 13 | 14 | steps: 15 | - checkout 16 | - run: 17 | name: install dockerize 18 | command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz 19 | environment: 20 | DOCKERIZE_VERSION: v0.3.0 21 | - run: 22 | name: Wait for db 23 | command: dockerize -wait tcp://localhost:5432 -timeout 1m 24 | - run: sudo chown -R circleci:circleci /usr/local/bin 25 | - run: sudo chown -R circleci:circleci /usr/local/lib/python3.6/site-packages 26 | 27 | - restore_cache: 28 | keys: 29 | - python-versions-cache 30 | - run: 31 | name: Install Python versions 32 | command: | 33 | sudo apt-get update 34 | sudo apt-get install -y make build-essential libssl-dev zlib1g-dev libbz2-dev \ 35 | libreadline-dev libsqlite3-dev wget curl llvm libncurses5-dev libncursesw5-dev \ 36 | xz-utils tk-dev libffi-dev liblzma-dev python-openssl git 37 | [ ! -d "/home/circleci/.pyenv" ] && curl https://pyenv.run | bash 38 | export PATH="$HOME/.pyenv/bin:$PATH" 39 | eval "$(pyenv init -)" 40 | eval "$(pyenv virtualenv-init -)" 41 | pyenv install 3.5.7 --skip-existing 42 | pyenv install 3.6.4 --skip-existing 43 | pyenv install 3.7.0 --skip-existing 44 | pyenv install 3.8.6 --skip-existing 45 | - save_cache: 46 | paths: 47 | - /home/circleci/.pyenv/ 48 | key: 49 | python-versions-cache 50 | 51 | - run: 52 | command: | 53 | export PATH="$HOME/.pyenv/bin:$PATH" 54 | eval "$(pyenv init -)" 55 | eval "$(pyenv virtualenv-init -)" 56 | pip install tox tox-pyenv 57 | pyenv local 3.5.7 3.6.4 3.7.0 3.8.6 58 | tox 59 | pyenv local 3.8.6 60 | pip install -e .[dev] 61 | cd django_enum_choices/tests/e2e && python3 tests.py 62 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 HackSoft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do 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 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include pytest.ini 3 | include tox.ini 4 | recursive-include django_enum_choices *.py 5 | recursive-include django_enum_choices *.txt 6 | recursive-include examples *.py 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-enum-choices (DEPRECATED) 2 | 3 | A custom Django choice field to use with [Python enums.](https://docs.python.org/3/library/enum.html) 4 | 5 | [![PyPI version](https://badge.fury.io/py/django-enum-choices.svg)](https://badge.fury.io/py/django-enum-choices) 6 | 7 | ## ⚠️ Disclaimer ⚠️ 8 | 9 | Starting with version 3.0, Django started supporting [Enumerations for model field choices](https://docs.djangoproject.com/en/dev/releases/3.0/#enumerations-for-model-field-choices) and we recommend using this as a native Django feature, instead of `django-enum-choices` 10 | 11 | **If you are using `django-enum-choices` and you want to upgrade your project to Django >= `3.0` you can refer to the guide in the wiki: [Migrating to Django 3](https://github.com/HackSoftware/django-enum-choices/wiki/Migrating-to-Django-3)** 12 | 13 | ## Table of Contents 14 | 15 | - [django-enum-choices](#django-enum-choices) 16 | - [Table of Contents](#table-of-contents) 17 | - [Installation](#installation) 18 | - [Basic Usage](#basic-usage) 19 | - [Choice builders](#choice-builders) 20 | - [Changing/Removing options from enumerations](#changingremoving-options-from-enumerations) 21 | - [Changing options](#changing-options) 22 | - [Removing options](#removing-options) 23 | - [Usage inside the admin panel](#usage-in-the-admin-panel) 24 | - [Usage with forms](#usage-with-forms) 25 | - [Usage with `django.forms.ModelForm`](#usage-with-djangoformsmodelform) 26 | - [Usage with `django.forms.Form`](#usage-with-djangoformsform) 27 | - [Usage with `django-filter`](#usage-with-django-filter) 28 | - [By using a `Meta` inner class and inheriting from `EnumChoiceFilterMixin`](#by-using-a-meta-inner-class-and-inheriting-from-enumchoicefiltermixin) 29 | - [By declaring the field explicitly on the `FilterSet`](#by-declaring-the-field-explicitly-on-the-filterset) 30 | - [Postgres ArrayField Usage](#postgres-arrayfield-usage) 31 | - [Usage with Django Rest Framework](#usage-with-django-rest-framework) 32 | - [Using `serializers.ModelSerializer` with `EnumChoiceModelSerializerMixin`](#using-serializersmodelserializer-with-enumchoicemodelserializermixin) 33 | - [Using `serializers.ModelSerializer` without `EnumChoiceModelSerializerMixin`](#using-serializersmodelserializer-without-enumchoicemodelserializermixin) 34 | - [Using a subclass of `serializers.Serializer`](#using-a-subclass-of-serializersserializer) 35 | - [Serializing PostgreSQL ArrayField](#serializing-postgresql-arrayfield) 36 | - [Implementation details](#implementation-details) 37 | - [Using Python's `enum.auto`](#using-pythons-enumauto) 38 | - [Development](#development) 39 | 40 | ## Installation 41 | 42 | ```bash 43 | pip install django-enum-choices 44 | ``` 45 | 46 | ## Basic Usage 47 | 48 | ```python 49 | from enum import Enum 50 | 51 | from django.db import models 52 | 53 | from django_enum_choices.fields import EnumChoiceField 54 | 55 | 56 | class MyEnum(Enum): 57 | A = 'a' 58 | B = 'b' 59 | 60 | 61 | class MyModel(models.Model): 62 | enumerated_field = EnumChoiceField(MyEnum) 63 | ``` 64 | 65 | **Model creation** 66 | 67 | ```python 68 | instance = MyModel.objects.create(enumerated_field=MyEnum.A) 69 | ``` 70 | 71 | **Changing enum values** 72 | 73 | ```python 74 | instance.enumerated_field = MyEnum.B 75 | instance.save() 76 | ``` 77 | 78 | **Filtering** 79 | 80 | ```python 81 | MyModel.objects.filter(enumerated_field=MyEnum.A) 82 | ``` 83 | 84 | ## Choice builders 85 | 86 | `EnumChoiceField` extends `CharField` and generates choices internally. Each choice is generated using something, called a `choice_builder`. 87 | 88 | A choice builder function looks like that: 89 | 90 | ```python 91 | def choice_builder(enum: Enum) -> Tuple[str, str]: 92 | # Some implementation 93 | ``` 94 | 95 | If a `choice_builder` argument is passed to a model's `EnumChoiceField`, `django_enum_choices` will use it to generate the choices. 96 | The `choice_builder` must be a callable that accepts an enumeration choice and returns a tuple, 97 | containing the value to be saved and the readable value. 98 | 99 | By default `django_enum_choices` uses one of the four choice builders defined in `django_enum_choices.choice_builders`, named `value_value`. 100 | 101 | It returns a tuple containing the enumeration's value twice: 102 | 103 | ```python 104 | from django_enum_choices.choice_builders import value_value 105 | 106 | class MyEnum(Enum): 107 | A = 'a' 108 | B = 'b' 109 | 110 | print(value_value(MyEnum.A)) # ('a', 'a') 111 | ``` 112 | 113 | You can use one of the four default ones that fits your needs: 114 | 115 | * `value_value` 116 | * `attribute_value` 117 | * `value_attribute` 118 | * `attribute_attribute` 119 | 120 | For example: 121 | 122 | ```python 123 | from django_enum_choices.choice_builders import attribute_value 124 | 125 | class MyEnum(Enum): 126 | A = 'a' 127 | B = 'b' 128 | 129 | class CustomReadableValueEnumModel(models.Model): 130 | enumerated_field = EnumChoiceField( 131 | MyEnum, 132 | choice_builder=attribute_value 133 | ) 134 | ``` 135 | 136 | The resulting choices for `enumerated_field` will be `(('A', 'a'), ('B', 'b'))` 137 | 138 | You can also define your own choice builder: 139 | 140 | ```python 141 | class MyEnum(Enum): 142 | A = 'a' 143 | B = 'b' 144 | 145 | def choice_builder(choice: Enum) -> Tuple[str, str]: 146 | return choice.value, choice.value.upper() + choice.value 147 | 148 | class CustomReadableValueEnumModel(models.Model): 149 | enumerated_field = EnumChoiceField( 150 | MyEnum, 151 | choice_builder=choice_builder 152 | ) 153 | ``` 154 | 155 | Which will result in the following choices `(('a', 'Aa'), ('b', 'Bb'))` 156 | 157 | The values in the returned from `choice_builder` tuple will be cast to strings before being used. 158 | 159 | ## Changing/Removing options from enumerations 160 | At any given point of time all instances of a model that has `EnumChoiceField` must have a value that is currently present in the enumeration. 161 | When changing or removing an option from the enumeration, a custom database migration must be made prior to the enumeration change. 162 | 163 | ### Changing options 164 | When chaging options we'll need several operations: 165 | 166 | 1. Inserting a new option with the new value that we want 167 | 2. Migrating all instances from the old option to the new one 168 | 3. Removing the old option and renaming the old one 169 | 4. Removing the custom data migration code, so migrations can be run on a clean database without an `AttributeError` ocurring 170 | 171 | Example: 172 | 173 | Initial setup: 174 | 175 | ```python 176 | class MyEnum(Enum): 177 | A = 'a' 178 | B = 'b' 179 | 180 | # Desired change: 181 | # A = 'a_updated' 182 | 183 | class MyModel(models.Model): 184 | enumerated_field = EnumChoiceField(MyEnum) 185 | ``` 186 | 187 | 1. Insert a new option with the desired new value: 188 | ```python 189 | class MyEnum: 190 | A_UPDATED = 'a_updated' 191 | A = 'a' 192 | B = 'b' 193 | ``` 194 | ```bash 195 | python manage.py makemigrations 196 | ``` 197 | 198 | 2. Migrate model instances 199 | ```bash 200 | python manage.py makemigrations app_label --empty 201 | ``` 202 | ```python 203 | # migration_name.py 204 | 205 | def forwards(apps, schema_editor): 206 | MyModel = apps.get_model('app_label', 'MyModel') 207 | 208 | MyModel.objects.filter(enumerated_field=MyEnum.A).update(enumerated_field=MyEnum.A_UPDATED) 209 | 210 | class Migration(migrations.Migration): 211 | ... 212 | 213 | operations = [ 214 | migrations.RunPython(forwards), 215 | ] 216 | ``` 217 | ```bash 218 | python manage.py migrate 219 | ``` 220 | 221 | 3. Remove old option and rename new one 222 | ```python 223 | class MyEnum: 224 | A = 'a_updated' 225 | B = 'b' 226 | ``` 227 | ```bash 228 | python manage.py makemigrations 229 | python manage.py migrate 230 | ``` 231 | 232 | 4. Remove custom data migration code 233 | ```python 234 | # migration_name.py 235 | 236 | def forwards(apps, schema_editor): 237 | pass 238 | 239 | class Migration(migrations.Migration): 240 | ... 241 | 242 | operations = [ 243 | migrations.RunPython(forwards), 244 | ] 245 | ``` 246 | 247 | ### Removing options 248 | Removing options from the enumeration includes several operations as well: 249 | 250 | 1. Optional: Making the field nullable (if we want our existing instances' values to be `None`) 251 | 2. Migrating all instances to a new option (or None) 252 | 3. Removing the option from the enumeration 253 | 4. Removing the custom data migration code, so migrations can be run on a clean database without an `AttributeError` ocurring 254 | 255 | Example: 256 | 257 | Initial setup: 258 | 259 | ```python 260 | class MyEnum(Enum): 261 | A = 'a' 262 | B = 'b' 263 | 264 | # Desired change: 265 | # class MyEnum(Enum): 266 | # A = 'a' 267 | 268 | class MyModel(models.Model): 269 | enumerated_field = EnumChoiceField(MyEnum) 270 | ``` 271 | 272 | 1. Optional: Make the field nullable (if you want your existing instances to have a `None` value) 273 | ```python 274 | class MyModel(models.Model): 275 | enumerated_field = EnumChoiceField(MyEnum, blank=True, null=True) 276 | ``` 277 | ```bash 278 | python manage.py makemigrations 279 | ``` 280 | 281 | 2. Migrate model instances 282 | ```bash 283 | python manage.py makemigrations app_label --empty 284 | ``` 285 | ```python 286 | # migration_name.py 287 | 288 | def forwards(apps, schema_editor): 289 | MyModel = apps.get_model('app_label', 'MyModel') 290 | 291 | MyModel.objects.filter(enumerated_field=MyEnum.B).update(enumerated_field=MyEnum.A) 292 | # OR MyModel.objects.filter(enumerated_field=MyEnum.B).update(enumerated_field=None) 293 | 294 | class Migration(migrations.Migration): 295 | ... 296 | 297 | operations = [ 298 | migrations.RunPython(forwards), 299 | ] 300 | ``` 301 | ```bash 302 | python manage.py migrate 303 | ``` 304 | 305 | 3. Remove old option 306 | ```python 307 | class MyEnum: 308 | A = 'a 309 | ``` 310 | ```bash 311 | python manage.py makemigrations 312 | python manage.py migrate 313 | ``` 314 | 315 | 4. Remove custom data migration code 316 | ```python 317 | # migration_name.py 318 | 319 | def forwards(apps, schema_editor): 320 | pass 321 | 322 | class Migration(migrations.Migration): 323 | ... 324 | 325 | operations = [ 326 | migrations.RunPython(forwards), 327 | ] 328 | ``` 329 | 330 | 331 | ## Usage in the admin panel 332 | 333 | Model fields, defined as `EnumChoiceField` can be used with almost all of the admin panel's 334 | standard functionallities. 335 | 336 | One exception from this their usage in `list_filter`. 337 | 338 | If you need an `EnumChoiceField` inside a `ModelAdmin`'s `list_filter`, you can use the following 339 | options: 340 | 341 | * Define the entry insite the list filter as a tuple, containing the field's name and `django_enum_choices.admin.EnumChoiceListFilter` 342 | 343 | ```python 344 | from django.contrib import admin 345 | 346 | from django_enum_choices.admin import EnumChoiceListFilter 347 | 348 | from .models import MyModel 349 | 350 | @admin.register(MyModel) 351 | class MyModelAdmin(admin.ModelAdmin): 352 | list_filter = [('enumerated_field', EnumChoiceListFilter)] 353 | ``` 354 | 355 | * Set `DJANGO_ENUM_CHOICES_REGISTER_LIST_FILTER` inside your settings to `True`, which will automatically set the `EnumChoiceListFilter` class to all 356 | `list_filter` fields that are instances of `EnumChoiceField`. This way, they can be declared directly in the `list_filter` iterable: 357 | 358 | ```python 359 | from django.contrib import admin 360 | 361 | from .models import MyModel 362 | 363 | @admin.register(MyModel) 364 | class MyModelAdmin(admin.ModelAdmin): 365 | list_filter = ('enumerated_field', ) 366 | ``` 367 | 368 | 369 | ## Usage with forms 370 | 371 | There are 2 rules of thumb: 372 | 373 | 1. If you use a `ModelForm`, everything will be taken care of automatically. 374 | 2. If you use a `Form`, you need to take into account what `Enum` and `choice_builder` you are using. 375 | 376 | 377 | ### Usage with `django.forms.ModelForm` 378 | 379 | ```python 380 | from .models import MyModel 381 | 382 | class ModelEnumForm(forms.ModelForm): 383 | class Meta: 384 | model = MyModel 385 | fields = ['enumerated_field'] 386 | 387 | form = ModelEnumForm({ 388 | 'enumerated_field': 'a' 389 | }) 390 | 391 | form.is_valid() 392 | 393 | print(form.save(commit=True)) # 394 | ``` 395 | 396 | ### Usage with `django.forms.Form` 397 | 398 | If you are using the default `value_value` choice builder, you can just do that: 399 | 400 | ```python 401 | from django_enum_choices.forms import EnumChoiceField 402 | 403 | from .enumerations import MyEnum 404 | 405 | class StandardEnumForm(forms.Form): 406 | enumerated_field = EnumChoiceField(MyEnum) 407 | 408 | form = StandardEnumForm({ 409 | 'enumerated_field': 'a' 410 | }) 411 | form.is_valid() 412 | 413 | print(form.cleaned_data) # {'enumerated_field': } 414 | ``` 415 | 416 | If you are passing a different choice builder, you have to also pass it to the form field: 417 | 418 | ```python 419 | from .enumerations import MyEnum 420 | 421 | def custom_choice_builder(choice): 422 | return 'Custom_' + choice.value, choice.value 423 | 424 | class CustomChoiceBuilderEnumForm(forms.Form): 425 | enumerated_field = EnumChoiceField( 426 | MyEnum, 427 | choice_builder=custom_choice_builder 428 | ) 429 | 430 | form = CustomChoiceBuilderEnumForm({ 431 | 'enumerated_field': 'Custom_a' 432 | }) 433 | 434 | form.is_valid() 435 | 436 | print(form.cleaned_data) # {'enumerated_field': } 437 | ``` 438 | 439 | ## Usage with `django-filter` 440 | 441 | As with forms, there are 2 general rules of thumb: 442 | 443 | 1. If you have declared an `EnumChoiceField` in the `Meta.fields` for a given `Meta.model`, you need to inherit `EnumChoiceFilterMixin` in your filter class & everything will be taken care of automatically. 444 | 2. If you are declaring an explicit field, without a model, you need to specify the `Enum` class & the `choice_builder`, if a custom one is used. 445 | 446 | ### By using a `Meta` inner class and inheriting from `EnumChoiceFilterMixin` 447 | 448 | ```python 449 | import django_filters as filters 450 | 451 | from django_enum_choices.filters import EnumChoiceFilterMixin 452 | 453 | class ImplicitFilterSet(EnumChoiceFilterSetMixin, filters.FilterSet): 454 | class Meta: 455 | model = MyModel 456 | fields = ['enumerated_field'] 457 | 458 | filters = { 459 | 'enumerated_field': 'a' 460 | } 461 | filterset = ImplicitFilterSet(filters) 462 | 463 | print(filterset.qs.values_list('enumerated_field', flat=True)) 464 | # , , ]> 465 | ``` 466 | 467 | The `choice_builder` argument can be passed to `django_enum_choices.filters.EnumChoiceFilter` as well when using the field explicitly. When using `EnumChoiceFilterSetMixin`, the `choice_builder` is determined from the model field, for the fields defined inside the `Meta` inner class. 468 | 469 | ```python 470 | import django_filters as filters 471 | 472 | from django_enum_choices.filters import EnumChoiceFilter 473 | 474 | def custom_choice_builder(choice): 475 | return 'Custom_' + choice.value, choice.value 476 | 477 | class ExplicitCustomChoiceBuilderFilterSet(filters.FilterSet): 478 | enumerated_field = EnumChoiceFilter( 479 | MyEnum, 480 | choice_builder=custom_choice_builder 481 | ) 482 | 483 | filters = { 484 | 'enumerated_field': 'Custom_a' 485 | } 486 | filterset = ExplicitCustomChoiceBuilderFilterSet(filters, MyModel.objects.all()) 487 | 488 | print(filterset.qs.values_list('enumerated_field', flat=True)) # , , ]> 489 | ``` 490 | 491 | 492 | ### By declaring the field explicitly on the `FilterSet` 493 | 494 | ```python 495 | import django_filters as filters 496 | 497 | from django_enum_choices.filters import EnumChoiceFilter 498 | 499 | class ExplicitFilterSet(filters.FilterSet): 500 | enumerated_field = EnumChoiceFilter(MyEnum) 501 | 502 | 503 | filters = { 504 | 'enumerated_field': 'a' 505 | } 506 | filterset = ExplicitFilterSet(filters, MyModel.objects.all()) 507 | 508 | print(filterset.qs.values_list('enumerated_field', flat=True)) # , , ]> 509 | ``` 510 | 511 | ## Postgres ArrayField Usage 512 | 513 | You can use `EnumChoiceField` as a child field of an Postgres `ArrayField`. 514 | 515 | ```python 516 | from django.db import models 517 | from django.contrib.postgres.fields import ArrayField 518 | 519 | from django_enum_choices.fields import EnumChoiceField 520 | 521 | from enum import Enum 522 | 523 | class MyEnum(Enum): 524 | A = 'a' 525 | B = 'b' 526 | 527 | class MyModelMultiple(models.Model): 528 | enumerated_field = ArrayField( 529 | base_field=EnumChoiceField(MyEnum) 530 | ) 531 | ``` 532 | 533 | **Model Creation** 534 | 535 | ```python 536 | instance = MyModelMultiple.objects.create(enumerated_field=[MyEnum.A, MyEnum.B]) 537 | ``` 538 | 539 | **Changing enum values** 540 | 541 | ```python 542 | instance.enumerated_field = [MyEnum.B] 543 | instance.save() 544 | ``` 545 | 546 | ## Usage with Django Rest Framework 547 | 548 | As with forms & filters, there are 2 general rules of thumb: 549 | 550 | 1. If you are using a `ModelSerializer` and you inherit `EnumChoiceModelSerializerMixin`, everything will be taken care of automatically. 551 | 2. If you are using a `Serializer`, you need to take the `Enum` class & `choice_builder` into acount. 552 | 553 | ### Using `serializers.ModelSerializer` with `EnumChoiceModelSerializerMixin` 554 | 555 | ```python 556 | from rest_framework import serializers 557 | 558 | from django_enum_choices.serializers import EnumChoiceModelSerializerMixin 559 | 560 | class ImplicitMyModelSerializer( 561 | EnumChoiceModelSerializerMixin, 562 | serializers.ModelSerializer 563 | ): 564 | class Meta: 565 | model = MyModel 566 | fields = ('enumerated_field', ) 567 | ``` 568 | 569 | By default `ModelSerializer.build_standard_field` coerces any field that has a model field with choices to `ChoiceField` which returns the value directly. 570 | 571 | Since enum values resemble `EnumClass.ENUM_INSTANCE` they won't be able to be encoded by the `JSONEncoder` when being passed to a `Response`. 572 | 573 | That's why we need the mixin. 574 | 575 | When using the `EnumChoiceModelSerializerMixin` with DRF's `serializers.ModelSerializer`, the `choice_builder` is automatically passed from the model field to the serializer field. 576 | 577 | ### Using `serializers.ModelSerializer` without `EnumChoiceModelSerializerMixin` 578 | 579 | ```python 580 | from rest_framework import serializers 581 | 582 | from django_enum_choices.serializers import EnumChoiceField 583 | 584 | class MyModelSerializer(serializers.ModelSerializer): 585 | enumerated_field = EnumChoiceField(MyEnum) 586 | 587 | class Meta: 588 | model = MyModel 589 | fields = ('enumerated_field', ) 590 | 591 | # Serialization: 592 | instance = MyModel.objects.create(enumerated_field=MyEnum.A) 593 | serializer = MyModelSerializer(instance) 594 | data = serializer.data # {'enumerated_field': 'a'} 595 | 596 | # Saving: 597 | serializer = MyModelSerializer(data={ 598 | 'enumerated_field': 'a' 599 | }) 600 | serializer.is_valid() 601 | serializer.save() 602 | ``` 603 | 604 | If you are using a custom `choice_builder`, you need to pass that too. 605 | 606 | ```python 607 | def custom_choice_builder(choice): 608 | return 'Custom_' + choice.value, choice.value 609 | 610 | class CustomChoiceBuilderSerializer(serializers.Serializer): 611 | enumerted_field = EnumChoiceField( 612 | MyEnum, 613 | choice_builder=custom_choice_builder 614 | ) 615 | 616 | serializer = CustomChoiceBuilderSerializer({ 617 | 'enumerated_field': MyEnum.A 618 | }) 619 | 620 | data = serializer.data # {'enumerated_field': 'Custom_a'} 621 | ``` 622 | 623 | ### Using a subclass of `serializers.Serializer` 624 | 625 | ```python 626 | from rest_framework import serializers 627 | 628 | from django_enum_choices.serializers import EnumChoiceField 629 | 630 | class MySerializer(serializers.Serializer): 631 | enumerated_field = EnumChoiceField(MyEnum) 632 | 633 | # Serialization: 634 | serializer = MySerializer({ 635 | 'enumerated_field': MyEnum.A 636 | }) 637 | data = serializer.data # {'enumerated_field': 'a'} 638 | 639 | # Deserialization: 640 | serializer = MySerializer(data={ 641 | 'enumerated_field': 'a' 642 | }) 643 | serializer.is_valid() 644 | data = serializer.validated_data # OrderedDict([('enumerated_field', )]) 645 | ``` 646 | 647 | If you are using a custom `choice_builder`, you need to pass that too. 648 | 649 | ### Serializing PostgreSQL ArrayField 650 | 651 | `django-enum-choices` exposes a `MultipleEnumChoiceField` that can be used for serializing arrays of enumerations. 652 | 653 | **Using a subclass of `serializers.Serializer`** 654 | 655 | ```python 656 | from rest_framework import serializers 657 | 658 | from django_enum_choices.serializers import MultipleEnumChoiceField 659 | 660 | class MultipleMySerializer(serializers.Serializer): 661 | enumerated_field = MultipleEnumChoiceField(MyEnum) 662 | 663 | # Serialization: 664 | serializer = MultipleMySerializer({ 665 | 'enumerated_field': [MyEnum.A, MyEnum.B] 666 | }) 667 | data = serializer.data # {'enumerated_field': ['a', 'b']} 668 | 669 | # Deserialization: 670 | serializer = MultipleMySerializer(data={ 671 | 'enumerated_field': ['a', 'b'] 672 | }) 673 | serializer.is_valid() 674 | data = serializer.validated_data # OrderedDict([('enumerated_field', [, ])]) 675 | ``` 676 | 677 | **Using a subclass of `serializers.ModelSerializer`** 678 | 679 | ```python 680 | class ImplicitMultipleMyModelSerializer( 681 | EnumChoiceModelSerializerMixin, 682 | serializers.ModelSerializer 683 | ): 684 | class Meta: 685 | model = MyModelMultiple 686 | fields = ('enumerated_field', ) 687 | 688 | # Serialization: 689 | instance = MyModelMultiple.objects.create(enumerated_field=[MyEnum.A, MyEnum.B]) 690 | serializer = ImplicitMultipleMyModelSerializer(instance) 691 | data = serializer.data # {'enumerated_field': ['a', 'b']} 692 | 693 | # Saving: 694 | serializer = ImplicitMultipleMyModelSerializer(data={ 695 | 'enumerated_field': ['a', 'b'] 696 | }) 697 | serializer.is_valid() 698 | serializer.save() 699 | ``` 700 | 701 | The `EnumChoiceModelSerializerMixin` does not need to be used if `enumerated_field` is defined on the serializer class explicitly. 702 | 703 | ## Implementation details 704 | 705 | * `EnumChoiceField` is a subclass of `CharField`. 706 | * Only subclasses of `Enum` are valid arguments for `EnumChoiceField`. 707 | * `max_length`, if passed, is ignored. `max_length` is automatically calculated from the longest choice. 708 | * `choices` are generated using a special `choice_builder` function, which accepts an enumeration and returns a tuple of 2 items. 709 | * Four choice builder functions are defined inside `django_enum_choices.choice_builders` 710 | * By default the `value_value` choice builder is used. It produces the choices from the values in the enumeration class, like `(enumeration.value, enumeration.value)` 711 | * `choice_builder` can be overriden by passing a callable to the `choice_builder` keyword argument of `EnumChoiceField`. 712 | * All values returned from the choice builder **will be cast to strings** when generating choices. 713 | 714 | For example, lets have the following case: 715 | 716 | ```python 717 | class Value: 718 | def __init__(self, value): 719 | self.value = value 720 | 721 | def __str__(self): 722 | return self.value 723 | 724 | 725 | class CustomObjectEnum(Enum): 726 | A = Value(1) 727 | B = Value('B') 728 | 729 | # The default choice builder `value_value` is being used 730 | 731 | class SomeModel(models.Model): 732 | enumerated_field = EnumChoiceField(CustomObjectEnum) 733 | ``` 734 | 735 | We'll have the following: 736 | 737 | * `SomeModel.enumerated_field.choices == (('1', '1'), ('B', 'B'))` 738 | * `SomeModel.enumerated_field.max_length == 3` 739 | 740 | ## Using Python's `enum.auto` 741 | 742 | `enum.auto` can be used for shorthand enumeration definitions: 743 | 744 | ```python 745 | from enum import Enum, auto 746 | 747 | class AutoEnum(Enum): 748 | A = auto() # 1 749 | B = auto() # 2 750 | 751 | class SomeModel(models.Model): 752 | enumerated_field = EnumChoiceField(Enum) 753 | ``` 754 | 755 | This will result in the following: 756 | * `SomeModel.enumerated_field.choices == (('1', '1'), ('2', '2'))` 757 | 758 | **Overridinng `auto` behaviour** 759 | Custom values for enumerations, created by `auto`, can be defined by 760 | subclassing an `Enum` that defines `_generate_next_value_`: 761 | 762 | ```python 763 | class CustomAutoEnumValueGenerator(Enum): 764 | def _generate_next_value_(name, start, count, last_values): 765 | return { 766 | 'A': 'foo', 767 | 'B': 'bar' 768 | }[name] 769 | 770 | 771 | class CustomAutoEnum(CustomAutoEnumValueGenerator): 772 | A = auto() 773 | B = auto() 774 | ``` 775 | 776 | The above will assign the values mapped in the dictionary as values to attributes in `CustomAutoEnum`. 777 | 778 | ## Development 779 | 780 | **Prerequisites** 781 | * SQLite3 782 | * PostgreSQL server 783 | * Python >= 3.5 virtual environment 784 | 785 | **Fork the repository** 786 | ```bash 787 | git clone https://github.com/your-user-name/django-enum-choices.git django-enum-choices-yourname 788 | cd django-enum-choices-yourname 789 | git remote add upstream https://github.com/HackSoftware/django-enum-choices.git 790 | ``` 791 | 792 | Install the requirements: 793 | ```bash 794 | pip install -e .[dev] 795 | ``` 796 | 797 | Linting and running the tests: 798 | ```bash 799 | tox 800 | ``` 801 | -------------------------------------------------------------------------------- /django_enum_choices/__init__.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | 3 | if apps.apps_ready: 4 | """ 5 | 6 | `register_enum_choice_list_filter` depends on `django.conf.settings` 7 | The tests do not define a settings module so we need to execute 8 | `register_enum_choice_list_filter` only when the apps are loaded. 9 | 10 | """ 11 | 12 | from .admin import register_enum_choice_list_filter 13 | 14 | register_enum_choice_list_filter() 15 | -------------------------------------------------------------------------------- /django_enum_choices/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.1.4" 2 | -------------------------------------------------------------------------------- /django_enum_choices/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.conf import settings 3 | from django.utils.translation import gettext as _ 4 | 5 | from .fields import EnumChoiceField 6 | 7 | 8 | class EnumChoiceListFilter(admin.ChoicesFieldListFilter): 9 | def choices(self, changelist): 10 | """ 11 | The `choices` method from `django.contrib.admin.ChoicesFieldListFilter` 12 | with a patch to cast lookup values from enum enumerations to their respective 13 | primitive values. 14 | """ 15 | 16 | yield { 17 | 'selected': self.lookup_val is None, 18 | 'query_string': changelist.get_query_string( 19 | remove=[ 20 | self.lookup_kwarg, 21 | self.lookup_kwarg_isnull 22 | ] 23 | ), 24 | 'display': _('All') 25 | } 26 | none_title = '' 27 | for lookup, title in self.field.flatchoices: 28 | lookup = self.field.get_prep_value(lookup) 29 | 30 | if lookup is None: 31 | none_title = title 32 | continue 33 | yield { 34 | 'selected': str(lookup) == self.lookup_val, 35 | 'query_string': changelist.get_query_string( 36 | {self.lookup_kwarg: lookup}, 37 | [self.lookup_kwarg_isnull] 38 | ), 39 | 'display': title, 40 | } 41 | if none_title: 42 | yield { 43 | 'selected': bool(self.lookup_val_isnull), 44 | 'query_string': changelist.get_query_string( 45 | {self.lookup_kwarg_isnull: 'True'}, 46 | [self.lookup_kwarg] 47 | ), 48 | 'display': none_title, 49 | } 50 | 51 | def queryset(self, request, queryset): 52 | query = { 53 | field_name: self.field.to_enum_value(value) 54 | for field_name, value in self.used_parameters.items() 55 | } 56 | 57 | return queryset.filter(**query) 58 | 59 | 60 | def register_enum_choice_list_filter(): 61 | register_filter = getattr( 62 | settings, 63 | 'DJANGO_ENUM_CHOICES_REGISTER_LIST_FILTER', 64 | False 65 | ) 66 | 67 | if register_filter: 68 | admin.FieldListFilter.register( 69 | lambda f: isinstance(f, EnumChoiceField), 70 | EnumChoiceListFilter, 71 | take_priority=True 72 | ) 73 | -------------------------------------------------------------------------------- /django_enum_choices/choice_builders.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Tuple 3 | 4 | 5 | def value_value(enumeration: Enum) -> Tuple[str, str]: 6 | return ( 7 | str(enumeration.value), 8 | str(enumeration.value) 9 | ) 10 | 11 | 12 | def attribute_attribute(enumeration: Enum) -> Tuple[str, str]: 13 | return ( 14 | str(enumeration.name), 15 | str(enumeration.name) 16 | ) 17 | 18 | 19 | def attribute_value(enumeration: Enum) -> Tuple[str, str]: 20 | return ( 21 | str(enumeration.name), 22 | str(enumeration.value) 23 | ) 24 | 25 | 26 | def value_attribute(enumeration: Enum) -> Tuple[str, str]: 27 | return ( 28 | str(enumeration.value), 29 | str(enumeration.name) 30 | ) 31 | -------------------------------------------------------------------------------- /django_enum_choices/exceptions.py: -------------------------------------------------------------------------------- 1 | class EnumChoiceFieldException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /django_enum_choices/fields.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Tuple, Type 3 | 4 | from django.db.models import CharField 5 | from django.core.exceptions import ValidationError 6 | from django.core.validators import MaxLengthValidator 7 | from django.utils.translation import gettext as _ 8 | from django.utils.text import capfirst 9 | 10 | from .exceptions import EnumChoiceFieldException 11 | from .validators import EnumValueMaxLengthValidator 12 | from .choice_builders import value_value 13 | from .utils import as_choice_builder, value_from_built_choice, build_enum_choices 14 | from .forms import EnumChoiceField as EnumChoiceFormField 15 | 16 | 17 | class EnumChoiceField(CharField): 18 | description = _('EnumChoiceField for %(enum_class)') 19 | 20 | def __init__(self, enum_class: Type[Enum], choice_builder=value_value, **kwargs): 21 | if not issubclass(enum_class, Enum): 22 | raise EnumChoiceFieldException( 23 | _('`enum_class` argument must be a child of `Enum`') 24 | ) 25 | 26 | self.enum_class = enum_class 27 | self.choice_builder = self._get_choice_builder(choice_builder) 28 | 29 | # Saving original for proper deconstruction 30 | self._original_choice_builder = choice_builder 31 | 32 | built_choices = self.build_choices() 33 | 34 | # `choices` is passed to `__init__` when migrations are generated 35 | # `choices` should not be passed when normally initializing the field 36 | self._passed_choices = kwargs.get( 37 | 'choices', 38 | built_choices 39 | ) # Saved for deconstruction 40 | 41 | kwargs['choices'] = built_choices 42 | 43 | calculated_max_length = self._calculate_max_length(**kwargs) 44 | 45 | kwargs.setdefault('max_length', calculated_max_length) 46 | 47 | super().__init__(**kwargs) 48 | 49 | # Removing `MaxLengthValidator` instances and adding 50 | # an `EnumValueMaxLengthValidator` instance 51 | self.validators = [ 52 | validator for validator in self.validators 53 | if not isinstance(validator, MaxLengthValidator) 54 | ] 55 | 56 | self.validators.append( 57 | EnumValueMaxLengthValidator( 58 | value_builder=self.get_prep_value, 59 | limit_value=kwargs['max_length'] 60 | ) 61 | ) 62 | 63 | def _get_choice_builder(self, choice_builder): 64 | if not callable(choice_builder): 65 | raise EnumChoiceFieldException( 66 | _('`{}.choice_builder` must be a callable.'.format( 67 | self.enum_class.__name__ 68 | )) 69 | ) 70 | 71 | return as_choice_builder(choice_builder) 72 | 73 | def build_choices(self) -> Tuple[Tuple[str]]: 74 | return build_enum_choices( 75 | self.enum_class, 76 | self.choice_builder 77 | ) 78 | 79 | def _calculate_max_length(self, **kwargs) -> int: 80 | max_choice_length = max(len(choice) for choice, _ in kwargs['choices']) 81 | 82 | return max_choice_length 83 | 84 | def to_enum_value(self, value): 85 | if value is None: 86 | return 87 | 88 | for choice in self.enum_class: 89 | # Check if the value from the built choice matches the passed one 90 | if value_from_built_choice(self.choice_builder(choice)) == value: 91 | return choice 92 | 93 | raise ValidationError( 94 | _('Value {} not found in {}'.format(value, self.enum_class)) 95 | ) 96 | 97 | def get_prep_value(self, value): 98 | return value_from_built_choice( 99 | self.choice_builder(value) 100 | ) 101 | 102 | def from_db_value(self, value, expression, connection, *args): 103 | # Accepting `*args` because Django 1.11 calls with an extra 104 | # `context` argument 105 | 106 | return self.to_enum_value(value) 107 | 108 | def to_python(self, value): 109 | if isinstance(value, self.enum_class): 110 | return value 111 | 112 | return self.to_enum_value(value) 113 | 114 | def deconstruct(self): 115 | name, path, args, kwargs = super().deconstruct() 116 | 117 | kwargs['choices'] = self._passed_choices 118 | 119 | if self.enum_class: 120 | kwargs['enum_class'] = self.enum_class 121 | 122 | if self.choice_builder: 123 | kwargs['choice_builder'] = self._original_choice_builder 124 | 125 | return name, path, args, kwargs 126 | 127 | def validate(self, value, *args, **kwargs): 128 | """ 129 | Runs standard `django.db.models.Field` validation 130 | with different logic for choices validation 131 | """ 132 | 133 | if not self.editable: 134 | return 135 | 136 | if value is None and not self.null: 137 | raise ValidationError(self.error_messages['null'], code='null') 138 | 139 | if not self.blank and value in self.empty_values: 140 | raise ValidationError(self.error_messages['blank'], code='blank') 141 | 142 | enum_value = value 143 | 144 | if not isinstance(enum_value, self.enum_class): 145 | try: 146 | enum_value = self.to_enum_value(value) 147 | 148 | if enum_value not in self.enum_class: 149 | raise ValidationError( 150 | self.error_messages['invalid_choice'], 151 | code='invalid_choice', 152 | params={'value': value} 153 | ) 154 | except ValidationError: 155 | raise ValidationError( 156 | self.error_messages['invalid_choice'], 157 | code='invalid_choice', 158 | params={'value': value} 159 | ) 160 | 161 | def value_to_string(self, obj): 162 | value = self.value_from_object(obj) 163 | return self.get_prep_value(value) 164 | 165 | @property 166 | def flatchoices(self): 167 | """ 168 | Django admin uses `flatchoices` to generate a value under the 169 | fields column in their list display. By default it calculates 170 | `flatchoices` as a Tuple[Tuple[str]], 171 | I.E: `(('choice1', 'choice1'), ('choice2', 'choice2')) 172 | It accesses the readable value by using the actual value 173 | which is an enumeration instance in our case. 174 | Since that does not match inside the original `flatchoices` 175 | it sets the display value to `-`. 176 | """ 177 | 178 | flatchoices = super()._get_flatchoices() 179 | 180 | return [ 181 | (self.to_enum_value(choice), readable) 182 | for choice, readable in flatchoices 183 | ] 184 | 185 | def formfield(self, **kwargs): 186 | """ 187 | Uses `django.forms.Field`'s parameter generation with 188 | `EnumChoiceField` specific updates. 189 | """ 190 | 191 | defaults = { 192 | 'required': not self.blank, 193 | 'label': capfirst(self.verbose_name), 194 | 'help_text': self.help_text, 195 | 'enum_class': self.enum_class, 196 | 'choice_builder': self.choice_builder 197 | } 198 | 199 | if self.has_default(): 200 | if callable(self.default): 201 | defaults['initial'] = self.default 202 | defaults['show_hidden_initial'] = True 203 | else: 204 | defaults['initial'] = self.get_default() 205 | 206 | include_blank = (self.blank or 207 | not (self.has_default() or 'initial' in kwargs)) 208 | defaults['choices'] = self.get_choices(include_blank=include_blank) 209 | 210 | # Many of the subclass-specific formfield arguments (min_value, 211 | # max_value) don't apply for choice fields, so be sure to only pass 212 | # the values that TypedChoiceField will understand. 213 | for k in list(kwargs): 214 | if k not in ( 215 | 'coerce', 'choices', 'required', 'enum_class', 'disabled', 216 | 'choice_builder, ''widget', 'label', 'initial', 'help_text', 217 | 'error_messages', 'show_hidden_initial', 218 | ): 219 | del kwargs[k] 220 | 221 | defaults.update(kwargs) 222 | 223 | return EnumChoiceFormField(**defaults) 224 | -------------------------------------------------------------------------------- /django_enum_choices/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters as filters 2 | 3 | from .fields import EnumChoiceField 4 | from .forms import EnumChoiceField as EnumChoiceFormField 5 | from .choice_builders import value_value 6 | 7 | 8 | class EnumChoiceFilter(filters.ChoiceFilter): 9 | field_class = EnumChoiceFormField 10 | 11 | def __init__(self, enum_class, choice_builder=value_value, *args, **kwargs): 12 | super().__init__( 13 | enum_class=enum_class, 14 | choice_builder=choice_builder, 15 | *args, 16 | **kwargs 17 | ) 18 | 19 | 20 | class EnumChoiceFilterSetMixin: 21 | """ 22 | `django-filter` has specific logic for handling fields with `choices`. 23 | We need to override `filter_for_lookup` to return an `EnumChoiceFilter` 24 | before `django-filter` returns a `ChoiceFilter` as the `filter_class` 25 | for the `EnumChoiceField` instances in the model. 26 | """ 27 | 28 | @classmethod 29 | def filter_for_lookup(cls, field, lookup_type): 30 | if isinstance(field, EnumChoiceField): 31 | return EnumChoiceFilter, { 32 | 'enum_class': field.enum_class, 33 | 'choice_builder': field.choice_builder 34 | } 35 | 36 | return super().filter_for_lookup(field, lookup_type) 37 | -------------------------------------------------------------------------------- /django_enum_choices/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .choice_builders import value_value 4 | from .utils import as_choice_builder, value_from_built_choice, build_enum_choices 5 | 6 | 7 | class EnumChoiceField(forms.ChoiceField): 8 | def __init__(self, enum_class, choice_builder=value_value, **kwargs): 9 | self.enum_class = enum_class 10 | self.choice_builder = as_choice_builder(choice_builder) 11 | 12 | # Special case where choices are autogenerated from `fields.EnumChoiceField.formfield` 13 | # The `choices` argument is not to be used outside of `fields.EnumChoiceField.formfield` 14 | if 'choices' not in kwargs: 15 | kwargs['choices'] = self.build_choices() 16 | 17 | super().__init__(**kwargs) 18 | 19 | def build_choices(self): 20 | return build_enum_choices( 21 | self.enum_class, 22 | self.choice_builder 23 | ) 24 | 25 | def _enum_from_input_value(self, value): 26 | for choice in self.enum_class: 27 | if value_from_built_choice(self.choice_builder(choice)) == value: 28 | return choice 29 | 30 | def to_python(self, value): 31 | if value is None: 32 | return 33 | 34 | return self._enum_from_input_value(value) or value 35 | 36 | def prepare_value(self, value): 37 | if not value or isinstance(value, self.enum_class): 38 | return value_from_built_choice( 39 | self.choice_builder(value) 40 | ) 41 | 42 | return value 43 | 44 | def valid_value(self, value): 45 | return isinstance(value, self.enum_class) and value in self.enum_class 46 | -------------------------------------------------------------------------------- /django_enum_choices/serializers.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | 3 | from rest_framework import serializers 4 | from rest_framework.utils.field_mapping import get_field_kwargs 5 | 6 | from .fields import EnumChoiceField as ModelEnumChoiceField 7 | from .choice_builders import value_value 8 | from .utils import as_choice_builder, value_from_built_choice 9 | 10 | NO_KEY_MSG = _('Key {failing_key} is not a valid {enum_class_name}') 11 | NOT_A_LIST_MSG = _('Expected a list of items but got type "{input_type}".') 12 | EMPTY_MSG = _('This selection may not be empty.') 13 | 14 | 15 | class EnumChoiceField(serializers.Field): 16 | default_error_messages = { 17 | 'non_existent_key': NO_KEY_MSG 18 | } 19 | 20 | def __init__(self, enum_class, choice_builder=value_value, **kwargs): 21 | super().__init__(**kwargs) 22 | self.enum_class = enum_class 23 | self.choice_builder = as_choice_builder(choice_builder) 24 | 25 | def to_representation(self, value): 26 | return value_from_built_choice( 27 | self.choice_builder(value) 28 | ) 29 | 30 | def to_internal_value(self, value): 31 | for choice in self.enum_class: 32 | if value_from_built_choice(self.choice_builder(choice)) == value: 33 | return choice 34 | 35 | self.fail( 36 | 'non_existent_key', 37 | failing_key=value, 38 | enum_class_name=self.enum_class.__name__ 39 | ) 40 | 41 | 42 | class MultipleEnumChoiceField(EnumChoiceField): 43 | default_error_messages = { 44 | 'non_existent_key': NO_KEY_MSG, 45 | 'not_a_list': NOT_A_LIST_MSG, 46 | 'empty': EMPTY_MSG 47 | } 48 | 49 | def __init__(self, *args, **kwargs): 50 | self.allow_empty = kwargs.pop('allow_empty', False) 51 | 52 | super().__init__(*args, **kwargs) 53 | 54 | self.default_error_messages = { 55 | **self.default_error_messages, 56 | 57 | } 58 | 59 | def to_internal_value(self, data): 60 | if not isinstance(data, list): 61 | self.fail('not_a_list', input_type=type(data).__name__) 62 | 63 | if not self.allow_empty and not data: 64 | self.fail('empty') 65 | 66 | return [ 67 | super(MultipleEnumChoiceField, self).to_internal_value(value) 68 | for value in data 69 | ] 70 | 71 | def to_representation(self, data): 72 | return [ 73 | super(MultipleEnumChoiceField, self).to_representation(value) 74 | for value in data 75 | ] 76 | 77 | 78 | class EnumChoiceModelSerializerMixin: 79 | def __init__(self, *args, **kwargs): 80 | super().__init__(*args, **kwargs) 81 | 82 | self.serializer_field_mapping[ModelEnumChoiceField] = EnumChoiceField 83 | 84 | def build_standard_field(self, field_name, model_field): 85 | """ 86 | By default `ModelSerializer.build_standard_field` coerces any field 87 | that has a model field with choices to `ChoiceField` wich returns the 88 | value directly. 89 | 90 | Since enum values resemble `EnumClass.ENUM_INSTANCE` 91 | they won't be able to be encoded by the JSONEncoder when being passed 92 | to a `Response`. 93 | """ 94 | 95 | if isinstance(model_field, ModelEnumChoiceField): 96 | # These are kwargs, generated by `get_field_kwargs` 97 | # but are not needed for our field. 98 | # `model_field` is used only in children of DRF's `ModelField` 99 | # `choices` is not used because we use `field.enum_class` to validate the choice 100 | # `max_length` is generated from the model field's max_length and we don't use it 101 | dump_kwargs = ('model_field', 'choices', 'max_length', 'allow_blank') 102 | 103 | initial_kwargs = { 104 | 'enum_class': model_field.enum_class, 105 | 'choice_builder': model_field.choice_builder, 106 | **get_field_kwargs(field_name, model_field) 107 | } 108 | finalized_kwargs = { 109 | key: value for key, value in initial_kwargs.items() 110 | if key not in dump_kwargs 111 | } 112 | 113 | return EnumChoiceField, finalized_kwargs 114 | 115 | return super().build_standard_field(field_name, model_field) 116 | -------------------------------------------------------------------------------- /django_enum_choices/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackSoftware/django-enum-choices/ed2d06500a16a97e3bacfbdd2122e45203ab3209/django_enum_choices/tests/__init__.py -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackSoftware/django-enum-choices/ed2d06500a16a97e3bacfbdd2122e45203ab3209/django_enum_choices/tests/e2e/e2e/e2e/__init__.py -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/e2e/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for e2e project. 3 | 4 | Generated by 'django-admin startproject' using Django 2.2.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/2.2/ref/settings/ 11 | """ 12 | 13 | import os 14 | import environ 15 | 16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 18 | 19 | 20 | env = environ.Env() 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = 'hn*r)$lq6rs3o*#$=6s#-+cq5ak$a!b^p7oh9sox#zw!^h2&0^' 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = True 30 | 31 | ALLOWED_HOSTS = [] 32 | 33 | 34 | # Application definition 35 | 36 | INSTALLED_APPS = [ 37 | 'django.contrib.admin', 38 | 'django.contrib.auth', 39 | 'django.contrib.contenttypes', 40 | 'django.contrib.sessions', 41 | 'django.contrib.messages', 42 | 'django.contrib.staticfiles', 43 | 'test_models.apps.ModelsConfig' 44 | ] 45 | 46 | MIDDLEWARE = [ 47 | 'django.middleware.security.SecurityMiddleware', 48 | 'django.contrib.sessions.middleware.SessionMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'e2e.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'e2e.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 79 | 80 | DATABASES = { 81 | 'default': env.db('DATABASE_URL', default='postgres:///django_enum_choices_e2e') 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 91 | }, 92 | { 93 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 94 | }, 95 | { 96 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 97 | }, 98 | { 99 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 106 | 107 | LANGUAGE_CODE = 'en-us' 108 | 109 | TIME_ZONE = 'UTC' 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | 118 | # Static files (CSS, JavaScript, Images) 119 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 120 | 121 | STATIC_URL = '/static/' 122 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/e2e/urls.py: -------------------------------------------------------------------------------- 1 | """e2e URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/e2e/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for e2e project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'e2e.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'e2e.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackSoftware/django-enum-choices/ed2d06500a16a97e3bacfbdd2122e45203ab3209/django_enum_choices/tests/e2e/e2e/test_models/__init__.py -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ModelsConfig(AppConfig): 5 | name = 'test_models' 6 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-08-24 17:31 2 | 3 | from django.db import migrations, models 4 | import django_enum_choices.choice_builders 5 | import django_enum_choices.fields 6 | import test_models.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='SomeModel', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('choices', django_enum_choices.fields.EnumChoiceField(choice_builder=django_enum_choices.choice_builders.value_value, choices=[('a', 'a'), ('aa', 'aa'), ('aaa', 'aaa')], enum_class=test_models.models.ChoicesA, max_length=3)), 22 | ('other_choices', django_enum_choices.fields.EnumChoiceField(choice_builder=django_enum_choices.choice_builders.value_value, choices=[('a', 'a')], enum_class=test_models.models.ChoicesB, max_length=1)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackSoftware/django-enum-choices/ed2d06500a16a97e3bacfbdd2122e45203ab3209/django_enum_choices/tests/e2e/e2e/test_models/migrations/__init__.py -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/models.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from django.db import models 4 | 5 | from django_enum_choices.fields import EnumChoiceField 6 | 7 | 8 | class ChoicesA(Enum): 9 | A = 'a' 10 | B = 'aa' 11 | C = 'aaa' 12 | #TEST_1 D = 'aaaa' 13 | 14 | 15 | class ChoicesB(Enum): 16 | A = 'a' 17 | #TEST_2 B = 'b' 18 | 19 | 20 | class SomeModel(models.Model): 21 | choices = EnumChoiceField(ChoicesA) 22 | 23 | other_choices = EnumChoiceField(ChoicesB) 24 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/e2e/test_models/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /django_enum_choices/tests/e2e/tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from subprocess import check_output 4 | import shlex 5 | 6 | import psycopg2 7 | from psycopg2.extras import DictCursor 8 | 9 | 10 | class Database: 11 | def get_column_size(self): 12 | cursor = self.connection.cursor( 13 | cursor_factory=DictCursor 14 | ) 15 | 16 | query = """ 17 | SELECT character_maximum_length AS max_length 18 | FROM INFORMATION_SCHEMA.COLUMNS 19 | WHERE table_name = 'test_models_somemodel' and column_name = 'choices' 20 | """ 21 | 22 | cursor.execute(query) 23 | 24 | result = cursor.fetchone() 25 | 26 | return result['max_length'] 27 | 28 | def open(self): 29 | database_url = os.environ.get('DATABASE_URL', 'postgres:///django_enum_choices_e2e') 30 | self.connection = psycopg2.connect(database_url) 31 | 32 | def close(self): 33 | self.connection.close() 34 | 35 | 36 | class Setuper: 37 | def __init__(self, database, test_case): 38 | self.cwd = os.getcwd() 39 | self.database = database 40 | self.test_case = test_case 41 | 42 | def get_base_project_location(self): 43 | return f'{self.cwd}/e2e' 44 | 45 | def get_new_project_location(self): 46 | return f'{self.cwd}/e2e_testing' 47 | 48 | def get_models_path(self): 49 | return f'{self.get_new_project_location()}/test_models/models.py' 50 | 51 | def call(self, command): 52 | result = check_output(shlex.split(command)).decode('utf-8') 53 | 54 | return result 55 | 56 | def before_setup(self): 57 | if not os.environ.get('CI', False): 58 | self.call('dropdb --if-exists django_enum_choices_e2e') 59 | self.call('createdb django_enum_choices_e2e') 60 | 61 | self.database.open() 62 | 63 | base_project_location = self.get_base_project_location() 64 | 65 | os.chdir(base_project_location) 66 | self.call('python manage.py migrate') 67 | 68 | def setup(self): 69 | base_project_location = self.get_base_project_location() 70 | new_project_location = self.get_new_project_location() 71 | 72 | if os.path.exists(new_project_location): 73 | shutil.rmtree(new_project_location) 74 | 75 | shutil.copytree( 76 | src=base_project_location, 77 | dst=new_project_location 78 | ) 79 | 80 | models = self.get_models_path() 81 | 82 | with open(models, 'r') as models_file: 83 | models_content = models_file.read() 84 | 85 | models_content = models_content.replace(f' #{self.test_case}', '') 86 | 87 | with open(models, 'w') as models_file: 88 | models_file.write(models_content) 89 | 90 | def after_setup(self): 91 | new_project_location = self.get_new_project_location() 92 | 93 | if os.path.exists(new_project_location): 94 | shutil.rmtree(new_project_location) 95 | 96 | os.chdir(self.cwd) 97 | self.database.close() 98 | 99 | 100 | def test_case_1(): 101 | """ 102 | If we add new choice, which changes the max length, we should have a migration, 103 | plus the column in postgres should be affected. 104 | """ 105 | print('Running test case 1') 106 | 107 | database = Database() 108 | 109 | setuper = Setuper(database, 'TEST_1') 110 | 111 | setuper.before_setup() 112 | assert database.get_column_size() == 3, 'Initial max length should be 3' 113 | 114 | setuper.setup() 115 | 116 | new_project_location = setuper.get_new_project_location() 117 | 118 | os.chdir(new_project_location) 119 | 120 | result = setuper.call('python manage.py makemigrations') 121 | 122 | assert 'No changes detected' not in result, 'There should be new migrations' 123 | 124 | print(result) 125 | 126 | print(setuper.call('python manage.py migrate')) 127 | 128 | assert database.get_column_size() == 4, 'Migration should increase the max length to 4' 129 | 130 | setuper.after_setup() 131 | 132 | 133 | def test_case_2(): 134 | """ 135 | If we add new choice, but within the existing max length, we should have a new migration. 136 | """ 137 | print('Running test case 2') 138 | 139 | database = Database() 140 | 141 | setuper = Setuper(database, 'TEST_2') 142 | setuper.before_setup() 143 | setuper.setup() 144 | 145 | new_project_location = setuper.get_new_project_location() 146 | 147 | os.chdir(new_project_location) 148 | 149 | result = setuper.call('python manage.py makemigrations') 150 | 151 | assert 'No changes detected' not in result, 'There should be new migrations' 152 | 153 | print(result) 154 | 155 | print(setuper.call('python manage.py migrate')) 156 | 157 | setuper.after_setup() 158 | 159 | 160 | test_case_1() 161 | test_case_2() 162 | -------------------------------------------------------------------------------- /django_enum_choices/tests/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import environ 4 | 5 | env = environ.Env() 6 | 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "django.db.backends.sqlite3", 10 | "NAME": ":memory:" 11 | }, 12 | 'postgresql': env.db('DATABASE_URL', default='postgres:///django_enum_choices') 13 | 14 | } 15 | 16 | DATABASE_ROUTERS = ['tests.testapp.database_routers.DataBaseRouter'] 17 | 18 | INSTALLED_APPS = [ 19 | "django.contrib.admin", 20 | "django.contrib.auth", 21 | "django.contrib.contenttypes", 22 | "django.contrib.sessions", 23 | "django.contrib.sites", 24 | "tests.testapp.apps.TestAppConfig" 25 | ] 26 | 27 | SITE_ID = 1 28 | 29 | SECRET_KEY = "test" 30 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_admin_filter.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, RequestFactory, override_settings 2 | from django.contrib import admin 3 | from django.contrib.auth.models import User 4 | 5 | from django_enum_choices.admin import EnumChoiceListFilter, register_enum_choice_list_filter 6 | 7 | from .testapp.models import StringEnumeratedModel 8 | from .testapp.enumerations import CharTestEnum 9 | 10 | 11 | class StringEnumAdmin(admin.ModelAdmin): 12 | list_filter = ('enumeration', ) 13 | 14 | 15 | class EnumChoiceListFilterTests(TestCase): 16 | request_factory = RequestFactory() 17 | 18 | def setUp(self): 19 | self.user = User.objects.create_superuser('user', 'user@example.com', 'password') 20 | 21 | # Django 1.11 compatibillity 22 | def get_changelist_instance(self, request, model, modeladmin): 23 | changelist_args = [ 24 | request, model, modeladmin.list_display, 25 | modeladmin.list_display_links, modeladmin.list_filter, 26 | modeladmin.date_hierarchy, modeladmin.search_fields, 27 | modeladmin.list_select_related, modeladmin.list_per_page, 28 | modeladmin.list_max_show_all, modeladmin.list_editable, modeladmin, 29 | ] 30 | 31 | try: 32 | changelist = modeladmin.get_changelist(request)(*changelist_args) 33 | except TypeError: 34 | # Django < 2 does not accept `sortable_by` in `ChangeList` 35 | changelist_args.append(modeladmin.sortable_by) 36 | changelist = modeladmin.get_changelist(request)(*changelist_args) 37 | 38 | return changelist 39 | 40 | @override_settings(DJANGO_ENUM_CHOICES_REGISTER_LIST_FILTER=True) 41 | def test_list_filter_instance_is_enumchoicelistfilter_when_registered(self): 42 | register_enum_choice_list_filter() 43 | 44 | modeladmin = StringEnumAdmin( 45 | StringEnumeratedModel, 46 | admin.site 47 | ) 48 | request = self.request_factory.get('/', {}) 49 | request.user = self.user 50 | 51 | changelist = self.get_changelist_instance(request, StringEnumeratedModel, modeladmin) 52 | filterspec = changelist.get_filters(request)[0][0] 53 | 54 | self.assertIsInstance(filterspec, EnumChoiceListFilter) 55 | 56 | @override_settings(DJANGO_ENUM_CHOICES_REGISTER_LIST_FILTER=True) 57 | def test_list_filter_lookup_kwarg_is_correct(self): 58 | register_enum_choice_list_filter() 59 | 60 | modeladmin = StringEnumAdmin( 61 | StringEnumeratedModel, 62 | admin.site 63 | ) 64 | request = self.request_factory.get('/', {}) 65 | request.user = self.user 66 | 67 | changelist = self.get_changelist_instance(request, StringEnumeratedModel, modeladmin) 68 | filterspec = changelist.get_filters(request)[0][0] 69 | 70 | self.assertEqual(filterspec.lookup_kwarg, 'enumeration__exact') 71 | 72 | @override_settings(DJANGO_ENUM_CHOICES_REGISTER_LIST_FILTER=True) 73 | def test_list_filter_queryset_filters_objects_correctly(self): 74 | StringEnumeratedModel.objects.create(enumeration=CharTestEnum.FIRST) 75 | StringEnumeratedModel.objects.create(enumeration=CharTestEnum.SECOND) 76 | StringEnumeratedModel.objects.create(enumeration=CharTestEnum.THIRD) 77 | 78 | modeladmin = StringEnumAdmin( 79 | StringEnumeratedModel, 80 | admin.site 81 | ) 82 | 83 | for enumeration in CharTestEnum: 84 | request = self.request_factory.get('/', {'enumeration__exact': enumeration.value}) 85 | request.user = self.user 86 | 87 | changelist = self.get_changelist_instance(request, StringEnumeratedModel, modeladmin) 88 | 89 | self.assertEqual(changelist.queryset.count(), 1) 90 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_filters.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_enum_choices.filters import EnumChoiceFilter 4 | from django_enum_choices.forms import EnumChoiceField as EnumChoiceFormField 5 | 6 | from .testapp.enumerations import CharTestEnum 7 | 8 | 9 | class EnumChoiceFilterTests(TestCase): 10 | def test_filter_instance_extra_has_enum_class_and_choice_builder(self): 11 | instance = EnumChoiceFilter(enum_class=CharTestEnum) 12 | 13 | self.assertEqual(instance.extra['enum_class'], CharTestEnum) 14 | self.assertIsNotNone(instance.extra['choice_builder']) 15 | 16 | def test_corresponding_field_is_of_correct_type(self): 17 | instance = EnumChoiceFilter(enum_class=CharTestEnum) 18 | form_field = instance.field 19 | 20 | self.assertIsInstance(form_field, EnumChoiceFormField) 21 | 22 | def test_corresponding_field_has_enum_class_and_choice_builder(self): 23 | instance = EnumChoiceFilter(enum_class=CharTestEnum) 24 | 25 | form_field = instance.field 26 | 27 | self.assertEqual(form_field.enum_class, CharTestEnum) 28 | self.assertIsNotNone(form_field.choice_builder) 29 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_filterset_integrations.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | import django_filters as filters 4 | from django_filters import rest_framework as drf_filters 5 | 6 | from django_enum_choices.filters import EnumChoiceFilter, EnumChoiceFilterSetMixin 7 | 8 | from .testapp.enumerations import CharTestEnum 9 | from .testapp.models import ( 10 | StringEnumeratedModel, 11 | CustomChoiceBuilderEnumeratedModel, 12 | custom_choice_builder 13 | ) 14 | 15 | 16 | class FilterSetIntegrationTests(TestCase): 17 | class ExplicitFilterSet(filters.FilterSet): 18 | enumeration = EnumChoiceFilter(CharTestEnum) 19 | 20 | class ExplicitChoiceBuilderFilterSet(filters.FilterSet): 21 | enumeration = EnumChoiceFilter(CharTestEnum, choice_builder=custom_choice_builder) 22 | 23 | class ImplicitFilterSet(EnumChoiceFilterSetMixin, filters.FilterSet): 24 | class Meta: 25 | model = StringEnumeratedModel 26 | fields = ['enumeration'] 27 | 28 | class ImplicitChoiceBuilderFilterSet(EnumChoiceFilterSetMixin, filters.FilterSet): 29 | class Meta: 30 | model = CustomChoiceBuilderEnumeratedModel 31 | fields = ['enumeration'] 32 | 33 | def setUp(self): 34 | for choice in CharTestEnum: 35 | StringEnumeratedModel.objects.create( 36 | enumeration=choice 37 | ) 38 | 39 | def test_explicitly_declarated_field_filters_correctly(self): 40 | filters = { 41 | 'enumeration': 'first' 42 | } 43 | filterset = self.ExplicitFilterSet(filters, StringEnumeratedModel.objects.all()) 44 | 45 | self.assertEqual(filterset.qs.count(), 1) 46 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 47 | 48 | def test_explicitly_declarated_field_filters_correctly_with_custom_choice_builder(self): 49 | filters = { 50 | 'enumeration': 'Custom_first' 51 | } 52 | filterset = self.ExplicitChoiceBuilderFilterSet(filters, StringEnumeratedModel.objects.all()) 53 | 54 | self.assertEqual(filterset.qs.count(), 1) 55 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 56 | 57 | def test_filter_by_non_valid_choice_returns_full_queryset(self): 58 | filters = { 59 | 'enumeration': 'invalid' 60 | } 61 | filterset = self.ExplicitChoiceBuilderFilterSet(filters, StringEnumeratedModel.objects.all()) 62 | 63 | self.assertEqual(filterset.qs.count(), StringEnumeratedModel.objects.count()) 64 | 65 | def test_implicit_filter_filters_correctly(self): 66 | filters = { 67 | 'enumeration': 'first' 68 | } 69 | filterset = self.ImplicitFilterSet(filters) 70 | 71 | self.assertEqual(filterset.qs.count(), 1) 72 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 73 | 74 | def test_implicit_filter_filters_correctly_on_field_with_custom_choice_builder(self): 75 | for choice in CharTestEnum: 76 | CustomChoiceBuilderEnumeratedModel.objects.create(enumeration=choice) 77 | 78 | filters = { 79 | 'enumeration': 'Custom_first' 80 | } 81 | filterset = self.ImplicitChoiceBuilderFilterSet(filters) 82 | 83 | self.assertEqual(filterset.qs.count(), 1) 84 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 85 | 86 | 87 | class FilterSetDRFIntegrationTests(TestCase): 88 | class ExplicitFilterSet(drf_filters.FilterSet): 89 | enumeration = EnumChoiceFilter(CharTestEnum) 90 | 91 | class ExplicitChoiceBuilderFilterSet(drf_filters.FilterSet): 92 | enumeration = EnumChoiceFilter(CharTestEnum, choice_builder=custom_choice_builder) 93 | 94 | class ImplicitFilterSet(EnumChoiceFilterSetMixin, drf_filters.FilterSet): 95 | class Meta: 96 | model = StringEnumeratedModel 97 | fields = ['enumeration'] 98 | 99 | class ImplicitChoiceBuilderFilterSet(EnumChoiceFilterSetMixin, drf_filters.FilterSet): 100 | class Meta: 101 | model = CustomChoiceBuilderEnumeratedModel 102 | fields = ['enumeration'] 103 | 104 | def setUp(self): 105 | for choice in CharTestEnum: 106 | StringEnumeratedModel.objects.create( 107 | enumeration=choice 108 | ) 109 | 110 | def test_explicitly_declarated_field_filters_correctly(self): 111 | filters = { 112 | 'enumeration': 'first' 113 | } 114 | filterset = self.ExplicitFilterSet(filters, StringEnumeratedModel.objects.all()) 115 | 116 | self.assertEqual(filterset.qs.count(), 1) 117 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 118 | 119 | def test_explicitly_declarated_field_filters_correctly_with_custom_choice_builder(self): 120 | filters = { 121 | 'enumeration': 'Custom_first' 122 | } 123 | filterset = self.ExplicitChoiceBuilderFilterSet(filters, StringEnumeratedModel.objects.all()) 124 | 125 | self.assertEqual(filterset.qs.count(), 1) 126 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 127 | 128 | def test_filter_by_non_valid_choice_returns_full_queryset(self): 129 | filters = { 130 | 'enumeration': 'invalid' 131 | } 132 | filterset = self.ExplicitChoiceBuilderFilterSet(filters, StringEnumeratedModel.objects.all()) 133 | 134 | self.assertEqual(filterset.qs.count(), StringEnumeratedModel.objects.count()) 135 | 136 | def test_implicit_filter_filters_correctly(self): 137 | filters = { 138 | 'enumeration': 'first' 139 | } 140 | filterset = self.ImplicitFilterSet(filters) 141 | 142 | self.assertEqual(filterset.qs.count(), 1) 143 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 144 | 145 | def test_implicit_filter_filters_correctly_on_field_with_custom_choice_builder(self): 146 | for choice in CharTestEnum: 147 | CustomChoiceBuilderEnumeratedModel.objects.create(enumeration=choice) 148 | 149 | filters = { 150 | 'enumeration': 'Custom_first' 151 | } 152 | filterset = self.ImplicitChoiceBuilderFilterSet(filters) 153 | 154 | self.assertEqual(filterset.qs.count(), 1) 155 | self.assertEqual(filterset.qs.first().enumeration, CharTestEnum.FIRST) 156 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_form_fields.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django_enum_choices.forms import EnumChoiceField 4 | 5 | from .testapp.enumerations import CharTestEnum 6 | 7 | 8 | class FormFieldTests(TestCase): 9 | def test_field_instance_creates_choices_correctly(self): 10 | instance = EnumChoiceField(CharTestEnum) 11 | choices = instance.build_choices() 12 | 13 | self.assertEqual( 14 | choices, 15 | [('first', 'first'), 16 | ('second', 'second'), 17 | ('third', 'third')] 18 | ) 19 | 20 | def test_field_instance_creates_choices_correctly_with_custom_choice_builder(self): 21 | def choice_builder(choice): 22 | return 'Custom_' + choice.value, choice.value 23 | 24 | instance = EnumChoiceField(CharTestEnum, choice_builder=choice_builder) 25 | choices = instance.build_choices() 26 | 27 | self.assertEqual( 28 | choices, 29 | [('Custom_first', 'first'), 30 | ('Custom_second', 'second'), 31 | ('Custom_third', 'third')] 32 | ) 33 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_form_integrations.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django import forms 3 | 4 | from django_enum_choices.forms import EnumChoiceField 5 | 6 | from .testapp.enumerations import CharTestEnum 7 | from .testapp.models import StringEnumeratedModel, CustomChoiceBuilderEnumeratedModel 8 | 9 | 10 | def custom_choice_builder(choice): 11 | return 'Custom_' + choice.value, choice.value 12 | 13 | 14 | class FormIntegrationTests(TestCase): 15 | class StandardEnumForm(forms.Form): 16 | enumerated_field = EnumChoiceField(CharTestEnum) 17 | 18 | class CustomChoiceBuilderEnumForm(forms.Form): 19 | enumerated_field = EnumChoiceField( 20 | CharTestEnum, 21 | choice_builder=custom_choice_builder 22 | ) 23 | 24 | def test_value_is_cleaned_successfully_when_value_is_valid(self): 25 | form = self.StandardEnumForm({ 26 | 'enumerated_field': 'first' 27 | }) 28 | 29 | self.assertTrue(form.is_valid()) 30 | self.assertEqual( 31 | CharTestEnum.FIRST, 32 | form.cleaned_data['enumerated_field'] 33 | ) 34 | 35 | def test_form_is_not_valid_when_value_is_not_in_enum_class(self): 36 | form = self.StandardEnumForm({ 37 | 'enumerated_field': 'not_valid' 38 | }) 39 | 40 | expected_error = EnumChoiceField.default_error_messages['invalid_choice'] % {'value': 'not_valid'} 41 | 42 | self.assertFalse(form.is_valid()) 43 | self.assertIn(expected_error, form.errors.get('enumerated_field', [])) 44 | 45 | def test_form_is_not_valid_when_value_is_none_and_field_is_required(self): 46 | form = self.StandardEnumForm({ 47 | 'enumerated_field': None 48 | }) 49 | 50 | expected_error = 'This field is required.' 51 | 52 | self.assertFalse(form.is_valid()) 53 | self.assertIn(expected_error, form.errors.get('enumerated_field', [])) 54 | 55 | def test_form_is_valid_when_value_is_none_and_field_is_not_required(self): 56 | class NonRequiredForm(forms.Form): 57 | enumerated_field = EnumChoiceField(CharTestEnum, required=False) 58 | 59 | form = NonRequiredForm({ 60 | 'enumerated_field': None 61 | }) 62 | 63 | self.assertTrue(form.is_valid()) 64 | self.assertEqual(None, form.cleaned_data['enumerated_field']) 65 | 66 | def test_form_is_valid_when_value_is_valid_and_field_uses_custom_choice_builder(self): 67 | form = self.CustomChoiceBuilderEnumForm({ 68 | 'enumerated_field': 'Custom_first' 69 | }) 70 | 71 | self.assertTrue(form.is_valid()) 72 | self.assertEqual( 73 | CharTestEnum.FIRST, 74 | form.cleaned_data['enumerated_field'] 75 | ) 76 | 77 | def test_form_is_not_valid_when_value_is_invalid_and_field_uses_custom_choice_builder(self): 78 | form = self.CustomChoiceBuilderEnumForm({ 79 | 'enumerated_field': 'first' 80 | }) 81 | 82 | expected_error = EnumChoiceField.default_error_messages['invalid_choice'] % {'value': 'first'} 83 | 84 | self.assertFalse(form.is_valid()) 85 | self.assertIn(expected_error, form.errors['enumerated_field']) 86 | 87 | 88 | class ModelFormIntegrationTests(TestCase): 89 | class StandardEnumForm(forms.ModelForm): 90 | class Meta: 91 | model = StringEnumeratedModel 92 | fields = ('enumeration', ) 93 | 94 | class CustomChoiceBuilderEnumForm(forms.ModelForm): 95 | class Meta: 96 | model = CustomChoiceBuilderEnumeratedModel 97 | fields = ('enumeration', ) 98 | 99 | def test_form_is_valid_when_value_is_valid(self): 100 | form = self.StandardEnumForm({ 101 | 'enumeration': 'first' 102 | }) 103 | 104 | self.assertTrue(form.is_valid()) 105 | self.assertEqual(CharTestEnum.FIRST, form.cleaned_data['enumeration']) 106 | 107 | def test_form_is_invalid_when_value_is_not_from_choices(self): 108 | form = self.StandardEnumForm({ 109 | 'enumeration': 'not_valid' 110 | }) 111 | 112 | expected_error = EnumChoiceField.default_error_messages['invalid_choice'] % {'value': 'not_valid'} 113 | 114 | self.assertFalse(form.is_valid()) 115 | self.assertIn(expected_error, form.errors.get('enumeration', [])) 116 | 117 | def test_form_is_valid_when_value_is_valid_and_form_uses_custom_choice_builder(self): 118 | form = self.CustomChoiceBuilderEnumForm({ 119 | 'enumeration': 'Custom_first' 120 | }) 121 | 122 | self.assertTrue(form.is_valid()) 123 | self.assertEqual(CharTestEnum.FIRST, form.cleaned_data['enumeration']) 124 | 125 | def test_form_is_invalid_when_value_is_not_from_choices_and_form_uses_custom_choice_builder(self): 126 | form = self.CustomChoiceBuilderEnumForm({ 127 | 'enumeration': 'first' 128 | }) 129 | 130 | self.assertFalse(form.is_valid()) 131 | 132 | expected_error = EnumChoiceField.default_error_messages['invalid_choice'] % {'value': 'first'} 133 | self.assertIn(expected_error, form.errors.get('enumeration', [])) 134 | 135 | def test_saving_model_form_creates_instance(self): 136 | form = self.CustomChoiceBuilderEnumForm({ 137 | 'enumeration': 'Custom_first' 138 | }) 139 | 140 | current_instance_count = CustomChoiceBuilderEnumeratedModel.objects.count() 141 | 142 | self.assertTrue(form.is_valid()) 143 | 144 | instance = form.save(commit=True) 145 | 146 | self.assertEqual( 147 | current_instance_count + 1, 148 | CustomChoiceBuilderEnumeratedModel.objects.count() 149 | ) 150 | self.assertEqual( 151 | CharTestEnum.FIRST, 152 | instance.enumeration 153 | ) 154 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_model_fields.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from django.test import TestCase 4 | from django.core.exceptions import ValidationError 5 | from django.contrib.admin.utils import display_for_field 6 | 7 | from django_enum_choices.fields import EnumChoiceField 8 | from django_enum_choices.exceptions import EnumChoiceFieldException 9 | from django_enum_choices.choice_builders import value_value 10 | from django_enum_choices.forms import EnumChoiceField as EnumChoiceFormField 11 | 12 | from .testapp.enumerations import CharTestEnum, IntTestEnum 13 | 14 | 15 | class EnumChoiceFieldTests(TestCase): 16 | def test_field_initializes_string_choice_values(self): 17 | field = EnumChoiceField(enum_class=CharTestEnum) 18 | 19 | self.assertEqual( 20 | field.enum_class, 21 | CharTestEnum 22 | ) 23 | 24 | expected_choices = [ 25 | ('first', 'first'), 26 | ('second', 'second'), 27 | ('third', 'third'), 28 | ] 29 | 30 | self.assertEqual( 31 | expected_choices, 32 | field.choices, 33 | ) 34 | 35 | def test_field_initializes_int_choice_values_by_stringifying_them(self): 36 | field = EnumChoiceField(enum_class=IntTestEnum) 37 | 38 | self.assertEqual( 39 | field.enum_class, 40 | IntTestEnum 41 | ) 42 | 43 | expected_choices = [ 44 | ('1', '1'), 45 | ('2', '2'), 46 | ('3', '3'), 47 | ] 48 | 49 | self.assertEqual( 50 | expected_choices, 51 | field.choices, 52 | ) 53 | 54 | def test_field_initializes_arbitrary_object_values_by_stringifying_them(self): 55 | class Foo: 56 | def __str__(self): 57 | return 'foo' 58 | 59 | class TestEnum(Enum): 60 | FOO = Foo() 61 | 62 | field = EnumChoiceField(enum_class=TestEnum) 63 | 64 | expected_choices = [ 65 | ('foo', 'foo') 66 | ] 67 | 68 | self.assertEqual( 69 | expected_choices, 70 | field.choices, 71 | ) 72 | 73 | def test_max_length_is_calculated_from_the_longest_element(self): 74 | 75 | class TestEnum(Enum): 76 | FOO = 'foo' 77 | BAR = 'A' * 100 78 | 79 | field = EnumChoiceField(enum_class=TestEnum) 80 | 81 | self.assertEqual(100, field.max_length) 82 | 83 | def test_field_raises_exception_when_enum_class_is_not_enumeration(self): 84 | class FailingEnum: 85 | FOO = 'foo' 86 | BAR = 'bar' 87 | 88 | with self.assertRaisesMessage( 89 | EnumChoiceFieldException, 90 | '`enum_class` argument must be a child of `Enum`' 91 | ): 92 | EnumChoiceField(enum_class=FailingEnum) 93 | 94 | def test_get_prep_value_returns_primitive_value_when_base_is_integer(self): 95 | instance = EnumChoiceField(enum_class=IntTestEnum) 96 | 97 | result = instance.get_prep_value(IntTestEnum.FIRST) 98 | 99 | self.assertEqual(result, '1') 100 | 101 | def test_get_prep_value_returns_primitive_value_when_base_is_string(self): 102 | instance = EnumChoiceField(enum_class=CharTestEnum) 103 | 104 | result = instance.get_prep_value(CharTestEnum.FIRST) 105 | 106 | self.assertEqual(result, 'first') 107 | 108 | def test_from_db_value_returns_none_when_value_is_none(self): 109 | instance = EnumChoiceField(enum_class=IntTestEnum) 110 | 111 | result = instance.from_db_value(None, None, None) 112 | 113 | self.assertIsNone(result) 114 | 115 | def test_from_db_value_returns_enum_value_when_base_is_integer(self): 116 | instance = EnumChoiceField(enum_class=IntTestEnum) 117 | 118 | result = instance.from_db_value('1', None, None) 119 | 120 | self.assertEqual(result, IntTestEnum.FIRST) 121 | 122 | def test_from_db_value_returns_enum_value_when_base_is_string(self): 123 | instance = EnumChoiceField(enum_class=CharTestEnum) 124 | 125 | result = instance.from_db_value('first', None, None) 126 | 127 | self.assertEqual(result, CharTestEnum.FIRST) 128 | 129 | def test_from_db_value_raises_exception_when_int_value_not_contained_in_enum_class(self): 130 | instance = EnumChoiceField(enum_class=IntTestEnum) 131 | 132 | with self.assertRaises(ValidationError): 133 | instance.from_db_value(7, None, None) 134 | 135 | def test_deconstruct_behaves_as_expected(self): 136 | """ 137 | Idea taken from: 138 | https://docs.djangoproject.com/en/2.2/howto/custom-model-fields/#field-deconstruction 139 | """ 140 | instance = EnumChoiceField(enum_class=IntTestEnum) 141 | name, path, args, kwargs = instance.deconstruct() 142 | 143 | new_instance = EnumChoiceField(*args, **kwargs) 144 | 145 | self.assertEqual(instance.enum_class, new_instance.enum_class) 146 | self.assertEqual(instance.choices, new_instance.choices) 147 | self.assertEqual(instance.max_length, new_instance.max_length) 148 | self.assertEqual(instance._original_choice_builder, new_instance._original_choice_builder) 149 | 150 | def test_choice_builder_is_used_when_callable(self): 151 | class TestEnum(Enum): 152 | A = 1 153 | B = 2 154 | 155 | def choice_builder(enum_instance): 156 | if enum_instance == TestEnum.A: 157 | return 1, 'A' 158 | 159 | if enum_instance == TestEnum.B: 160 | return 2, 'B' 161 | 162 | instance = EnumChoiceField(enum_class=TestEnum, choice_builder=choice_builder) 163 | 164 | expected_choices = [ 165 | ('1', 'A'), 166 | ('2', 'B') 167 | ] 168 | 169 | self.assertEqual(expected_choices, instance.choices) 170 | 171 | def test_to_python_returns_enum_when_called_with_enum_value(self): 172 | instance = EnumChoiceField(enum_class=CharTestEnum) 173 | 174 | result = instance.to_python(CharTestEnum.FIRST) 175 | 176 | self.assertEqual(CharTestEnum.FIRST, result) 177 | 178 | def test_to_python_returns_enum_when_called_with_primitive_value(self): 179 | instance = EnumChoiceField(enum_class=CharTestEnum) 180 | 181 | result = instance.to_python('first') 182 | 183 | self.assertEqual(CharTestEnum.FIRST, result) 184 | 185 | def test_to_python_returns_none_when_called_with_none(self): 186 | instance = EnumChoiceField(enum_class=CharTestEnum) 187 | 188 | result = instance.to_python(None) 189 | 190 | self.assertIsNone(result) 191 | 192 | def test_to_python_raises_exception_when_called_with_value_outside_enum_class(self): 193 | instance = EnumChoiceField(enum_class=CharTestEnum) 194 | 195 | with self.assertRaises( 196 | ValidationError 197 | ): 198 | instance.to_python('NOT_EXISTING') 199 | 200 | def test_flatchoices_returns_enumerations_as_choice_keys(self): 201 | instance = EnumChoiceField(enum_class=CharTestEnum) 202 | 203 | result = instance.flatchoices 204 | 205 | for choice, _ in result: 206 | self.assertIsInstance(choice, CharTestEnum) 207 | 208 | def test_flatchoices_returns_readable_value_as_choice_value_when_autogenerated(self): 209 | instance = EnumChoiceField(enum_class=CharTestEnum) 210 | 211 | result = instance.flatchoices 212 | 213 | for choice, readable in result: 214 | self.assertEqual( 215 | CharTestEnum(choice).value, 216 | readable 217 | ) 218 | 219 | def test_flatchoices_returns_readable_value_as_choice_value_when_choice_builder_is_redefined(self): 220 | class TestEnum(Enum): 221 | FOO = 'foo' 222 | BAR = 'bar' 223 | 224 | def choice_builder(choice): 225 | return choice.value, choice.value.upper() 226 | 227 | instance = EnumChoiceField(enum_class=TestEnum, choice_builder=choice_builder) 228 | 229 | result = instance.flatchoices 230 | 231 | for choice, readable in result: 232 | self.assertEqual( 233 | choice_builder(TestEnum(choice))[1], 234 | readable 235 | ) 236 | 237 | def test_display_for_field_returns_readable_value_when_autogenerated(self): 238 | instance = EnumChoiceField(enum_class=CharTestEnum) 239 | 240 | result = display_for_field(CharTestEnum.FIRST, instance, None) 241 | 242 | self.assertEqual(CharTestEnum.FIRST.value, result) 243 | 244 | def test_display_for_field_returns_readable_value_when_choice_builder_is_redefined(self): 245 | class TestEnum(Enum): 246 | FOO = 'foo' 247 | BAR = 'bar' 248 | 249 | def choice_builder(choice): 250 | return choice.value, choice.value.upper() 251 | 252 | instance = EnumChoiceField(enum_class=TestEnum, choice_builder=choice_builder) 253 | 254 | result = display_for_field(TestEnum.FOO, instance, None) 255 | 256 | self.assertEqual(choice_builder(TestEnum.FOO)[1], result) 257 | 258 | def test_display_for_field_returns_empty_display_when_value_is_none(self): 259 | EMPTY_DISPLAY = 'EMPTY' 260 | 261 | instance = EnumChoiceField(enum_class=CharTestEnum) 262 | 263 | result = display_for_field(None, instance, EMPTY_DISPLAY) 264 | 265 | self.assertEqual(EMPTY_DISPLAY, result) 266 | 267 | def test_validate_raises_error_when_field_is_not_null_and_value_is_none(self): 268 | instance = EnumChoiceField(enum_class=CharTestEnum) 269 | 270 | with self.assertRaisesMessage( 271 | ValidationError, 272 | str(instance.error_messages['null']) 273 | ): 274 | instance.validate(None) 275 | 276 | def test_validate_raises_error_when_field_is_not_blank_and_value_is_empty(self): 277 | instance = EnumChoiceField(enum_class=CharTestEnum) 278 | 279 | with self.assertRaisesMessage( 280 | ValidationError, 281 | str(instance.error_messages['blank']) 282 | ): 283 | instance.validate('') 284 | 285 | def test_validate_raises_error_when_value_is_not_from_enum_class(self): 286 | instance = EnumChoiceField(enum_class=CharTestEnum) 287 | 288 | expected = instance.error_messages['invalid_choice'] % {'value': 'foo'} 289 | 290 | with self.assertRaisesMessage( 291 | ValidationError, 292 | expected 293 | ): 294 | instance.validate('foo') 295 | 296 | def test_get_choice_builder_raises_exception_when_choice_builder_is_not_callable(self): 297 | class TestEnum(Enum): 298 | A = 1 299 | B = 2 300 | 301 | choice_builder = 'choice_builder' 302 | 303 | with self.assertRaisesMessage( 304 | EnumChoiceFieldException, 305 | '`TestEnum.choice_builder` must be a callable' 306 | ): 307 | instance = EnumChoiceField(enum_class=TestEnum) 308 | 309 | instance._get_choice_builder(choice_builder) 310 | 311 | def test_value_value_is_used_when_choice_builder_is_not_provided(self): 312 | instance = EnumChoiceField(enum_class=CharTestEnum) 313 | 314 | self.assertEqual(instance._original_choice_builder, value_value) 315 | 316 | def test_custom_choice_builder_is_used_when_provided_and_callable(self): 317 | def choice_builder(choice): 318 | return choice.name, choice.value 319 | 320 | instance = EnumChoiceField( 321 | enum_class=CharTestEnum, 322 | choice_builder=choice_builder 323 | ) 324 | 325 | self.assertEqual( 326 | instance._original_choice_builder, 327 | choice_builder 328 | ) 329 | 330 | def test_build_choices_raises_exception_when_not_all_values_are_strings(self): 331 | instance = EnumChoiceField(enum_class=CharTestEnum) 332 | instance.choice_builder = lambda x: (1, 1) 333 | 334 | with self.assertRaisesMessage( 335 | EnumChoiceFieldException, 336 | 'Received type {} on key inside choice: (1, 1).\n'.format(int) + 337 | 'All choices generated from {} must be strings.'.format(CharTestEnum) 338 | ): 339 | instance.build_choices() 340 | 341 | def test_get_choices_returns_choices_in_correct_format(self): 342 | instance = EnumChoiceField(enum_class=CharTestEnum) 343 | 344 | result = instance.get_choices() 345 | 346 | self.assertEqual(len(result), 4) 347 | self.assertIn(instance.choice_builder(CharTestEnum.FIRST), result) 348 | self.assertIn(instance.choice_builder(CharTestEnum.SECOND), result) 349 | self.assertIn(instance.choice_builder(CharTestEnum.THIRD), result) 350 | 351 | def test_formfield_returns_enum_choice_form_field_instance(self): 352 | instance = EnumChoiceField(enum_class=CharTestEnum) 353 | 354 | result = instance.formfield() 355 | 356 | self.assertIsInstance(result, EnumChoiceFormField) 357 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_model_integrations.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.core import serializers 3 | from django.core.exceptions import ValidationError 4 | 5 | from django_enum_choices.fields import EnumChoiceField 6 | 7 | from .testapp.enumerations import CharTestEnum, CharLongValuesTestEnum, IntTestEnum 8 | from .testapp.models import ( 9 | IntegerEnumeratedModel, 10 | StringEnumeratedModel, 11 | NullableEnumeratedModel, 12 | BlankNullableEnumeratedModel, 13 | EnumChoiceFieldWithDefaultModel, 14 | AttributeChoiceBuilderEnumeratedModel 15 | ) 16 | 17 | 18 | class ModelIntegrationTests(TestCase): 19 | def test_can_create_object_with_char_base(self): 20 | instance = StringEnumeratedModel.objects.create( 21 | enumeration=CharTestEnum.FIRST 22 | ) 23 | instance.refresh_from_db() 24 | 25 | self.assertEqual(instance.enumeration, CharTestEnum.FIRST) 26 | 27 | def test_can_assign_enumeration_with_char_base(self): 28 | instance = StringEnumeratedModel.objects.create( 29 | enumeration=CharTestEnum.FIRST 30 | ) 31 | instance.refresh_from_db() 32 | 33 | instance.enumeration = CharTestEnum.SECOND 34 | instance.save() 35 | instance.refresh_from_db() 36 | 37 | self.assertEqual(instance.enumeration, CharTestEnum.SECOND) 38 | 39 | def test_can_filter_by_enumeration_with_char_base(self): 40 | first = StringEnumeratedModel.objects.create( 41 | enumeration=CharTestEnum.FIRST 42 | ) 43 | second = StringEnumeratedModel.objects.create( 44 | enumeration=CharTestEnum.SECOND 45 | ) 46 | 47 | first_qs = StringEnumeratedModel.objects.filter(enumeration=CharTestEnum.FIRST) 48 | second_qs = StringEnumeratedModel.objects.filter(enumeration=CharTestEnum.SECOND) 49 | 50 | self.assertIn(first, first_qs) 51 | self.assertNotIn(second, first_qs) 52 | 53 | self.assertIn(second, second_qs) 54 | self.assertNotIn(first, second_qs) 55 | 56 | def test_can_create_object_with_int_base(self): 57 | instance = IntegerEnumeratedModel.objects.create( 58 | enumeration=IntTestEnum.FIRST 59 | ) 60 | instance.refresh_from_db() 61 | 62 | self.assertEqual(instance.enumeration, IntTestEnum.FIRST) 63 | 64 | def test_can_assign_enumeration_with_int_base(self): 65 | instance = IntegerEnumeratedModel.objects.create( 66 | enumeration=IntTestEnum.FIRST 67 | ) 68 | instance.refresh_from_db() 69 | 70 | instance.enumeration = IntTestEnum.SECOND 71 | instance.save() 72 | instance.refresh_from_db() 73 | 74 | self.assertEqual(instance.enumeration, IntTestEnum.SECOND) 75 | 76 | def test_can_filter_by_enumeration_with_int_base(self): 77 | first = IntegerEnumeratedModel.objects.create( 78 | enumeration=IntTestEnum.FIRST 79 | ) 80 | second = IntegerEnumeratedModel.objects.create( 81 | enumeration=IntTestEnum.SECOND 82 | ) 83 | 84 | first_qs = IntegerEnumeratedModel.objects.filter(enumeration=IntTestEnum.FIRST) 85 | second_qs = IntegerEnumeratedModel.objects.filter(enumeration=IntTestEnum.SECOND) 86 | 87 | self.assertIn(first, first_qs) 88 | self.assertNotIn(second, first_qs) 89 | 90 | self.assertIn(second, second_qs) 91 | self.assertNotIn(first, second_qs) 92 | 93 | def test_serialization(self): 94 | IntegerEnumeratedModel.objects.create( 95 | enumeration=IntTestEnum.FIRST 96 | ) 97 | 98 | data = serializers.serialize('json', IntegerEnumeratedModel.objects.all()) 99 | 100 | expected = '[{"model": "testapp.integerenumeratedmodel", "pk": 1, "fields": {"enumeration": "1"}}]' 101 | 102 | self.assertEqual(expected, data) 103 | 104 | def test_deserialization(self): 105 | instance = IntegerEnumeratedModel.objects.create( 106 | enumeration=IntTestEnum.FIRST 107 | ) 108 | 109 | data = serializers.serialize('json', IntegerEnumeratedModel.objects.all()) 110 | objects = list(serializers.deserialize('json', data)) 111 | 112 | self.assertEqual(1, len(objects)) 113 | 114 | deserialized_instance = objects[0] 115 | 116 | self.assertEqual(instance, deserialized_instance.object) 117 | 118 | def test_object_with_nullable_field_can_be_created(self): 119 | instance = NullableEnumeratedModel() 120 | 121 | self.assertIsNone(instance.enumeration) 122 | 123 | def test_nullable_field_can_be_set_to_none(self): 124 | instance = NullableEnumeratedModel( 125 | enumeration=CharTestEnum.FIRST 126 | ) 127 | 128 | instance.enumeration = None 129 | instance.save() 130 | instance.refresh_from_db() 131 | 132 | self.assertIsNone(instance.enumeration) 133 | 134 | def test_non_blank_field_raises_error_on_clean(self): 135 | instance = NullableEnumeratedModel() 136 | 137 | with self.assertRaisesMessage( 138 | ValidationError, 139 | str(EnumChoiceField.default_error_messages['blank']) 140 | ): 141 | instance.full_clean() 142 | 143 | def test_blank_field_does_not_raise_error_on_clean(self): 144 | instance = BlankNullableEnumeratedModel() 145 | 146 | instance.full_clean() 147 | 148 | self.assertIsNone(instance.enumeration) 149 | 150 | def test_non_enum_value_raises_error_on_clean(self): 151 | instance = StringEnumeratedModel.objects.create( 152 | enumeration=CharTestEnum.FIRST 153 | ) 154 | 155 | instance.enumeration = "foo" 156 | 157 | with self.assertRaises( 158 | ValidationError 159 | ): 160 | instance.full_clean() 161 | 162 | def test_enum_value_from_enum_class_does_not_raise_error_on_clean(self): 163 | instance = StringEnumeratedModel(enumeration=CharTestEnum.FIRST) 164 | instance.full_clean() 165 | instance.save() 166 | 167 | self.assertIsNotNone(instance) 168 | 169 | def test_attribute_choice_builder_validates_enum_field_name_length(self): 170 | instance = AttributeChoiceBuilderEnumeratedModel(enumeration=CharLongValuesTestEnum.FIRST) 171 | instance.full_clean() 172 | instance.save() 173 | 174 | self.assertIsNotNone(instance) 175 | 176 | def test_default_value_is_used(self): 177 | instance = EnumChoiceFieldWithDefaultModel.objects.create() 178 | 179 | self.assertEqual( 180 | EnumChoiceFieldWithDefaultModel._meta.get_field('enumeration').default, 181 | instance.enumeration 182 | ) 183 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_serializer_fields.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from rest_framework.exceptions import ValidationError 4 | 5 | from django_enum_choices.serializers import EnumChoiceField, MultipleEnumChoiceField 6 | from .testapp.enumerations import IntTestEnum, CharTestEnum 7 | 8 | 9 | class TestSerializerField(TestCase): 10 | def test_to_representation_returns_primitive_int_value(self): 11 | field = EnumChoiceField(enum_class=IntTestEnum) 12 | 13 | result = field.to_representation(IntTestEnum.FIRST) 14 | 15 | self.assertEqual(result, '1') 16 | 17 | def test_to_representation_returns_primitive_string_value(self): 18 | field = EnumChoiceField(enum_class=CharTestEnum) 19 | 20 | result = field.to_representation(CharTestEnum.FIRST) 21 | 22 | self.assertEqual(result, 'first') 23 | 24 | def test_to_internal_value_fails_when_value_not_in_enum_class(self): 25 | failing_value = 5 26 | field = EnumChoiceField(enum_class=IntTestEnum) 27 | 28 | with self.assertRaisesMessage( 29 | ValidationError, 30 | 'Key 5 is not a valid IntTestEnum' 31 | ): 32 | field.to_internal_value(failing_value) 33 | 34 | def test_to_internal_value_returns_enum_value_when_value_is_int(self): 35 | field = EnumChoiceField(enum_class=IntTestEnum) 36 | 37 | result = field.to_internal_value('1') 38 | 39 | self.assertEqual(result, IntTestEnum.FIRST) 40 | 41 | def test_to_internal_value_returns_enum_value_when_value_is_string(self): 42 | field = EnumChoiceField(enum_class=CharTestEnum) 43 | 44 | result = field.to_internal_value('first') 45 | 46 | self.assertEqual(result, CharTestEnum.FIRST) 47 | 48 | 49 | class TestMultipleSerializerField(TestCase): 50 | def test_to_representation_returns_list_of_ints(self): 51 | field = MultipleEnumChoiceField(enum_class=IntTestEnum) 52 | 53 | result = field.to_representation([IntTestEnum.FIRST, IntTestEnum.SECOND]) 54 | 55 | self.assertEqual(['1', '2'], result) 56 | 57 | def test_to_representation_returns_list_of_strings(self): 58 | field = MultipleEnumChoiceField(enum_class=CharTestEnum) 59 | 60 | result = field.to_representation([CharTestEnum.FIRST, CharTestEnum.SECOND]) 61 | 62 | self.assertEqual(['first', 'second'], result) 63 | 64 | def test_to_internal_value_fails_when_value_is_not_list(self): 65 | field = MultipleEnumChoiceField(enum_class=IntTestEnum) 66 | 67 | with self.assertRaisesMessage( 68 | ValidationError, 69 | 'Expected a list of items but got type "int".' 70 | ): 71 | field.to_internal_value(5) 72 | 73 | def test_to_internal_value_fails_when_not_allowed_empty_field_gets_empty_list(self): 74 | field = MultipleEnumChoiceField(enum_class=IntTestEnum) 75 | 76 | with self.assertRaisesMessage( 77 | ValidationError, 78 | 'This selection may not be empty.' 79 | ): 80 | field.to_internal_value([]) 81 | 82 | def test_to_internal_value_returns_list_of_enums_when_value_is_list_of_ints(self): 83 | field = MultipleEnumChoiceField(enum_class=IntTestEnum) 84 | 85 | result = field.to_internal_value(['1', '2']) 86 | 87 | self.assertEqual([IntTestEnum.FIRST, IntTestEnum.SECOND], result) 88 | 89 | def test_to_internal_value_returns_list_of_enums_when_value_is_list_of_strings(self): 90 | field = MultipleEnumChoiceField(enum_class=CharTestEnum) 91 | 92 | result = field.to_internal_value(['first', 'second']) 93 | 94 | self.assertEqual([CharTestEnum.FIRST, CharTestEnum.SECOND], result) 95 | -------------------------------------------------------------------------------- /django_enum_choices/tests/test_serializer_integrations.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from rest_framework import serializers 4 | 5 | from django_enum_choices.serializers import ( 6 | EnumChoiceField, 7 | EnumChoiceModelSerializerMixin, 8 | MultipleEnumChoiceField 9 | ) 10 | from .testapp.models import ( 11 | StringEnumeratedModel, 12 | MultipleEnumeratedModel, 13 | CustomChoiceBuilderEnumeratedModel, 14 | BlankNullableEnumeratedModel 15 | ) 16 | from .testapp.enumerations import CharTestEnum 17 | 18 | 19 | class EnumChoiceFieldSerializerIntegrationTests(TestCase): 20 | class Serializer(serializers.Serializer): 21 | enumeration = EnumChoiceField(enum_class=CharTestEnum) 22 | 23 | def test_field_value_is_serialized_correctly(self): 24 | serializer = self.Serializer({'enumeration': CharTestEnum.FIRST}) 25 | 26 | result = serializer.data['enumeration'] 27 | 28 | self.assertEqual(result, 'first') 29 | 30 | def test_field_is_deserialized_correctly(self): 31 | serializer = self.Serializer(data={'enumeration': 'first'}) 32 | serializer.is_valid() 33 | 34 | result = serializer.validated_data['enumeration'] 35 | 36 | self.assertEqual(result, CharTestEnum.FIRST) 37 | 38 | def test_serializer_is_not_valid_when_field_is_required_and_not_provided(self): 39 | class Serializer(serializers.Serializer): 40 | enumeration = EnumChoiceField( 41 | enum_class=CharTestEnum 42 | ) 43 | 44 | serializer = Serializer(data={}) 45 | 46 | self.assertFalse(serializer.is_valid()) 47 | 48 | def test_serializer_is_valid_when_field_is_required_and_provided(self): 49 | class Serializer(serializers.Serializer): 50 | enumeration = EnumChoiceField( 51 | enum_class=CharTestEnum 52 | ) 53 | 54 | serializer = Serializer(data={'enumeration': 'first'}) 55 | 56 | self.assertTrue(serializer.is_valid) 57 | 58 | def test_serializer_does_not_add_field_to_validated_data_when_not_required_and_not_provided(self): 59 | class Serializer(serializers.Serializer): 60 | enumeration = EnumChoiceField( 61 | enum_class=CharTestEnum, 62 | required=False 63 | ) 64 | 65 | serializer = Serializer(data={}) 66 | is_valid = serializer.is_valid() 67 | 68 | self.assertTrue(is_valid) 69 | self.assertNotIn( 70 | 'enumeration', 71 | serializer.validated_data.keys() 72 | ) 73 | 74 | def test_serializer_adds_field_to_validated_data_when_not_required_and_provided(self): 75 | class Serializer(serializers.Serializer): 76 | enumeration = EnumChoiceField( 77 | enum_class=CharTestEnum, 78 | required=False 79 | ) 80 | 81 | serializer = Serializer(data={'enumeration': 'first'}) 82 | is_valid = serializer.is_valid() 83 | 84 | self.assertTrue(is_valid) 85 | self.assertIn( 86 | 'enumeration', 87 | serializer.validated_data.keys() 88 | ) 89 | 90 | def test_serializer_is_not_valid_when_field_is_not_nullable_and_value_is_none(self): 91 | class Serializer(serializers.Serializer): 92 | enumeration = EnumChoiceField( 93 | enum_class=CharTestEnum 94 | ) 95 | 96 | serializer = Serializer(data={'enumeration': None}) 97 | 98 | self.assertFalse(serializer.is_valid()) 99 | 100 | def test_serializer_is_valid_when_field_is_nullable_and_value_is_none(self): 101 | class Serializer(serializers.Serializer): 102 | enumeration = EnumChoiceField( 103 | enum_class=CharTestEnum, 104 | allow_null=True 105 | ) 106 | 107 | serializer = Serializer(data={'enumeration': None}) 108 | 109 | self.assertTrue(serializer.is_valid()) 110 | 111 | 112 | class MultipleEnumChoiceFieldSerializerIntegrationTests(TestCase): 113 | class Serializer(serializers.Serializer): 114 | enumeration = MultipleEnumChoiceField(enum_class=CharTestEnum) 115 | 116 | def test_multiple_field_is_serialized_correctly(self): 117 | serializer = self.Serializer({ 118 | 'enumeration': [CharTestEnum.FIRST, CharTestEnum.SECOND] 119 | }) 120 | 121 | result = serializer.data['enumeration'] 122 | 123 | self.assertEqual(result, ['first', 'second']) 124 | 125 | def test_multiple_field_is_deserialized_correctly(self): 126 | serializer = self.Serializer( 127 | data={ 128 | 'enumeration': [CharTestEnum.FIRST.value, CharTestEnum.SECOND.value] 129 | } 130 | ) 131 | self.assertTrue(serializer.is_valid()) 132 | 133 | result = serializer.validated_data['enumeration'] 134 | 135 | self.assertEqual(result, [CharTestEnum.FIRST, CharTestEnum.SECOND]) 136 | 137 | def test_serializer_is_not_valid_when_field_is_required_and_not_provided(self): 138 | class Serializer(serializers.Serializer): 139 | enumeration = MultipleEnumChoiceField( 140 | enum_class=CharTestEnum 141 | ) 142 | 143 | serializer = Serializer(data={}) 144 | 145 | self.assertFalse(serializer.is_valid()) 146 | 147 | def test_serializer_is_not_valid_when_field_is_required_and_provided_but_not_a_list(self): 148 | class Serializer(serializers.Serializer): 149 | enumeration = MultipleEnumChoiceField( 150 | enum_class=CharTestEnum 151 | ) 152 | 153 | serializer = Serializer(data={'enumeration': 'first'}) 154 | 155 | self.assertFalse(serializer.is_valid()) 156 | 157 | def test_serializer_is_valid_when_field_is_required_and_provided(self): 158 | class Serializer(serializers.Serializer): 159 | enumeration = MultipleEnumChoiceField( 160 | enum_class=CharTestEnum 161 | ) 162 | 163 | serializer = Serializer(data={'enumeration': ['first']}) 164 | 165 | self.assertTrue(serializer.is_valid) 166 | 167 | def test_serializer_does_not_add_field_to_validated_data_when_not_required_and_not_provided(self): 168 | class Serializer(serializers.Serializer): 169 | enumeration = MultipleEnumChoiceField( 170 | enum_class=CharTestEnum, 171 | required=False 172 | ) 173 | 174 | serializer = Serializer(data={}) 175 | is_valid = serializer.is_valid() 176 | 177 | self.assertTrue(is_valid) 178 | self.assertNotIn( 179 | 'enumeration', 180 | serializer.validated_data.keys() 181 | ) 182 | 183 | def test_serializer_adds_field_to_validated_data_when_not_required_and_provided(self): 184 | class Serializer(serializers.Serializer): 185 | enumeration = MultipleEnumChoiceField( 186 | enum_class=CharTestEnum, 187 | required=False 188 | ) 189 | 190 | serializer = Serializer(data={'enumeration': ['first']}) 191 | is_valid = serializer.is_valid() 192 | 193 | self.assertTrue(is_valid) 194 | self.assertIn( 195 | 'enumeration', 196 | serializer.validated_data.keys() 197 | ) 198 | 199 | def test_serializer_is_not_valid_when_field_is_not_nullable_and_value_is_none(self): 200 | class Serializer(serializers.Serializer): 201 | enumeration = MultipleEnumChoiceField( 202 | enum_class=CharTestEnum 203 | ) 204 | 205 | serializer = Serializer(data={'enumeration': None}) 206 | 207 | self.assertFalse(serializer.is_valid()) 208 | 209 | def test_serializer_is_valid_when_field_is_nullable_and_value_is_none(self): 210 | class Serializer(serializers.Serializer): 211 | enumeration = MultipleEnumChoiceField( 212 | enum_class=CharTestEnum, 213 | allow_null=True 214 | ) 215 | 216 | serializer = Serializer(data={'enumeration': None}) 217 | 218 | self.assertTrue(serializer.is_valid()) 219 | 220 | def test_serializer_is_not_valid_when_field_is_not_allow_empty_but_empty_list_is_provided(self): 221 | class Serializer(serializers.Serializer): 222 | enumeration = MultipleEnumChoiceField( 223 | enum_class=CharTestEnum 224 | ) 225 | 226 | serializer = Serializer(data={'enumeration': []}) 227 | 228 | self.assertFalse(serializer.is_valid()) 229 | 230 | def test_serializer_is_valid_when_field_is_allow_empty_and_empty_list_is_provided(self): 231 | class Serializer(serializers.Serializer): 232 | enumeration = MultipleEnumChoiceField( 233 | enum_class=CharTestEnum, 234 | allow_empty=True 235 | ) 236 | 237 | serializer = Serializer(data={'enumeration': []}) 238 | 239 | self.assertTrue(serializer.is_valid()) 240 | 241 | 242 | class EnumChoiceFieldModelSerializerIntegrationTests(TestCase): 243 | class Serializer(serializers.ModelSerializer): 244 | enumeration = EnumChoiceField(enum_class=CharTestEnum) 245 | 246 | class Meta: 247 | model = StringEnumeratedModel 248 | fields = ('enumeration', ) 249 | 250 | def test_field_value_is_serialized_correctly(self): 251 | instance = StringEnumeratedModel.objects.create( 252 | enumeration=CharTestEnum.FIRST 253 | ) 254 | 255 | serializer = self.Serializer(instance) 256 | 257 | result = serializer.data['enumeration'] 258 | 259 | self.assertEqual(result, 'first') 260 | 261 | def test_field_is_deserialized_correctly(self): 262 | serializer = self.Serializer(data={'enumeration': 'first'}) 263 | serializer.is_valid() 264 | 265 | result = serializer.validated_data['enumeration'] 266 | 267 | self.assertEqual(result, CharTestEnum.FIRST) 268 | 269 | def test_field_is_serialized_correctly_when_using_serializer_mixin(self): 270 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 271 | class Meta: 272 | model = StringEnumeratedModel 273 | fields = ('enumeration', ) 274 | 275 | instance = StringEnumeratedModel.objects.create( 276 | enumeration=CharTestEnum.FIRST 277 | ) 278 | serializer = Serializer(instance) 279 | result = serializer.data['enumeration'] 280 | 281 | self.assertEqual('first', result) 282 | 283 | def test_field_is_deserialized_correctly_when_using_serializer_mixin(self): 284 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 285 | class Meta: 286 | model = StringEnumeratedModel 287 | fields = ('enumeration', ) 288 | 289 | serializer = self.Serializer(data={'enumeration': 'first'}) 290 | serializer.is_valid() 291 | 292 | result = serializer.validated_data['enumeration'] 293 | 294 | self.assertEqual(CharTestEnum.FIRST, result) 295 | 296 | def test_instance_is_created_successfully_after_model_serializer_create(self): 297 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 298 | class Meta: 299 | model = StringEnumeratedModel 300 | fiedls = ('enumeration', ) 301 | 302 | current_instance_count = StringEnumeratedModel.objects.count() 303 | 304 | serializer = self.Serializer(data={'enumeration': 'first'}) 305 | serializer.is_valid() 306 | 307 | instance = serializer.create(serializer.validated_data) 308 | 309 | self.assertEqual( 310 | current_instance_count + 1, 311 | StringEnumeratedModel.objects.count() 312 | ) 313 | self.assertEqual( 314 | CharTestEnum.FIRST, 315 | instance.enumeration 316 | ) 317 | 318 | def test_instance_is_updated_successfully_after_model_serializer_update(self): 319 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 320 | class Meta: 321 | model = StringEnumeratedModel 322 | fiedls = ('enumeration', ) 323 | 324 | instance = StringEnumeratedModel.objects.create( 325 | enumeration=CharTestEnum.FIRST 326 | ) 327 | 328 | serializer = self.Serializer(data={'enumeration': 'second'}) 329 | serializer.is_valid() 330 | 331 | serializer.update(instance, serializer.validated_data) 332 | instance.refresh_from_db() 333 | 334 | self.assertEqual( 335 | CharTestEnum.SECOND, 336 | instance.enumeration 337 | ) 338 | 339 | def test_instance_is_created_successfully_when_using_custom_choice_builder(self): 340 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 341 | class Meta: 342 | model = CustomChoiceBuilderEnumeratedModel 343 | fields = ('enumeration', ) 344 | 345 | current_instance_count = CustomChoiceBuilderEnumeratedModel.objects.count() 346 | 347 | serializer = Serializer(data={'enumeration': 'Custom_first'}) 348 | self.assertTrue(serializer.is_valid()) 349 | 350 | instance = serializer.create(serializer.validated_data) 351 | 352 | self.assertEqual( 353 | current_instance_count + 1, 354 | CustomChoiceBuilderEnumeratedModel.objects.count() 355 | ) 356 | self.assertEqual( 357 | CharTestEnum.FIRST, 358 | instance.enumeration 359 | ) 360 | 361 | def test_field_can_handle_allow_blank(self): 362 | # See GH-45 363 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 364 | class Meta: 365 | model = BlankNullableEnumeratedModel 366 | fields = ('enumeration', ) 367 | 368 | instance = StringEnumeratedModel.objects.create( 369 | enumeration=CharTestEnum.FIRST 370 | ) 371 | serializer = Serializer(instance) 372 | serializer.data 373 | 374 | 375 | class MultipleEnumChoiceFieldModelSerializerIntegrationTests(TestCase): 376 | databases = ['default', 'postgresql'] 377 | 378 | class Serializer(EnumChoiceModelSerializerMixin, serializers.ModelSerializer): 379 | class Meta: 380 | model = MultipleEnumeratedModel 381 | fields = ('enumeration', ) 382 | 383 | def test_mulitple_field_is_serialized_correctly_when_using_serializer_mixin(self): 384 | instance = MultipleEnumeratedModel.objects.create( 385 | enumeration=[CharTestEnum.FIRST, CharTestEnum.SECOND] 386 | ) 387 | 388 | serializer = self.Serializer(instance) 389 | result = serializer.data['enumeration'] 390 | 391 | self.assertEqual(['first', 'second'], result) 392 | 393 | def test_multiple_field_is_deserialized_correctly_when_using_serializer_mixin(self): 394 | serializer = self.Serializer(data={'enumeration': ['first', 'second']}) 395 | serializer.is_valid() 396 | 397 | result = serializer.validated_data['enumeration'] 398 | 399 | self.assertEqual([CharTestEnum.FIRST, CharTestEnum.SECOND], result) 400 | 401 | def test_instance_is_created_successfully_after_model_serializer_create(self): 402 | current_instance_count = MultipleEnumeratedModel.objects.count() 403 | 404 | serializer = self.Serializer(data={'enumeration': ['first', 'second']}) 405 | serializer.is_valid() 406 | 407 | instance = serializer.create(serializer.validated_data) 408 | 409 | self.assertEqual( 410 | current_instance_count + 1, 411 | MultipleEnumeratedModel.objects.count() 412 | ) 413 | self.assertEqual( 414 | [CharTestEnum.FIRST, CharTestEnum.SECOND], 415 | instance.enumeration 416 | ) 417 | 418 | def test_instance_is_updated_successfully_after_model_serializer_update(self): 419 | instance = MultipleEnumeratedModel.objects.create( 420 | enumeration=[CharTestEnum.FIRST, CharTestEnum.SECOND] 421 | ) 422 | 423 | serializer = self.Serializer(data={'enumeration': ['first', 'second', 'third']}) 424 | serializer.is_valid() 425 | 426 | serializer.update(instance, serializer.validated_data) 427 | instance.refresh_from_db() 428 | 429 | self.assertEqual( 430 | [CharTestEnum.FIRST, CharTestEnum.SECOND, CharTestEnum.THIRD], 431 | instance.enumeration 432 | ) 433 | -------------------------------------------------------------------------------- /django_enum_choices/tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = 'django_enum_choices.tests.testapp' 6 | verbose_name = 'TestApp' 7 | -------------------------------------------------------------------------------- /django_enum_choices/tests/testapp/database_routers.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.db import models 3 | from django.contrib.postgres import fields as pg_fields 4 | 5 | 6 | POSTGRES = 'postgresql' 7 | DEFAULT = 'default' 8 | 9 | 10 | class DataBaseRouter: 11 | def _get_postgresql_fields(self): 12 | return [ 13 | var for var in vars(pg_fields).values() 14 | if isinstance(var, type) and issubclass(var, models.Field) 15 | ] 16 | 17 | def _get_field_classes(self, db_obj): 18 | return [ 19 | type(field) for field in db_obj._meta.get_fields() 20 | ] 21 | 22 | def has_postgres_field(self, db_obj): 23 | field_classes = self._get_field_classes(db_obj) 24 | 25 | return len([ 26 | field_cls for field_cls in field_classes 27 | if field_cls in self._get_postgresql_fields() 28 | ]) > 0 29 | 30 | def db_for_read(self, model, **hints): 31 | if self.has_postgres_field(model): 32 | return POSTGRES 33 | 34 | return DEFAULT 35 | 36 | def db_for_write(self, model, **hints): 37 | if self.has_postgres_field(model): 38 | return POSTGRES 39 | 40 | return DEFAULT 41 | 42 | def allow_relation(self, obj1, obj2, **hints): 43 | if not self.has_postgres_field(obj1) and not self.has_postgres_field(obj2): 44 | return True 45 | 46 | return None 47 | 48 | def allow_migrate(self, db, app_label, model_name=None, **hints): 49 | if model_name is not None and \ 50 | db == DEFAULT and \ 51 | self.has_postgres_field(apps.get_model(app_label, model_name)): 52 | return False 53 | 54 | return True 55 | -------------------------------------------------------------------------------- /django_enum_choices/tests/testapp/enumerations.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class CharTestEnum(Enum): 5 | FIRST = 'first' 6 | SECOND = 'second' 7 | THIRD = 'third' 8 | 9 | 10 | class CharLongValuesTestEnum(Enum): 11 | FIRST = 'first value' 12 | SECOND = 'second value' 13 | THIRD = 'third value' 14 | 15 | 16 | class IntTestEnum(Enum): 17 | FIRST = 1 18 | SECOND = 2 19 | THIRD = 3 20 | -------------------------------------------------------------------------------- /django_enum_choices/tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.postgres.fields import ArrayField 3 | 4 | from django_enum_choices.fields import EnumChoiceField 5 | from django_enum_choices.choice_builders import attribute_value 6 | 7 | from .enumerations import CharTestEnum, CharLongValuesTestEnum, IntTestEnum 8 | 9 | 10 | def custom_choice_builder(choice): 11 | return 'Custom_' + choice.value, choice.value 12 | 13 | 14 | class IntegerEnumeratedModel(models.Model): 15 | enumeration = EnumChoiceField(enum_class=IntTestEnum) 16 | 17 | 18 | class StringEnumeratedModel(models.Model): 19 | enumeration = EnumChoiceField(enum_class=CharTestEnum) 20 | 21 | 22 | class NullableEnumeratedModel(models.Model): 23 | enumeration = EnumChoiceField( 24 | enum_class=CharTestEnum, 25 | null=True 26 | ) 27 | 28 | 29 | class BlankNullableEnumeratedModel(models.Model): 30 | enumeration = EnumChoiceField( 31 | enum_class=CharTestEnum, 32 | blank=True, 33 | null=True 34 | ) 35 | 36 | 37 | class EnumChoiceFieldWithDefaultModel(models.Model): 38 | enumeration = EnumChoiceField( 39 | CharTestEnum, 40 | default=CharTestEnum.FIRST 41 | ) 42 | 43 | 44 | class MultipleEnumeratedModel(models.Model): 45 | enumeration = ArrayField( 46 | base_field=EnumChoiceField( 47 | enum_class=CharTestEnum 48 | ), 49 | null=True 50 | ) 51 | 52 | 53 | class CustomChoiceBuilderEnumeratedModel(models.Model): 54 | enumeration = EnumChoiceField( 55 | enum_class=CharTestEnum, 56 | choice_builder=custom_choice_builder 57 | ) 58 | 59 | 60 | class AttributeChoiceBuilderEnumeratedModel(models.Model): 61 | enumeration = EnumChoiceField( 62 | enum_class=CharLongValuesTestEnum, 63 | choice_builder=attribute_value 64 | ) 65 | -------------------------------------------------------------------------------- /django_enum_choices/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Tuple, Any 2 | from enum import Enum 3 | 4 | from django.utils.translation import gettext as _ 5 | 6 | from .exceptions import EnumChoiceFieldException 7 | 8 | 9 | def as_choice_builder(choice_builder): 10 | def inner(enumeration): 11 | if not enumeration: 12 | return enumeration 13 | 14 | built = choice_builder(enumeration) 15 | 16 | return tuple(str(value) for value in built) 17 | 18 | return inner 19 | 20 | 21 | def value_from_built_choice(built_choice): 22 | if isinstance(built_choice, tuple): 23 | return built_choice[0] 24 | 25 | return built_choice 26 | 27 | 28 | def validate_built_choices( 29 | enum_class: Enum, 30 | built_choices: Tuple[Tuple[Any]] 31 | ): 32 | MEMBER_KEY = 'key' 33 | MEMBER_VALUE = 'value' 34 | 35 | message = _( 36 | 'Received type {failing_type} on {failing_member} inside choice: {failing_choice}.\n' + 37 | 'All choices generated from {failing_enum_class} must be strings.' 38 | ) 39 | 40 | for key, value in built_choices: 41 | message_kwargs = { 42 | 'failing_choice': (key, value), 43 | 'failing_enum_class': enum_class 44 | } 45 | 46 | if not isinstance(key, str): 47 | message_kwargs.update({ 48 | 'failing_type': type(key), 49 | 'failing_member': MEMBER_KEY 50 | }) 51 | 52 | raise EnumChoiceFieldException( 53 | message.format(**message_kwargs) 54 | ) 55 | 56 | if not isinstance(value, str): 57 | message_kwargs.update({ 58 | 'failing_type': type(value), 59 | 'failing_member': MEMBER_VALUE 60 | }) 61 | 62 | raise EnumChoiceFieldException( 63 | message.format(**message_kwargs) 64 | ) 65 | 66 | 67 | def build_enum_choices( 68 | enum_class: Enum, 69 | choice_builder: Callable 70 | ) -> Tuple[Tuple[str]]: 71 | choices = [ 72 | choice_builder(choice) 73 | for choice in enum_class 74 | ] 75 | 76 | validate_built_choices(enum_class, choices) 77 | 78 | return choices 79 | -------------------------------------------------------------------------------- /django_enum_choices/validators.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from django.core.validators import MaxLengthValidator 4 | 5 | 6 | class EnumValueMaxLengthValidator(MaxLengthValidator): 7 | """ 8 | When called from the field's `run_validators`, `MaxLengthValidator` 9 | attempts to return `len(value)` when value is an enumeration 10 | instance, which raises an error 11 | """ 12 | def __init__(self, value_builder: Callable, *args, **kwargs): 13 | self.value_builder = value_builder 14 | 15 | super().__init__(*args, **kwargs) 16 | 17 | def clean(self, x): 18 | value = self.value_builder(x) 19 | 20 | return len(value) 21 | -------------------------------------------------------------------------------- /examples/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackSoftware/django-enum-choices/ed2d06500a16a97e3bacfbdd2122e45203ab3209/examples/examples/__init__.py -------------------------------------------------------------------------------- /examples/examples/choice_builders.py: -------------------------------------------------------------------------------- 1 | def custom_choice_builder(choice): 2 | return 'Custom_' + choice.value, choice.value 3 | -------------------------------------------------------------------------------- /examples/examples/enumerations.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | # Standard enum 5 | class MyEnum(Enum): 6 | A = 'a' 7 | B = 'b' 8 | 9 | 10 | # Enum with custom objects as values 11 | class Value: 12 | def __init__(self, value): 13 | self.value = value 14 | 15 | def __str__(self): 16 | return self.value 17 | 18 | 19 | class CustomObjectEnum(Enum): 20 | A = Value(1) 21 | B = Value('B') 22 | 23 | 24 | # Enum with autogenerated values 25 | class AutoEnum(Enum): 26 | A = auto() # 1 27 | B = auto() # 2 28 | 29 | 30 | class CustomAutoEnumValueGenerator(Enum): 31 | def _generate_next_value_(name, start, count, last_values): 32 | return { 33 | 'A': 'foo', 34 | 'B': 'bar' 35 | }[name] 36 | 37 | 38 | class CustomAutoEnum(CustomAutoEnumValueGenerator): 39 | A = auto() 40 | B = auto() 41 | -------------------------------------------------------------------------------- /examples/examples/filters.py: -------------------------------------------------------------------------------- 1 | import django_filters as filters 2 | 3 | from django_enum_choices.filters import EnumChoiceFilter, EnumChoiceFilterSetMixin 4 | 5 | from .enumerations import MyEnum 6 | from .models import MyModel 7 | from .choice_builders import custom_choice_builder 8 | 9 | 10 | class ExplicitFilterSet(filters.FilterSet): 11 | enumerated_field = EnumChoiceFilter(MyEnum) 12 | 13 | 14 | class ExplicitCustomChoiceBuilderFilterSet(filters.FilterSet): 15 | enumerated_field = EnumChoiceFilter( 16 | MyEnum, 17 | choice_builder=custom_choice_builder 18 | ) 19 | 20 | 21 | class ImplicitFilterSet(EnumChoiceFilterSetMixin, filters.FilterSet): 22 | class Meta: 23 | model = MyModel 24 | fields = ['enumerated_field'] 25 | -------------------------------------------------------------------------------- /examples/examples/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from django_enum_choices.forms import EnumChoiceField 4 | 5 | from .models import MyModel 6 | from .enumerations import MyEnum 7 | from .choice_builders import custom_choice_builder 8 | 9 | 10 | class StandardEnumForm(forms.Form): 11 | enumerated_field = EnumChoiceField(MyEnum) 12 | 13 | 14 | class ModelEnumForm(forms.ModelForm): 15 | class Meta: 16 | model = MyModel 17 | fields = ['enumerated_field'] 18 | 19 | 20 | class CustomChoiceBuilderEnumForm(forms.Form): 21 | enumerated_field = EnumChoiceField( 22 | MyEnum, 23 | choice_builder=custom_choice_builder 24 | ) 25 | -------------------------------------------------------------------------------- /examples/examples/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-07-04 11:24 2 | 3 | import django.contrib.postgres.fields 4 | from django.db import migrations, models 5 | import django_enum_choices.fields 6 | import examples.enumerations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='MyModel', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('enumerated_field', django_enum_choices.fields.EnumChoiceField(choices=[('a', 'a'), ('b', 'b')], enum_class=examples.enumerations.MyEnum, max_length=1)), 22 | ], 23 | ), 24 | migrations.CreateModel( 25 | name='MyModelMultiple', 26 | fields=[ 27 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 28 | ('enumerated_field', django.contrib.postgres.fields.ArrayField(base_field=django_enum_choices.fields.EnumChoiceField(choices=[('a', 'a'), ('b', 'b')], enum_class=examples.enumerations.MyEnum, max_length=1), size=None)), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /examples/examples/migrations/0002_customreadablevalueenummodel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.3 on 2019-07-19 12:38 2 | 3 | from django.db import migrations, models 4 | import django_enum_choices.fields 5 | import examples.enumerations 6 | import examples.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('examples', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='CustomReadableValueEnumModel', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('enumerated_field', django_enum_choices.fields.EnumChoiceField(choice_builder=examples.models.choice_builder, choices=[('a', 'A'), ('b', 'B')], enum_class=examples.enumerations.MyEnum, max_length=1)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /examples/examples/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackSoftware/django-enum-choices/ed2d06500a16a97e3bacfbdd2122e45203ab3209/examples/examples/migrations/__init__.py -------------------------------------------------------------------------------- /examples/examples/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.postgres.fields import ArrayField 3 | 4 | from django_enum_choices.fields import EnumChoiceField 5 | 6 | from .enumerations import MyEnum 7 | 8 | 9 | # Model, containing a field with enumerations as choices 10 | class MyModel(models.Model): 11 | enumerated_field = EnumChoiceField(MyEnum) 12 | 13 | 14 | # Model, containing a field with a array of enumerations (PostgreSQL specific) 15 | class MyModelMultiple(models.Model): 16 | enumerated_field = ArrayField( 17 | base_field=EnumChoiceField(MyEnum) 18 | ) 19 | 20 | 21 | def choice_builder(choice): 22 | return choice.value, choice.value.upper() 23 | 24 | 25 | class CustomReadableValueEnumModel(models.Model): 26 | enumerated_field = EnumChoiceField( 27 | MyEnum, 28 | choice_builder=choice_builder 29 | ) 30 | -------------------------------------------------------------------------------- /examples/examples/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from django_enum_choices.serializers import ( 4 | EnumChoiceField, 5 | MultipleEnumChoiceField, 6 | EnumChoiceModelSerializerMixin 7 | ) 8 | 9 | 10 | from .enumerations import MyEnum 11 | from .models import MyModel, MyModelMultiple 12 | from .choice_builders import custom_choice_builder 13 | 14 | 15 | # Standard Serializer 16 | class MySerializer(serializers.Serializer): 17 | enumerated_field = EnumChoiceField(MyEnum) 18 | 19 | 20 | # Model Serializer 21 | class MyModelSerializer(serializers.ModelSerializer): 22 | enumerated_field = EnumChoiceField(MyEnum) 23 | 24 | class Meta: 25 | model = MyModel 26 | fields = ('enumerated_field', ) 27 | 28 | 29 | # Model Serializer without field declaration 30 | class ImplicitMyModelSerializer( 31 | EnumChoiceModelSerializerMixin, 32 | serializers.ModelSerializer 33 | ): 34 | class Meta: 35 | model = MyModel 36 | fields = ('enumerated_field', ) 37 | 38 | 39 | # Multiple Standard Serializer 40 | class MultipleMySerializer(serializers.Serializer): 41 | enumerated_field = MultipleEnumChoiceField(MyEnum) 42 | 43 | 44 | # Multiple Model Serializer without field declaration 45 | class ImplicitMultipleMyModelSerializer( 46 | EnumChoiceModelSerializerMixin, 47 | serializers.ModelSerializer 48 | ): 49 | class Meta: 50 | model = MyModelMultiple 51 | fields = ('enumerated_field', ) 52 | 53 | 54 | # Custom choice builder serializer 55 | class CustomChoiceBuilderSerializer(serializers.Serializer): 56 | enumerated_field = EnumChoiceField( 57 | MyEnum, 58 | choice_builder=custom_choice_builder 59 | ) 60 | -------------------------------------------------------------------------------- /examples/examples/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import environ 3 | 4 | env = environ.Env() 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | 8 | SECRET_KEY = 'examples' 9 | 10 | DEBUG = True 11 | 12 | ALLOWED_HOSTS = [] 13 | 14 | INSTALLED_APPS = [ 15 | 'django.contrib.admin', 16 | 'django.contrib.auth', 17 | 'django.contrib.contenttypes', 18 | 'django.contrib.sessions', 19 | 'django.contrib.messages', 20 | 'django.contrib.staticfiles', 21 | 'examples' 22 | ] 23 | 24 | MIDDLEWARE = [ 25 | 'django.middleware.security.SecurityMiddleware', 26 | 'django.contrib.sessions.middleware.SessionMiddleware', 27 | 'django.middleware.common.CommonMiddleware', 28 | 'django.middleware.csrf.CsrfViewMiddleware', 29 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 30 | 'django.contrib.messages.middleware.MessageMiddleware', 31 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 32 | ] 33 | 34 | TEMPLATES = [ 35 | { 36 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 37 | 'DIRS': [], 38 | 'APP_DIRS': True, 39 | 'OPTIONS': { 40 | 'context_processors': [ 41 | 'django.template.context_processors.debug', 42 | 'django.template.context_processors.request', 43 | 'django.contrib.auth.context_processors.auth', 44 | 'django.contrib.messages.context_processors.messages', 45 | ], 46 | }, 47 | }, 48 | ] 49 | 50 | ROOT_URLCONF = 'examples.urls' 51 | 52 | WSGI_APPLICATION = 'examples.wsgi.application' 53 | 54 | DATABASES = { 55 | 'default': env.db('EXAMPLES_DATABASE_URL', default='postgres:///django_enum_choices_examples') 56 | } 57 | 58 | LANGUAGE_CODE = 'en-us' 59 | 60 | TIME_ZONE = 'UTC' 61 | 62 | USE_I18N = True 63 | 64 | USE_L10N = True 65 | 66 | USE_TZ = True 67 | -------------------------------------------------------------------------------- /examples/examples/urls.py: -------------------------------------------------------------------------------- 1 | """examples URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | ] 22 | -------------------------------------------------------------------------------- /examples/examples/usage.py: -------------------------------------------------------------------------------- 1 | from .enumerations import MyEnum 2 | from .models import MyModel, MyModelMultiple 3 | from .serializers import ( 4 | MySerializer, 5 | MyModelSerializer, 6 | ImplicitMyModelSerializer, 7 | MultipleMySerializer, 8 | ImplicitMultipleMyModelSerializer, 9 | CustomChoiceBuilderSerializer 10 | ) 11 | from .forms import StandardEnumForm, ModelEnumForm, CustomChoiceBuilderEnumForm 12 | from .filters import ExplicitFilterSet, ExplicitCustomChoiceBuilderFilterSet, ImplicitFilterSet 13 | 14 | 15 | # Object Creation 16 | def create_instance(): 17 | return MyModel.objects.create(enumerated_field=MyEnum.A) 18 | 19 | 20 | # Overriding field value 21 | def update_instance(): 22 | instance = create_instance() 23 | instance.enumerated_field = MyEnum.B 24 | 25 | instance.save() 26 | 27 | return instance 28 | 29 | 30 | # Object creation with multiple field 31 | def create_instance_with_multiple_field(): 32 | return MyModelMultiple.objects.create(enumerated_field=[MyEnum.A, MyEnum.B]) 33 | 34 | 35 | # Overriding multiple field value 36 | def update_instance_with_multiple_field(): 37 | instance = create_instance_with_multiple_field() 38 | instance.enumerated_field = [MyEnum.B] 39 | 40 | instance.save() 41 | 42 | return instance 43 | 44 | 45 | # QuerySet filtering 46 | def filter_queryset(): 47 | return MyModel.objects.filter(enumerated_field=MyEnum.A) 48 | 49 | 50 | # Serializer usage 51 | def serialize_value(): 52 | serializer = MySerializer({ 53 | 'enumerated_field': MyEnum.A 54 | }) 55 | 56 | return serializer.data 57 | 58 | 59 | def deserialize_value(): 60 | serializer = MySerializer(data={ 61 | 'enumerated_field': 'a' 62 | }) 63 | serializer.is_valid() 64 | 65 | return serializer.validated_data 66 | 67 | 68 | # Explicit ModelSerializer usage 69 | def serialize_model_from_explicit_serializer(): 70 | instance = create_instance() 71 | serializer = MyModelSerializer(instance) 72 | 73 | return serializer.data 74 | 75 | 76 | def create_model_from_explicit_serializer(): 77 | serializer = MyModelSerializer(data={ 78 | 'enumerated_field': 'a' 79 | }) 80 | serializer.is_valid() 81 | 82 | return serializer.save() 83 | 84 | 85 | # Implicit ModelSerializer usage 86 | def serialize_model_from_implicit_serializer(): 87 | instance = create_instance() 88 | serializer = ImplicitMyModelSerializer(instance) 89 | 90 | return serializer.data 91 | 92 | 93 | def create_model_from_implicit_serializer(): 94 | serializer = ImplicitMyModelSerializer(data={ 95 | 'enumerated_field': 'a' 96 | }) 97 | serializer.is_valid() 98 | 99 | return serializer.save() 100 | 101 | 102 | # Multiple Standard Serializer Usage 103 | def serialize_multiple_value(): 104 | serializer = MultipleMySerializer({ 105 | 'enumerated_field': [MyEnum.A, MyEnum.B] 106 | }) 107 | 108 | return serializer.data 109 | 110 | 111 | def deserialize_multiple_value(): 112 | serializer = MultipleMySerializer(data={ 113 | 'enumerated_field': ['a', 'b'] 114 | }) 115 | serializer.is_valid() 116 | 117 | return serializer.validated_data 118 | 119 | 120 | # Implicit Multiple ModelSerializer usage 121 | def serialize_model_from_multiple_field_serializer(): 122 | instance = create_instance_with_multiple_field() 123 | serializer = ImplicitMultipleMyModelSerializer(instance) 124 | 125 | return serializer.data 126 | 127 | 128 | def create_model_from_multiple_field_serializer(): 129 | serializer = ImplicitMultipleMyModelSerializer(data={ 130 | 'enumerated_field': ['a', 'b'] 131 | }) 132 | serializer.is_valid() 133 | 134 | return serializer.save() 135 | 136 | 137 | def serialize_with_custom_choice_builder(): 138 | serializer = CustomChoiceBuilderSerializer({ 139 | 'enumerated_field': MyEnum.A 140 | }) 141 | 142 | return serializer.data 143 | 144 | 145 | def get_value_from_standard_form(): 146 | form = StandardEnumForm({ 147 | 'enumerated_field': 'a' 148 | }) 149 | 150 | form.is_valid() 151 | 152 | return form.cleaned_data 153 | 154 | 155 | def create_instance_from_model_form(): 156 | form = ModelEnumForm({ 157 | 'enumerated_field': 'a' 158 | }) 159 | 160 | form.is_valid() 161 | 162 | return form.save(commit=True) 163 | 164 | 165 | def get_value_from_form_with_custom_choice_builder_field(): 166 | form = CustomChoiceBuilderEnumForm({ 167 | 'enumerated_field': 'Custom_a' 168 | }) 169 | 170 | form.is_valid() 171 | 172 | return form.cleaned_data 173 | 174 | 175 | def filter_with_explicit_field(): 176 | for choice in MyEnum: 177 | MyModel.objects.create(enumerated_field=choice) 178 | 179 | filters = { 180 | 'enumerated_field': 'a' 181 | } 182 | filterset = ExplicitFilterSet(filters, MyModel.objects.all()) 183 | 184 | return filterset.qs.values_list('enumerated_field', flat=True) 185 | 186 | 187 | def filter_with_explicit_field_with_custom_choice_builder(): 188 | for choice in MyEnum: 189 | MyModel.objects.create(enumerated_field=choice) 190 | 191 | filters = { 192 | 'enumerated_field': 'Custom_a' 193 | } 194 | filterset = ExplicitCustomChoiceBuilderFilterSet(filters, MyModel.objects.all()) 195 | 196 | return filterset.qs.values_list('enumerated_field', flat=True) 197 | 198 | 199 | def filter_with_implicit_field(): 200 | for choice in MyEnum: 201 | MyModel.objects.create(enumerated_field=choice) 202 | 203 | filters = { 204 | 'enumerated_field': 'a' 205 | } 206 | filterset = ImplicitFilterSet(filters) 207 | 208 | return filterset.qs.values_list('enumerated_field', flat=True) 209 | -------------------------------------------------------------------------------- /examples/examples/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for examples project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'examples.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /examples/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'examples.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=tests.settings 3 | django_find_project = false 4 | python_paths = django_enum_choices 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.1.3 3 | commit = True 4 | 5 | [bumpversion:file:django_enum_choices/__version__.py] 6 | 7 | [flake8] 8 | max-line-length = 120 9 | exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,*/settings/*,.venv,*/e2e/* 10 | 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import sys 4 | from shutil import rmtree 5 | 6 | from setuptools import find_packages, setup, Command 7 | 8 | # Package meta-data. 9 | NAME = 'django_enum_choices' 10 | DESCRIPTION = "A custom Django field able to use subclasses of Python's internal `Enum` class as choices" 11 | URL = 'https://github.com/HackSoftware/django-enum-choices' 12 | EMAIL = 'vasil.slavov@hacksoft.io' 13 | AUTHOR = 'Vasil Slavov' 14 | REQUIRES_PYTHON = '>=3.5.0' 15 | VERSION = None 16 | 17 | # What packages are required for this module to be executed? 18 | REQUIRED = [ 19 | 'Django>=1.11' 20 | ] 21 | 22 | # What packages are optional? 23 | EXTRAS = { 24 | 'dev': [ 25 | 'Django==2.2.3', 26 | 'djangorestframework==3.9.4', 27 | 'psycopg2==2.8.3', 28 | 'flake8==3.7.7', 29 | 'pytest==4.6.3', 30 | 'pytest-django==3.5.0', 31 | 'pytest-pythonpath==0.7.3', 32 | 'django-environ==0.4.5', 33 | 'tox==3.13.2', 34 | 'bumpversion==0.5.3', 35 | 'tox-pyenv==1.1.0', 36 | 'django-filter==2.2.0' 37 | ] 38 | } 39 | 40 | # The rest you shouldn't have to touch too much :) 41 | # ------------------------------------------------ 42 | # Except, perhaps the License and Trove Classifiers! 43 | # If you do change the License, remember to change the Trove Classifier for that! 44 | 45 | here = os.path.abspath(os.path.dirname(__file__)) 46 | 47 | # Import the README and use it as the long-description. 48 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 49 | try: 50 | with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: 51 | long_description = '\n' + f.read() 52 | except FileNotFoundError: 53 | long_description = DESCRIPTION 54 | 55 | # Load the package's __version__.py module as a dictionary. 56 | about = {} 57 | if not VERSION: 58 | with open(os.path.join(here, NAME, '__version__.py')) as f: 59 | exec(f.read(), about) 60 | else: 61 | about['__version__'] = VERSION 62 | 63 | 64 | class UploadCommand(Command): 65 | """Support setup.py upload.""" 66 | 67 | description = 'Build and publish the package.' 68 | user_options = [] 69 | 70 | @staticmethod 71 | def status(s): 72 | """Prints things in bold.""" 73 | print('\033[1m{0}\033[0m'.format(s)) 74 | 75 | def initialize_options(self): 76 | pass 77 | 78 | def finalize_options(self): 79 | pass 80 | 81 | def run(self): 82 | try: 83 | self.status('Removing previous builds…') 84 | rmtree(os.path.join(here, 'dist')) 85 | except OSError: 86 | pass 87 | 88 | self.status('Building Source and Wheel (universal) distribution…') 89 | os.system('{0} setup.py sdist bdist_wheel --universal'.format(sys.executable)) 90 | 91 | self.status('Uploading the package to PyPI via Twine…') 92 | os.system('twine upload dist/*') 93 | 94 | self.status('Pushing git tags…') 95 | os.system('git tag v{0}'.format(about['__version__'])) 96 | os.system('git push --tags') 97 | 98 | sys.exit() 99 | 100 | 101 | # Where the magic happens: 102 | setup( 103 | name=NAME, 104 | version=about['__version__'], 105 | description=DESCRIPTION, 106 | long_description=long_description, 107 | long_description_content_type='text/markdown', 108 | author=AUTHOR, 109 | author_email=EMAIL, 110 | python_requires=REQUIRES_PYTHON, 111 | url=URL, 112 | packages=find_packages(exclude=('tests',)), 113 | # If your package is a single module, use this instead of 'packages': 114 | # py_modules=['mypackage'], 115 | 116 | # entry_points={ 117 | # 'console_scripts': ['mycli=mymodule:cli'], 118 | # }, 119 | install_requires=REQUIRED, 120 | extras_require=EXTRAS, 121 | include_package_data=True, 122 | license='MIT', 123 | classifiers=[ 124 | # Trove classifiers 125 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 126 | 'License :: OSI Approved :: MIT License', 127 | 'Programming Language :: Python', 128 | 'Programming Language :: Python :: 3', 129 | 'Programming Language :: Python :: 3.5', 130 | 'Programming Language :: Python :: 3.6', 131 | 'Programming Language :: Python :: 3.7', 132 | 'Programming Language :: Python :: Implementation :: CPython', 133 | 'Framework :: Django', 134 | 'Framework :: Django :: 1.11', 135 | 'Framework :: Django :: 2.1', 136 | 'Framework :: Django :: 2.2', 137 | 'Operating System :: OS Independent', 138 | 'Intended Audience :: Developers', 139 | 'Topic :: Software Development :: Libraries' 140 | ], 141 | # $ setup.py publish support. 142 | cmdclass={ 143 | 'upload': UploadCommand, 144 | }, 145 | ) 146 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint-py{37,38} 4 | django31-py{38,37,36} 5 | django30-py{38,37,36} 6 | django22-py{38,37,36} 7 | django21-py{38,37,36,35} 8 | django111-py{38,37,36,35} 9 | 10 | [testenv] 11 | deps = 12 | {[base]deps} 13 | django31: {[django]3.1} 14 | django30: {[django]3.0} 15 | django22: {[django]2.2} 16 | django21: {[django]2.1} 17 | django111: {[django]1.11} 18 | commands = pytest 19 | setenv = 20 | DATABASE_URL = {env:DATABASE_URL:postgres:///django_enum_choices} 21 | 22 | [testenv:lint-py37] 23 | deps = 24 | flake8 25 | commands = flake8 django_enum_choices/ 26 | 27 | [testenv:lint-py38] 28 | deps = 29 | flake8 30 | commands = flake8 django_enum_choices/ 31 | 32 | [base] 33 | deps = 34 | pytest 35 | pytest-django 36 | pytest-pythonpath 37 | django-environ 38 | psycopg2 39 | 40 | [django] 41 | 3.1 = 42 | Django>=3.1.0,<3.2.0 43 | djangorestframework>=3.7.3 44 | django-filter>=2.2.0 45 | 3.0 = 46 | Django>=3.0.0,<3.1.0 47 | djangorestframework>=3.7.3 48 | django-filter>=2.2.0 49 | 2.2 = 50 | Django>=2.2.0,<2.2.17 51 | djangorestframework>=3.7.3 52 | django-filter>=2.2.0 53 | 2.1 = 54 | Django>=2.1.0,<2.2.0 55 | djangorestframework>=3.7.3 56 | django-filter>=2.2.0 57 | 1.11 = 58 | Django>=1.11.0,<2.0.0 59 | djangorestframework>=3.6.2,<3.9.0 60 | django-filter>=2.2.0 61 | --------------------------------------------------------------------------------