Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: editing of FlagField via EnumFlagField form field #97

Merged
merged 4 commits into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ rest = ["djangorestframework>=3.9,<4.0"]

[dependency-groups]
dev = [
"doc8>=1.1.2",
"beautifulsoup4>=4.13.3",
"coverage>=7.6.12",
"darglint>=1.8.1",
Expand Down Expand Up @@ -104,7 +105,6 @@ dev = [
"typing-extensions>=4.12.2",
]
docs = [
"doc8>=1.1.2",
"docutils>=0.21.2",
"furo>=2024.8.6",
"readme-renderer[md]>=44.0",
Expand Down
9 changes: 6 additions & 3 deletions src/django_enum/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,17 +673,20 @@ def formfield(self, form_class=None, choices_form_class=None, **kwargs):
)

is_multi = self.enum and issubclass(self.enum, Flag)
if is_multi and self.enum:
if is_multi:
kwargs["empty_value"] = self.enum(0)
# why fail? - does this fail for single select too?
# kwargs['show_hidden_initial'] = True

if not self.strict:
kwargs.setdefault(
"widget", NonStrictSelectMultiple if is_multi else NonStrictSelect
"widget",
NonStrictSelectMultiple(enum=self.enum)
if is_multi
else NonStrictSelect,
)
elif is_multi:
kwargs.setdefault("widget", FlagSelectMultiple)
kwargs.setdefault("widget", FlagSelectMultiple(enum=self.enum))

form_field = super().formfield(
form_class=form_class,
Expand Down
51 changes: 50 additions & 1 deletion src/django_enum/forms.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""Enumeration support for django model forms"""

import sys
from copy import copy
from decimal import DecimalException
from enum import Enum, Flag
from functools import reduce
from operator import or_
from typing import Any, Iterable, List, Optional, Protocol, Sequence, Tuple, Type, Union

from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -85,8 +88,39 @@ class FlagSelectMultiple(SelectMultiple):
A SelectMultiple widget for EnumFlagFields.
"""

enum: Optional[Type[Flag]]

class NonStrictSelectMultiple(NonStrictMixin, SelectMultiple):
def __init__(self, enum: Optional[Type[Flag]] = None, **kwargs):
self.enum = enum
super().__init__(**kwargs)

def format_value(self, value):
"""
Return a list of the flag's values.
"""
if not isinstance(value, list):
# see impl of ChoiceWidget.optgroups
# it compares the string conversion of the value of each
# choice tuple to the string conversion of the value
# to determine selected options
if self.enum:
if sys.version_info < (3, 11):
return [
str(flg.value)
for flg in self.enum
if flg in self.enum(value) and flg is not self.enum(0)
]
else:
return [str(en.value) for en in self.enum(value)]
if isinstance(value, int):
# automagically work for IntFlags even if we weren't given the enum
return [
str(1 << i) for i in range(value.bit_length()) if (value >> i) & 1
]
return value


class NonStrictSelectMultiple(NonStrictMixin, FlagSelectMultiple):
"""
A SelectMultiple widget for non-strict EnumFlagFields that includes any
existing non-conforming value as a choice option.
Expand Down Expand Up @@ -314,6 +348,8 @@ class EnumFlagField(ChoiceFieldMixin, TypedMultipleChoiceField): # type: ignore
if strict=False, values can be outside of the enumerations
"""

widget = FlagSelectMultiple

def __init__(
self,
enum: Optional[Type[Flag]] = None,
Expand All @@ -324,6 +360,10 @@ def __init__(
choices: _ChoicesParameter = (),
**kwargs,
):
kwargs.setdefault(
"widget",
self.widget(enum=enum) if strict else NonStrictSelectMultiple(enum=enum),
)
super().__init__(
enum=enum,
empty_value=(
Expand All @@ -334,3 +374,12 @@ def __init__(
choices=choices,
**kwargs,
)

def _coerce(self, value: Any) -> Any:
"""Combine the values into a single flag using |"""
if self.enum and isinstance(value, self.enum):
return value
values = TypedMultipleChoiceField._coerce(self, value) # type: ignore[attr-defined]
if values:
return reduce(or_, values)
return self.empty_value
98 changes: 97 additions & 1 deletion tests/test_forms_ep.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

pytest.importorskip("enum_properties")
from tests.test_forms import FormTests, TestFormField
from tests.enum_prop.models import EnumTester
from tests.enum_prop.models import EnumTester, BitFieldModel
from tests.enum_prop.forms import EnumTesterForm
from tests.examples.models import FlagExample
from django_enum.forms import EnumFlagField, FlagSelectMultiple
from django.forms import ModelForm


class EnumPropertiesFormTests(FormTests):
Expand Down Expand Up @@ -34,6 +37,99 @@ def model_params(self):
"no_coerce": "Value 1",
}

def test_flag_choices_admin_form(self):
from django.contrib import admin

admin_class = admin.site._registry.get(BitFieldModel)
self.assertIsInstance(
admin_class.get_form(None).base_fields.get("bit_field_small"), EnumFlagField
)

def test_flag_choices_model_form(self):
from tests.examples.models.flag import Permissions
from tests.enum_prop.enums import GNSSConstellation

class FlagChoicesModelForm(ModelForm):
class Meta(EnumTesterForm.Meta):
model = BitFieldModel

form = FlagChoicesModelForm(
data={"bit_field_small": [GNSSConstellation.GPS, GNSSConstellation.GLONASS]}
)

form.full_clean()
self.assertTrue(form.is_valid())
self.assertEqual(
form.cleaned_data["bit_field_small"],
GNSSConstellation.GPS | GNSSConstellation.GLONASS,
)
self.assertIsInstance(form.base_fields["bit_field_small"], EnumFlagField)

def test_extern_flag_admin_form(self):
from django.contrib import admin

admin_class = admin.site._registry.get(FlagExample)
self.assertIsInstance(
admin_class.get_form(None).base_fields.get("permissions"), EnumFlagField
)

def test_extern_flag_model_form(self):
from tests.examples.models.flag import Permissions

class FlagModelForm(ModelForm):
class Meta(EnumTesterForm.Meta):
model = FlagExample

form = FlagModelForm(
data={"permissions": [Permissions.READ, Permissions.WRITE]}
)

form.full_clean()
self.assertTrue(form.is_valid())
self.assertEqual(
form.cleaned_data["permissions"], Permissions.READ | Permissions.WRITE
)
self.assertIsInstance(form.base_fields["permissions"], EnumFlagField)

def test_flag_select_multiple_format(self):
from tests.examples.models.flag import Permissions

widget = FlagSelectMultiple() # no enum
self.assertEqual(
widget.format_value(Permissions.READ | Permissions.WRITE),
[str(Permissions.READ.value), str(Permissions.WRITE.value)],
)
self.assertEqual(
widget.format_value(Permissions.READ | Permissions.EXECUTE),
[str(Permissions.READ.value), str(Permissions.EXECUTE.value)],
)
self.assertEqual(
widget.format_value(Permissions.EXECUTE | Permissions.WRITE),
[str(Permissions.WRITE.value), str(Permissions.EXECUTE.value)],
)

widget = FlagSelectMultiple(enum=Permissions) # no enum
self.assertEqual(
widget.format_value(Permissions.READ | Permissions.WRITE),
[str(Permissions.READ.value), str(Permissions.WRITE.value)],
)
self.assertEqual(
widget.format_value(Permissions.READ | Permissions.EXECUTE),
[str(Permissions.READ.value), str(Permissions.EXECUTE.value)],
)
self.assertEqual(
widget.format_value(Permissions.EXECUTE | Permissions.WRITE),
[str(Permissions.WRITE.value), str(Permissions.EXECUTE.value)],
)

# check pass through
self.assertEqual(
widget.format_value(
[str(Permissions.WRITE.value), str(Permissions.EXECUTE.value)]
),
[str(Permissions.WRITE.value), str(Permissions.EXECUTE.value)],
)


FormTests = None
TestFormField = None
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.