Skip to content

Commit 3a99c39

Browse files
authored
[feature] Added fallback fields openwisp#333
Closes openwisp#333
1 parent 9d3cb2e commit 3a99c39

File tree

9 files changed

+616
-34
lines changed

9 files changed

+616
-34
lines changed

README.rst

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -897,10 +897,138 @@ Model class inheriting ``UUIDModel`` which provides two additional fields:
897897
Which use respectively ``AutoCreatedField``, ``AutoLastModifiedField`` from ``model_utils.fields``
898898
(self-updating fields providing the creation date-time and the last modified date-time).
899899

900-
``openwisp_utils.base.KeyField``
901-
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
900+
``openwisp_utils.base.FallBackModelMixin``
901+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
902+
903+
Model mixin that implements ``get_field_value`` method which can be used
904+
to get value of fallback fields.
905+
906+
Custom Fields
907+
-------------
908+
909+
This section describes custom fields defined in ``openwisp_utils.fields``
910+
that can be used in Django models:
911+
912+
``openwisp_utils.fields.KeyField``
913+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
914+
915+
A model field which provides a random key or token, widely used across openwisp modules.
916+
917+
``openwisp_utils.fields.FallbackBooleanChoiceField``
918+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
919+
920+
This field extends Django's `BooleanField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#booleanfield>`_
921+
and provides additional functionality for handling choices with a fallback value.
922+
The field will use the **fallback value** whenever the field is set to ``None``.
923+
924+
This field is particularly useful when you want to present a choice between enabled
925+
and disabled options, with an additional "Default" option that reflects the fallback value.
926+
927+
.. code-block:: python
928+
929+
from django.db import models
930+
from openwisp_utils.fields import FallbackBooleanChoiceField
931+
from myapp import settings as app_settings
932+
933+
class MyModel(models.Model):
934+
is_active = FallbackBooleanChoiceField(
935+
null=True,
936+
blank=True,
937+
default=None,
938+
fallback=app_settings.IS_ACTIVE_FALLBACK,
939+
)
940+
941+
``openwisp_utils.fields.FallbackCharChoiceField``
942+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
943+
944+
This field extends Django's `CharField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#charfield>`_
945+
and provides additional functionality for handling choices with a fallback value.
946+
The field will use the **fallback value** whenever the field is set to ``None``.
947+
948+
.. code-block:: python
949+
950+
from django.db import models
951+
from openwisp_utils.fields import FallbackCharChoiceField
952+
from myapp import settings as app_settings
953+
954+
class MyModel(models.Model):
955+
is_first_name_required = FallbackCharChoiceField(
956+
null=True,
957+
blank=True,
958+
max_length=32,
959+
choices=(
960+
('disabled', _('Disabled')),
961+
('allowed', _('Allowed')),
962+
('mandatory', _('Mandatory')),
963+
),
964+
fallback=app_settings.IS_FIRST_NAME_REQUIRED,
965+
)
966+
967+
``openwisp_utils.fields.FallbackCharField``
968+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
969+
970+
This field extends Django's `CharField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#charfield>`_
971+
and provides additional functionality for handling text fields with a fallback value.
902972

903-
A model field whic provides a random key or token, widely used across openwisp modules.
973+
It allows populating the form with the fallback value when the actual value is set to ``null`` in the database.
974+
975+
.. code-block:: python
976+
977+
from django.db import models
978+
from openwisp_utils.fields import FallbackCharField
979+
from myapp import settings as app_settings
980+
981+
class MyModel(models.Model):
982+
greeting_text = FallbackCharField(
983+
null=True,
984+
blank=True,
985+
max_length=200,
986+
fallback=app_settings.GREETING_TEXT,
987+
)
988+
989+
``openwisp_utils.fields.FallbackURLField``
990+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
991+
992+
This field extends Django's `URLField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#urlfield>`_
993+
and provides additional functionality for handling URL fields with a fallback value.
994+
995+
It allows populating the form with the fallback value when the actual value is set to ``null`` in the database.
996+
997+
.. code-block:: python
998+
999+
from django.db import models
1000+
from openwisp_utils.fields import FallbackURLField
1001+
from myapp import settings as app_settings
1002+
1003+
class MyModel(models.Model):
1004+
password_reset_url = FallbackURLField(
1005+
null=True,
1006+
blank=True,
1007+
max_length=200,
1008+
fallback=app_settings.DEFAULT_PASSWORD_RESET_URL,
1009+
)
1010+
1011+
``openwisp_utils.fields.FallbackTextField``
1012+
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1013+
1014+
This extends Django's `TextField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#django.db.models.TextField>`_
1015+
and provides additional functionality for handling text fields with a fallback value.
1016+
1017+
It allows populating the form with the fallback value when the actual value is set to ``null`` in the database.
1018+
1019+
.. code-block:: python
1020+
1021+
from django.db import models
1022+
from openwisp_utils.fields import FallbackTextField
1023+
from myapp import settings as app_settings
1024+
1025+
class MyModel(models.Model):
1026+
extra_config = FallbackTextField(
1027+
null=True,
1028+
blank=True,
1029+
max_length=200,
1030+
fallback=app_settings.EXTRA_CONFIG,
1031+
)
9041032
9051033
Admin utilities
9061034
---------------

openwisp_utils/base.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
from django.db import models
44
from django.utils.translation import gettext_lazy as _
55
from model_utils.fields import AutoCreatedField, AutoLastModifiedField
6-
from openwisp_utils.utils import get_random_key
7-
from openwisp_utils.validators import key_validator
6+
7+
# For backward compatibility
8+
from .fields import KeyField # noqa
89

910

1011
class UUIDModel(models.Model):
@@ -27,28 +28,10 @@ class Meta:
2728
abstract = True
2829

2930

30-
class KeyField(models.CharField):
31-
default_callable = get_random_key
32-
default_validators = [key_validator]
33-
34-
def __init__(
35-
self,
36-
max_length: int = 64,
37-
unique: bool = False,
38-
db_index: bool = False,
39-
help_text: str = None,
40-
default: [str, callable, None] = default_callable,
41-
validators: list = default_validators,
42-
*args,
43-
**kwargs
44-
):
45-
super().__init__(
46-
max_length=max_length,
47-
unique=unique,
48-
db_index=db_index,
49-
help_text=help_text,
50-
default=default,
51-
validators=validators,
52-
*args,
53-
**kwargs
54-
)
31+
class FallbackModelMixin(object):
32+
def get_field_value(self, field_name):
33+
value = getattr(self, field_name)
34+
field = self._meta.get_field(field_name)
35+
if value is None and hasattr(field, 'fallback'):
36+
return field.fallback
37+
return value

openwisp_utils/fields.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
from django import forms
2+
from django.db.models.fields import BooleanField, CharField, TextField, URLField
3+
from django.utils.translation import gettext_lazy as _
4+
from openwisp_utils.utils import get_random_key
5+
from openwisp_utils.validators import key_validator
6+
7+
8+
class KeyField(CharField):
9+
default_callable = get_random_key
10+
default_validators = [key_validator]
11+
12+
def __init__(
13+
self,
14+
max_length: int = 64,
15+
unique: bool = False,
16+
db_index: bool = False,
17+
help_text: str = None,
18+
default: [str, callable, None] = default_callable,
19+
validators: list = default_validators,
20+
*args,
21+
**kwargs,
22+
):
23+
super().__init__(
24+
max_length=max_length,
25+
unique=unique,
26+
db_index=db_index,
27+
help_text=help_text,
28+
default=default,
29+
validators=validators,
30+
*args,
31+
**kwargs,
32+
)
33+
34+
35+
class FallbackMixin(object):
36+
def __init__(self, *args, **kwargs):
37+
self.fallback = kwargs.pop('fallback', None)
38+
super().__init__(*args, **kwargs)
39+
40+
def deconstruct(self):
41+
name, path, args, kwargs = super().deconstruct()
42+
kwargs['fallback'] = self.fallback
43+
return (name, path, args, kwargs)
44+
45+
46+
class FallbackFromDbValueMixin:
47+
"""
48+
Returns the fallback value when the value of the field
49+
is falsy (None or '').
50+
51+
It does not set the field's value to "None" when the value
52+
is equal to the fallback value. This allows overriding of
53+
the value when a user knows that the default will get changed.
54+
"""
55+
56+
def from_db_value(self, value, expression, connection):
57+
if value is None:
58+
return self.fallback
59+
return value
60+
61+
62+
class FalsyValueNoneMixin:
63+
"""
64+
If the field contains an empty string, then
65+
stores "None" in the database if the field is
66+
nullable.
67+
"""
68+
69+
# Django convention is to use the empty string, not NULL
70+
# for representing "no data" in the database.
71+
# https://docs.djangoproject.com/en/dev/ref/models/fields/#null
72+
# We need to use NULL for fallback field here to keep
73+
# the fallback logic simple. Hence, we allow only "None" (NULL)
74+
# as empty value here.
75+
empty_values = [None]
76+
77+
def clean(self, value, model_instance):
78+
if not value and self.null is True:
79+
return None
80+
return super().clean(value, model_instance)
81+
82+
83+
class FallbackBooleanChoiceField(FallbackMixin, BooleanField):
84+
def formfield(self, **kwargs):
85+
default_value = _('Enabled') if self.fallback else _('Disabled')
86+
kwargs.update(
87+
{
88+
"form_class": forms.NullBooleanField,
89+
'widget': forms.Select(
90+
choices=[
91+
(
92+
'',
93+
_('Default') + f' ({default_value})',
94+
),
95+
(True, _('Enabled')),
96+
(False, _('Disabled')),
97+
]
98+
),
99+
}
100+
)
101+
return super().formfield(**kwargs)
102+
103+
104+
class FallbackCharChoiceField(FallbackMixin, CharField):
105+
def get_choices(self, **kwargs):
106+
for choice, value in self.choices:
107+
if choice == self.fallback:
108+
default = value
109+
break
110+
kwargs.update({'blank_choice': [('', _('Default') + f' ({default})')]})
111+
return super().get_choices(**kwargs)
112+
113+
def formfield(self, **kwargs):
114+
kwargs.update(
115+
{
116+
"choices_form_class": forms.TypedChoiceField,
117+
}
118+
)
119+
return super().formfield(**kwargs)
120+
121+
122+
class FallbackCharField(
123+
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, CharField
124+
):
125+
"""
126+
Populates the form with the fallback value
127+
if the value is set to null in the database.
128+
"""
129+
130+
pass
131+
132+
133+
class FallbackURLField(
134+
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, URLField
135+
):
136+
"""
137+
Populates the form with the fallback value
138+
if the value is set to null in the database.
139+
"""
140+
141+
pass
142+
143+
144+
class FallbackTextField(
145+
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, TextField
146+
):
147+
"""
148+
Populates the form with the fallback value
149+
if the value is set to null in the database.
150+
"""
151+
152+
def formfield(self, **kwargs):
153+
kwargs.update({'form_class': FallbackTextFormField})
154+
return super().formfield(**kwargs)
155+
156+
157+
class FallbackTextFormField(forms.CharField):
158+
def widget_attrs(self, widget):
159+
attrs = super().widget_attrs(widget)
160+
attrs.update({'rows': 2, 'cols': 34, 'style': 'width:auto'})
161+
return attrs

tests/test_project/admin.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@
1616
SimpleInputFilter,
1717
)
1818

19-
from .models import Book, Operator, Project, RadiusAccounting, Shelf
19+
from .models import (
20+
Book,
21+
Operator,
22+
OrganizationRadiusSettings,
23+
Project,
24+
RadiusAccounting,
25+
Shelf,
26+
)
2027

2128
admin.site.unregister(User)
2229

@@ -114,3 +121,8 @@ class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
114121
ReverseBookFilter,
115122
]
116123
search_fields = ['name']
124+
125+
126+
@admin.register(OrganizationRadiusSettings)
127+
class OrganizationRadiusSettingsAdmin(admin.ModelAdmin):
128+
pass

0 commit comments

Comments
 (0)