diff --git a/dsfr/enums.py b/dsfr/enums.py index b0df178fd..fdd7fa011 100644 --- a/dsfr/enums.py +++ b/dsfr/enums.py @@ -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 @@ -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' " @@ -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): @@ -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 ) diff --git a/dsfr/templates/dsfr/widgets/radio_option.html b/dsfr/templates/dsfr/widgets/radio_option.html new file mode 100644 index 000000000..0b8fa01fc --- /dev/null +++ b/dsfr/templates/dsfr/widgets/radio_option.html @@ -0,0 +1,15 @@ +
+
+ + +
+ +
+
+
\ No newline at end of file diff --git a/dsfr/templates/dsfr/widgets/rich_radio.html b/dsfr/templates/dsfr/widgets/rich_radio.html new file mode 100644 index 000000000..d1333ae4c --- /dev/null +++ b/dsfr/templates/dsfr/widgets/rich_radio.html @@ -0,0 +1,9 @@ +{% with id=widget.attrs.id %} + {% for group, options, index in widget.optgroups %} + {% if group %}
{{ group }}{% endif %} + {% for option in options %} +
{% include option.template_name with widget=option %}
+ {% endfor %} + {% if group %}
{% endif %} + {% endfor %} +{% endwith %} diff --git a/dsfr/test/test_enums.py b/dsfr/test/test_enums.py new file mode 100644 index 000000000..bf29f8abb --- /dev/null +++ b/dsfr/test/test_enums.py @@ -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): + ... \ No newline at end of file diff --git a/dsfr/utils.py b/dsfr/utils.py index 1ebe9b410..5587e1e87 100644 --- a/dsfr/utils.py +++ b/dsfr/utils.py @@ -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 @@ -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)) diff --git a/dsfr/widgets.py b/dsfr/widgets.py index 0e67e97ff..7eb1e7693 100644 --- a/dsfr/widgets.py +++ b/dsfr/widgets.py @@ -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): + ... diff --git a/example_app/forms.py b/example_app/forms.py index 61c68901d..99438b414 100644 --- a/example_app/forms.py +++ b/example_app/forms.py @@ -1,19 +1,53 @@ +import functools +from enum import auto, FlagBoundary + from django import forms +from django.db.models import TextChoices from django.forms import ( ModelForm, inlineformset_factory, ) # /!\ In order to use formsets +from django.templatetags.static import static +from django.utils.functional import keep_lazy_text from dsfr.constants import COLOR_CHOICES, COLOR_CHOICES_ILLUSTRATION +from dsfr.enums import RichRadioButtonChoices from dsfr.forms import DsfrBaseForm # /!\ In order to use formsets from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Fieldset, Field +from dsfr.utils import lazy_static +from dsfr.widgets import RichRadioSelect from example_app.models import Author, Book from example_app.utils import populate_genre_choices +class Lol(TextChoices): + WEEE = auto(), "Weee" + OOOH = auto(), "Oooh" + + +class ExampleRichChoices(RichRadioButtonChoices): + ITEM_1 = { + "value": auto(), + "label": "Item 1", + "html_label": "Item 1", + "pictogram": lazy_static("img/placeholder.1x1.png"), + } + ITEM_2 = { + "value": auto(), + "label": "Item 2", + "html_label": "Item 2", + "pictogram": lazy_static("img/placeholder.1x1.png"), + } + ITEM_3 = { + "value": auto(), + "label": "Item 3", + "html_label": "Item 3", + "pictogram": lazy_static("img/placeholder.1x1.png"), + } + class ExampleForm(DsfrBaseForm): # basic fields @@ -94,6 +128,14 @@ class ExampleForm(DsfrBaseForm): widget=forms.CheckboxSelectMultiple, ) + sample_rich_radio = forms.ChoiceField( + label="Cases à cocher", + required=False, + choices=ExampleRichChoices.choices, + help_text="Exemple de boutons radios riches", + widget=RichRadioSelect(rich_choices=ExampleRichChoices), + ) + # text blocks sample_comment = forms.CharField(widget=forms.Textarea, required=False)