Skip to content

Commit

Permalink
[feature] Added fallback fields openwisp#333
Browse files Browse the repository at this point in the history
  • Loading branch information
pandafy authored Jun 28, 2023
1 parent 9d3cb2e commit 3a99c39
Show file tree
Hide file tree
Showing 9 changed files with 616 additions and 34 deletions.
134 changes: 131 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -897,10 +897,138 @@ Model class inheriting ``UUIDModel`` which provides two additional fields:
Which use respectively ``AutoCreatedField``, ``AutoLastModifiedField`` from ``model_utils.fields``
(self-updating fields providing the creation date-time and the last modified date-time).

``openwisp_utils.base.KeyField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
``openwisp_utils.base.FallBackModelMixin``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Model mixin that implements ``get_field_value`` method which can be used
to get value of fallback fields.

Custom Fields
-------------

This section describes custom fields defined in ``openwisp_utils.fields``
that can be used in Django models:

``openwisp_utils.fields.KeyField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

A model field which provides a random key or token, widely used across openwisp modules.

``openwisp_utils.fields.FallbackBooleanChoiceField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This field extends Django's `BooleanField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#booleanfield>`_
and provides additional functionality for handling choices with a fallback value.
The field will use the **fallback value** whenever the field is set to ``None``.

This field is particularly useful when you want to present a choice between enabled
and disabled options, with an additional "Default" option that reflects the fallback value.

.. code-block:: python
from django.db import models
from openwisp_utils.fields import FallbackBooleanChoiceField
from myapp import settings as app_settings
class MyModel(models.Model):
is_active = FallbackBooleanChoiceField(
null=True,
blank=True,
default=None,
fallback=app_settings.IS_ACTIVE_FALLBACK,
)
``openwisp_utils.fields.FallbackCharChoiceField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This field extends Django's `CharField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#charfield>`_
and provides additional functionality for handling choices with a fallback value.
The field will use the **fallback value** whenever the field is set to ``None``.

.. code-block:: python
from django.db import models
from openwisp_utils.fields import FallbackCharChoiceField
from myapp import settings as app_settings
class MyModel(models.Model):
is_first_name_required = FallbackCharChoiceField(
null=True,
blank=True,
max_length=32,
choices=(
('disabled', _('Disabled')),
('allowed', _('Allowed')),
('mandatory', _('Mandatory')),
),
fallback=app_settings.IS_FIRST_NAME_REQUIRED,
)
``openwisp_utils.fields.FallbackCharField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This field extends Django's `CharField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#charfield>`_
and provides additional functionality for handling text fields with a fallback value.

A model field whic provides a random key or token, widely used across openwisp modules.
It allows populating the form with the fallback value when the actual value is set to ``null`` in the database.

.. code-block:: python
from django.db import models
from openwisp_utils.fields import FallbackCharField
from myapp import settings as app_settings
class MyModel(models.Model):
greeting_text = FallbackCharField(
null=True,
blank=True,
max_length=200,
fallback=app_settings.GREETING_TEXT,
)
``openwisp_utils.fields.FallbackURLField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This field extends Django's `URLField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#urlfield>`_
and provides additional functionality for handling URL fields with a fallback value.

It allows populating the form with the fallback value when the actual value is set to ``null`` in the database.

.. code-block:: python
from django.db import models
from openwisp_utils.fields import FallbackURLField
from myapp import settings as app_settings
class MyModel(models.Model):
password_reset_url = FallbackURLField(
null=True,
blank=True,
max_length=200,
fallback=app_settings.DEFAULT_PASSWORD_RESET_URL,
)
``openwisp_utils.fields.FallbackTextField``
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This extends Django's `TextField <https://docs.djangoproject.com/en/4.2/ref/models/fields/#django.db.models.TextField>`_
and provides additional functionality for handling text fields with a fallback value.

It allows populating the form with the fallback value when the actual value is set to ``null`` in the database.

.. code-block:: python
from django.db import models
from openwisp_utils.fields import FallbackTextField
from myapp import settings as app_settings
class MyModel(models.Model):
extra_config = FallbackTextField(
null=True,
blank=True,
max_length=200,
fallback=app_settings.EXTRA_CONFIG,
)
Admin utilities
---------------
Expand Down
37 changes: 10 additions & 27 deletions openwisp_utils/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from model_utils.fields import AutoCreatedField, AutoLastModifiedField
from openwisp_utils.utils import get_random_key
from openwisp_utils.validators import key_validator

# For backward compatibility
from .fields import KeyField # noqa


class UUIDModel(models.Model):
Expand All @@ -27,28 +28,10 @@ class Meta:
abstract = True


class KeyField(models.CharField):
default_callable = get_random_key
default_validators = [key_validator]

def __init__(
self,
max_length: int = 64,
unique: bool = False,
db_index: bool = False,
help_text: str = None,
default: [str, callable, None] = default_callable,
validators: list = default_validators,
*args,
**kwargs
):
super().__init__(
max_length=max_length,
unique=unique,
db_index=db_index,
help_text=help_text,
default=default,
validators=validators,
*args,
**kwargs
)
class FallbackModelMixin(object):
def get_field_value(self, field_name):
value = getattr(self, field_name)
field = self._meta.get_field(field_name)
if value is None and hasattr(field, 'fallback'):
return field.fallback
return value
161 changes: 161 additions & 0 deletions openwisp_utils/fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from django import forms
from django.db.models.fields import BooleanField, CharField, TextField, URLField
from django.utils.translation import gettext_lazy as _
from openwisp_utils.utils import get_random_key
from openwisp_utils.validators import key_validator


class KeyField(CharField):
default_callable = get_random_key
default_validators = [key_validator]

def __init__(
self,
max_length: int = 64,
unique: bool = False,
db_index: bool = False,
help_text: str = None,
default: [str, callable, None] = default_callable,
validators: list = default_validators,
*args,
**kwargs,
):
super().__init__(
max_length=max_length,
unique=unique,
db_index=db_index,
help_text=help_text,
default=default,
validators=validators,
*args,
**kwargs,
)


class FallbackMixin(object):
def __init__(self, *args, **kwargs):
self.fallback = kwargs.pop('fallback', None)
super().__init__(*args, **kwargs)

def deconstruct(self):
name, path, args, kwargs = super().deconstruct()
kwargs['fallback'] = self.fallback
return (name, path, args, kwargs)


class FallbackFromDbValueMixin:
"""
Returns the fallback value when the value of the field
is falsy (None or '').
It does not set the field's value to "None" when the value
is equal to the fallback value. This allows overriding of
the value when a user knows that the default will get changed.
"""

def from_db_value(self, value, expression, connection):
if value is None:
return self.fallback
return value


class FalsyValueNoneMixin:
"""
If the field contains an empty string, then
stores "None" in the database if the field is
nullable.
"""

# Django convention is to use the empty string, not NULL
# for representing "no data" in the database.
# https://docs.djangoproject.com/en/dev/ref/models/fields/#null
# We need to use NULL for fallback field here to keep
# the fallback logic simple. Hence, we allow only "None" (NULL)
# as empty value here.
empty_values = [None]

def clean(self, value, model_instance):
if not value and self.null is True:
return None
return super().clean(value, model_instance)


class FallbackBooleanChoiceField(FallbackMixin, BooleanField):
def formfield(self, **kwargs):
default_value = _('Enabled') if self.fallback else _('Disabled')
kwargs.update(
{
"form_class": forms.NullBooleanField,
'widget': forms.Select(
choices=[
(
'',
_('Default') + f' ({default_value})',
),
(True, _('Enabled')),
(False, _('Disabled')),
]
),
}
)
return super().formfield(**kwargs)


class FallbackCharChoiceField(FallbackMixin, CharField):
def get_choices(self, **kwargs):
for choice, value in self.choices:
if choice == self.fallback:
default = value
break
kwargs.update({'blank_choice': [('', _('Default') + f' ({default})')]})
return super().get_choices(**kwargs)

def formfield(self, **kwargs):
kwargs.update(
{
"choices_form_class": forms.TypedChoiceField,
}
)
return super().formfield(**kwargs)


class FallbackCharField(
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, CharField
):
"""
Populates the form with the fallback value
if the value is set to null in the database.
"""

pass


class FallbackURLField(
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, URLField
):
"""
Populates the form with the fallback value
if the value is set to null in the database.
"""

pass


class FallbackTextField(
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, TextField
):
"""
Populates the form with the fallback value
if the value is set to null in the database.
"""

def formfield(self, **kwargs):
kwargs.update({'form_class': FallbackTextFormField})
return super().formfield(**kwargs)


class FallbackTextFormField(forms.CharField):
def widget_attrs(self, widget):
attrs = super().widget_attrs(widget)
attrs.update({'rows': 2, 'cols': 34, 'style': 'width:auto'})
return attrs
14 changes: 13 additions & 1 deletion tests/test_project/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@
SimpleInputFilter,
)

from .models import Book, Operator, Project, RadiusAccounting, Shelf
from .models import (
Book,
Operator,
OrganizationRadiusSettings,
Project,
RadiusAccounting,
Shelf,
)

admin.site.unregister(User)

Expand Down Expand Up @@ -114,3 +121,8 @@ class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
ReverseBookFilter,
]
search_fields = ['name']


@admin.register(OrganizationRadiusSettings)
class OrganizationRadiusSettingsAdmin(admin.ModelAdmin):
pass
Loading

0 comments on commit 3a99c39

Please sign in to comment.