Skip to content

Commit

Permalink
Improvements in ExtendedChoices enum creation + bootstrap widget
Browse files Browse the repository at this point in the history
  • Loading branch information
christophehenry committed Sep 4, 2024
1 parent f4d87da commit 5658270
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 88 deletions.
189 changes: 107 additions & 82 deletions dsfr/enums.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import functools
from typing import Any

from django.db import models
from django.db.models import TextChoices
from django.utils.safestring import mark_safe
Expand All @@ -12,13 +9,53 @@
from types import DynamicClassAttribute as enum_property


def _is_dunder(name):
"""
Returns True if a __dunder__ name, False otherwise.
"""
return (
len(name) > 4
and name[:2] == name[-2:] == "__"
and name[2] != "_"
and name[-3] != "_"
)


def _is_sunder(name):
"""
Returns True if a _sunder_ name, False otherwise.
"""
return (
len(name) > 2
and name[0] == name[-1] == "_"
and name[1:2] != "_"
and name[-2:-1] != "_"
)


class _ExtendedChoicesType(models.enums.ChoicesType):
def __new__(metacls, classname, bases, classdict, **kwds):
dynamic_attributes = {}
for member in classdict._member_names:
value = classdict[member]
if isinstance(value, dict):
value = value.copy()
@classmethod
def __prepare__(metacls, cls, bases, **kwds):
classdict = super().__prepare__(cls, bases, **kwds)

class _EnumDict(classdict.__class__):
def __init__(self, old_classdict):
super().__init__()
self._additional_attributes = {}
# Copy absent old_classdict members into self
for member_name, member_value in old_classdict.__dict__.items():
self.__dict__.setdefault(member_name, member_value)

# Copy dict content
self.update(old_classdict)

def __setitem__(self, member, value):
"""Allows to handle declaring enum members as dicts"""
# _additional_attributes is also a dict, but we don't want it to be
# processed like an enum member
if not isinstance(value, dict):
return super().__setitem__(member, value)

if "value" not in value:
raise ValueError(
"enum value for {member} should contain member 'value' "
Expand All @@ -27,92 +64,82 @@ def __new__(metacls, classname, bases, classdict, **kwds):
)
)

dict.__setitem__(classdict, member, metacls.get_value_from_dict(value))
value.pop("value")
value.pop("label", None)
if "label" in value:
super().__setitem__(
member, (value.pop("value"), value.pop("label"))
)
else:
super().__setitem__(member, value.pop("value"))

for k, v in value.items():
if metacls.is_sunder(k) or metacls.is_dunder(k):
for attr_name, attr_value in value.items():
if _is_sunder(attr_name) or _is_dunder(attr_name):
raise ValueError(
(
"enum value for {member} contains key {key}. "
"Names surrounded with single or double underscores are "
"not authorized as dict values"
).format(member=member, key=k)
).format(member=member, key=attr_name)
)
dynamic_attributes.setdefault(k, {})
dynamic_attributes[k][member] = v

classdict._last_values = [
metacls.get_value_from_dict(item) for item in classdict._last_values
]

cls = super().__new__(metacls, classname, bases, classdict, **kwds)
self._additional_attributes.setdefault(attr_name, {})
self._additional_attributes[attr_name][member] = attr_value

metacls.set_dynamic_attributes(cls, dynamic_attributes)
return _EnumDict(classdict)

return cls
def __new__(metacls, classname, bases, classdict, **kwds):

@staticmethod
def set_dynamic_attributes(cls, dynamic_attributes: dict[str, dict[str, Any]]):
cls.NO_VALUE = object()
cls = super().__new__(metacls, classname, bases, classdict, **kwds)
cls._additional_attributes = list(classdict._additional_attributes.keys())

for k, v in dynamic_attributes.items():
variable = "_{}_".format(k)
for attr_name, attr_value in classdict._additional_attributes.items():
private_name = "__{}".format(attr_name)
for instance in cls:
if hasattr(instance, variable):
if hasattr(instance, private_name):
raise ValueError(
(
"Can't set {} on {} members; please choose a different name "
"Can't set {} on {}.{}; please choose a different name "
"or remove from the member value"
).format(variable, cls.__name__)
).format(attr_name, cls.__name__, instance.name)
)
setattr(instance, variable, v.get(instance.name, cls.NO_VALUE))

def _getter(name, self):
result = getattr(self, name, cls.NO_VALUE)
if result is cls.NO_VALUE:
raise AttributeError(
"{} not present in {}.{}".format(
variable, cls.__name__, self.name
)
)
return result

setattr(cls, k, enum_property(functools.partial(_getter, variable)))

@staticmethod
def get_value_from_dict(value):
if not isinstance(value, dict):
return value
elif "label" in value:
return value["value"], value["label"]
else:
return value["value"]

@staticmethod
def is_dunder(name):
"""
Returns True if a __dunder__ name, False otherwise.
"""
return (
len(name) > 4
and name[:2] == name[-2:] == "__"
and name[2] != "_"
and name[-3] != "_"
)
if instance.name in attr_value:
setattr(instance, private_name, attr_value[instance.name])

def default_dynamic_attribute_value(enum_item, name):
raise NotImplementedError(
(
"{}.{} does not contain key {}. Please add key or implement "
"a 'dynamic_attribute_value(cls, instance, name)' classmethod in you enum "
"to provide the value"
).format(cls.__name__, instance.name, cls.__name__)
)

def instance_property_getter(name, default_value_getter):
@enum_property
def _instance_property_getter(enum_item):
if hasattr(enum_item, private_name):
return getattr(enum_item, private_name)
else:
return default_value_getter(enum_item, name)

return _instance_property_getter

setattr(
cls,
attr_name,
instance_property_getter(
attr_name,
classdict.get(
"dynamic_attribute_value",
default_dynamic_attribute_value,
),
),
)

@staticmethod
def is_sunder(name):
"""
Returns True if a _sunder_ name, False otherwise.
"""
return (
len(name) > 2
and name[0] == name[-1] == "_"
and name[1:2] != "_"
and name[-2:-1] != "_"
)
return cls

@property
def additional_attributes(cls):
"""Enum additionnal attributes that were set from dict"""
return cls._additional_attributes


class ExtendedChoices(models.Choices, metaclass=_ExtendedChoicesType):
Expand All @@ -122,12 +149,10 @@ class ExtendedChoices(models.Choices, metaclass=_ExtendedChoicesType):
class RichRadioButtonChoices(ExtendedChoices, TextChoices):
@enum_property
def pictogram(self):
return self._pictogram_ if hasattr(self, "_pictogram_") else ""
return self._pictogram if hasattr(self, "_pictogram") else ""

@enum_property
def html_label(self):
return (
mark_safe(self._html_label_)
if hasattr(self, "_html_label_")
else self.label
mark_safe(self._html_label) if hasattr(self, "_html_label") else self.label
)
15 changes: 15 additions & 0 deletions dsfr/templates/dsfr/widgets/radio_option.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="fr-fieldset__element">
<div class="fr-radio-group fr-radio-rich">
<input value="1" type="radio" id="radio-rich-1" name="radio-rich">
<label class="fr-label" for="radio-rich-1">
Libellé bouton radio
</label>
<div class="fr-radio-rich__pictogram">
<svg aria-hidden="true" class="fr-artwork" viewBox="0 0 80 80" width="80px" height="80px">
<use class="fr-artwork-decorative" href="../../../dist/artwork/pictograms/buildings/city-hall.svg#artwork-decorative"></use>
<use class="fr-artwork-minor" href="../../../dist/artwork/pictograms/buildings/city-hall.svg#artwork-minor"></use>
<use class="fr-artwork-major" href="../../../dist/artwork/pictograms/buildings/city-hall.svg#artwork-major"></use>
</svg>
</div>
</div>
</div>
9 changes: 9 additions & 0 deletions dsfr/templates/dsfr/widgets/rich_radio.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% with id=widget.attrs.id %}<fieldset{% if id %} id="{{ id }}"{% endif %} class="fr-fieldset{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}">
{% for group, options, index in widget.optgroups %}
{% if group %}<div><legend class="fr-fieldset__legend{% if widget.attrs.legend_classes %} {{ widget.attrs.legend_classes }}{% endif %}">{{ group }}</legend>{% endif %}
{% for option in options %}
<div>{% include option.template_name with widget=option %}</div>
{% endfor %}
{% if group %}</div>{% endif %}
{% endfor %}
</fieldset>{% endwith %}
22 changes: 22 additions & 0 deletions dsfr/test/test_enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from django.test import SimpleTestCase
from dsfr.utils import generate_random_id, generate_summary_items


class ExtendedChoicesTestCase(SimpleTestCase):
def test_create_dict_enum(self):
...

def test_absent_value_key_raises_error(self):
...

def test_absent_label_computes_default(self):
...

def test_absent_additionnal_key_calls_dynamic_attribute_value_method(self):
...

def test_absent_additionnal_key_no_dynamic_attribute_value_method(self):
...

def test_additional_attributes(self):
...
8 changes: 8 additions & 0 deletions dsfr/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import functools

from django.conf.urls.static import static
from django.forms import BoundField, widgets
from django.core.paginator import Page
from django.utils.functional import keep_lazy_text
from django.utils.text import slugify
import random
import string
Expand Down Expand Up @@ -127,3 +131,7 @@ def dsfr_input_class_attr(bf: BoundField):
):
bf.field.widget.attrs["class"] = "fr-input"
return bf


def lazy_static(path):
return keep_lazy_text(functools.partial(static, path))
47 changes: 41 additions & 6 deletions dsfr/widgets.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,53 @@
from typing import Type

from django.forms.widgets import ChoiceWidget
from django.forms.widgets import RadioSelect, ChoiceWidget, CheckboxSelectMultiple

from dsfr.enums import RichRadioButtonChoices


class RichRadioButtonWidget(ChoiceWidget):
def __init__(
self, rich_choices: Type[RichRadioButtonChoices], attrs=None
):
super().__init__(attrs, rich_choices.choices)
class _RichChoiceWidget(ChoiceWidget):
def __init__(self, rich_choices: Type[RichRadioButtonChoices], attrs=None):
super().__init__(attrs)
self.rich_choices = rich_choices

@property
def choices(self):
return self.rich_choices.choices

@choices.setter
def choices(self, value):
"""
Superseded by self.rich_choices;
kept for compatibility with ChoiceWidget.__init__
"""
...

def __deepcopy__(self, memo):
obj = super().__deepcopy__(memo)
obj.rich_choices = self.rich_choices
return obj

def create_option(
self, name, value, label, selected, index, subindex=None, attrs=None
):
opt = super().create_option(
name, value, label, selected, index, subindex, attrs
)

opt.update(
{
k: getattr(self.rich_choices(value), k)
for k in self.rich_choices.additional_attributes
}
)

return opt


class RichRadioSelect(_RichChoiceWidget, RadioSelect):
template_name = "dsfr/widgets/rich_radio.html"
option_template_name = "dsfr/widgets/rich_radio_option.html"


class RichCheckboxSelect(_RichChoiceWidget, CheckboxSelectMultiple):
...
Loading

0 comments on commit 5658270

Please sign in to comment.