Skip to content

Commit

Permalink
[fix/feature] Fixed fallback fields core behavior
Browse files Browse the repository at this point in the history
- Fallback fields will automatically default to None
- Centralized kwarg for initialzing fallback fields.
- Removed dependency for using get_field_value method to access
  value of a field.
- Simplified logic for choice fields
- Added FallbackDecimalField
- Fallback fields will set value to None in the database when the
field value is equal to fallback value.
  • Loading branch information
pandafy authored Aug 9, 2024
1 parent 3c059c6 commit 1951fad
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 152 deletions.
62 changes: 35 additions & 27 deletions docs/developer/custom-fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ openwisp modules.
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``.
value.

.. include:: ../partials/fallback-fields.rst

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.
between enabled and disabled options.

.. code-block:: python
Expand All @@ -38,9 +38,6 @@ that reflects the fallback value.
class MyModel(models.Model):
is_active = FallbackBooleanChoiceField(
null=True,
blank=True,
default=None,
fallback=app_settings.IS_ACTIVE_FALLBACK,
)
Expand All @@ -50,8 +47,9 @@ that reflects the fallback value.
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``.
value.

.. include:: ../partials/fallback-fields.rst

.. code-block:: python
Expand All @@ -62,8 +60,6 @@ to ``None``.
class MyModel(models.Model):
is_first_name_required = FallbackCharChoiceField(
null=True,
blank=True,
max_length=32,
choices=(
("disabled", _("Disabled")),
Expand All @@ -81,8 +77,7 @@ This field extends Django's `CharField
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.
.. include:: ../partials/fallback-fields.rst

.. code-block:: python
Expand All @@ -93,8 +88,6 @@ value is set to ``null`` in the database.
class MyModel(models.Model):
greeting_text = FallbackCharField(
null=True,
blank=True,
max_length=200,
fallback=app_settings.GREETING_TEXT,
)
Expand All @@ -107,8 +100,7 @@ This field extends Django's `URLField
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.
.. include:: ../partials/fallback-fields.rst

.. code-block:: python
Expand All @@ -119,8 +111,6 @@ value is set to ``null`` in the database.
class MyModel(models.Model):
password_reset_url = FallbackURLField(
null=True,
blank=True,
max_length=200,
fallback=app_settings.DEFAULT_PASSWORD_RESET_URL,
)
Expand All @@ -133,8 +123,7 @@ This extends Django's `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.
.. include:: ../partials/fallback-fields.rst

.. code-block:: python
Expand All @@ -145,8 +134,6 @@ value is set to ``null`` in the database.
class MyModel(models.Model):
extra_config = FallbackTextField(
null=True,
blank=True,
max_length=200,
fallback=app_settings.EXTRA_CONFIG,
)
Expand All @@ -159,8 +146,7 @@ This extends Django's `PositiveIntegerField
and provides additional functionality for handling positive integer 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.
.. include:: ../partials/fallback-fields.rst

.. code-block:: python
Expand All @@ -171,7 +157,29 @@ value is set to ``null`` in the database.
class MyModel(models.Model):
count = FallbackPositiveIntegerField(
blank=True,
null=True,
fallback=app_settings.DEFAULT_COUNT,
)
``openwisp_utils.fields.FallbackDecimalField``
----------------------------------------------

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

.. include:: ../partials/fallback-fields.rst

.. code-block:: python
from django.db import models
from openwisp_utils.fields import FallbackDecimalField
from myapp import settings as app_settings
class MyModel(models.Model):
price = FallbackDecimalField(
max_digits=4,
decimal_places=2,
fallback=app_settings.DEFAULT_PRICE,
)
6 changes: 0 additions & 6 deletions docs/developer/other-utilities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ 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.FallBackModelMixin``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

REST API Utilities
------------------

Expand Down
6 changes: 6 additions & 0 deletions docs/partials/fallback-fields.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.. note::

- The field will return the **fallback value** whenever is set to
``None``.
- Setting the same value as the **fallback value** will save ``None``
(NULL) in the database.
9 changes: 0 additions & 9 deletions openwisp_utils/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,3 @@ class TimeStampedEditableModel(UUIDModel):

class Meta:
abstract = True


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
112 changes: 52 additions & 60 deletions openwisp_utils/fields.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from django import forms
from django.db.models.fields import (
BLANK_CHOICE_DASH,
BooleanField,
CharField,
DecimalField,
PositiveIntegerField,
TextField,
URLField,
Expand Down Expand Up @@ -39,30 +41,48 @@ def __init__(


class FallbackMixin(object):
"""Returns the fallback value when the value of the field is falsy (None or '').
If the value of the field is equal to the fallback value, then the
field will save `None` in the database.
"""

def __init__(self, *args, **kwargs):
self.fallback = kwargs.pop('fallback', None)
super().__init__(*args, **kwargs)
self.fallback = kwargs.pop('fallback')
opts = dict(blank=True, null=True, default=None)
opts.update(kwargs)
super().__init__(*args, **opts)

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 empty.
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):
"""Called when fetching value from the database."""
if value is None:
return self.fallback
return value

def get_db_prep_value(self, value, connection, prepared=False):
"""Called when saving value in the database."""
value = super().get_db_prep_value(value, connection, prepared)
if value == self.fallback:
return None
return value

def get_default(self):
"""Returns the fallback value for the default.
The default is set to `None` on field initialization to ensure
that the default value in the database schema is `NULL` instead of
a non-null value (fallback value). Returning the fallback value
here also sets the initial value of the field to the fallback
value in admin add forms, similar to how Django handles default
values.
"""
return self.fallback


class FalsyValueNoneMixin:
"""Stores None instead of empty strings.
Expand All @@ -87,16 +107,12 @@ def clean(self, 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,
"form_class": forms.BooleanField,
'widget': forms.Select(
choices=[
(
'',
_('Default') + f' ({default_value})',
),
choices=BLANK_CHOICE_DASH
+ [
(True, _('Enabled')),
(False, _('Disabled')),
]
Expand All @@ -107,14 +123,6 @@ def formfield(self, **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(
{
Expand All @@ -124,52 +132,36 @@ def formfield(self, **kwargs):
return super().formfield(**kwargs)


class FallbackPositiveIntegerField(
FallbackMixin, FallbackFromDbValueMixin, PositiveIntegerField
):
class FallbackPositiveIntegerField(FallbackMixin, PositiveIntegerField):
pass


class FallbackCharField(
FallbackMixin, FalsyValueNoneMixin, FallbackFromDbValueMixin, CharField
):
"""Implements fallback logic.
Populates the form with the fallback value if the value is set to NULL
in the database.
"""
class FallbackCharField(FallbackMixin, FalsyValueNoneMixin, 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
):
"""Implements fallback logic.
Populates the form with the fallback value if the value is set to NULL
in the database.
"""
class FallbackURLField(FallbackMixin, FalsyValueNoneMixin, 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
):
"""Implements fallback logic.
Populates the form with the fallback value if the value is set to NULL
in the database.
"""
class FallbackTextField(FallbackMixin, FalsyValueNoneMixin, 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})
kwargs.update(
{
'form_class': forms.CharField,
'widget': forms.Textarea(
attrs={'rows': 2, 'cols': 34, 'style': 'width:auto'}
),
}
)
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
class FallbackDecimalField(FallbackMixin, DecimalField):
pass
1 change: 0 additions & 1 deletion openwisp_utils/metric_collection/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ def setUp(self):
# The post_migrate signal creates the first OpenwispVersion object
# and uses the actual modules installed in the Python environment.
# This would cause tests to fail when other modules are also installed.
# import ipdb; ipdb.set_trace()
OpenwispVersion.objects.update(
module_version={
'OpenWISP Version': '23.0.0a',
Expand Down
25 changes: 25 additions & 0 deletions tests/test_project/migrations/0008_book_price.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 4.2.7 on 2024-07-24 15:20

from django.db import migrations
import openwisp_utils.fields


class Migration(migrations.Migration):
dependencies = [
("test_project", "0007_radiusaccounting_start_time_and_more"),
]

operations = [
migrations.AddField(
model_name="book",
name="price",
field=openwisp_utils.fields.FallbackDecimalField(
blank=True,
decimal_places=2,
default=None,
fallback=20.0,
max_digits=4,
null=True,
),
),
]
Loading

0 comments on commit 1951fad

Please sign in to comment.