diff --git a/README.rst b/README.rst index cb943349d..36c763bfe 100644 --- a/README.rst +++ b/README.rst @@ -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 `_ +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 `_ +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 `_ +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 `_ +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 `_ +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 --------------- diff --git a/openwisp_utils/base.py b/openwisp_utils/base.py index 5daf993f9..a949ab8bc 100644 --- a/openwisp_utils/base.py +++ b/openwisp_utils/base.py @@ -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): @@ -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 diff --git a/openwisp_utils/fields.py b/openwisp_utils/fields.py new file mode 100644 index 000000000..6e13dd35a --- /dev/null +++ b/openwisp_utils/fields.py @@ -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 diff --git a/tests/test_project/admin.py b/tests/test_project/admin.py index c57ae36ef..1b819465e 100644 --- a/tests/test_project/admin.py +++ b/tests/test_project/admin.py @@ -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) @@ -114,3 +121,8 @@ class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin): ReverseBookFilter, ] search_fields = ['name'] + + +@admin.register(OrganizationRadiusSettings) +class OrganizationRadiusSettingsAdmin(admin.ModelAdmin): + pass diff --git a/tests/test_project/migrations/0005_organizationradiussettings.py b/tests/test_project/migrations/0005_organizationradiussettings.py new file mode 100644 index 000000000..ec1bf6a6e --- /dev/null +++ b/tests/test_project/migrations/0005_organizationradiussettings.py @@ -0,0 +1,71 @@ +# Generated by Django 3.2.19 on 2023-06-24 15:15 + +from django.db import migrations, models +import openwisp_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('test_project', '0004_sheft_data'), + ] + + operations = [ + migrations.CreateModel( + name='OrganizationRadiusSettings', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'is_active', + openwisp_utils.fields.FallbackBooleanChoiceField( + blank=True, default=None, fallback=False, null=True + ), + ), + ( + 'is_first_name_required', + openwisp_utils.fields.FallbackCharChoiceField( + blank=True, + choices=[ + ('disabled', 'Disabled'), + ('allowed', 'Allowed'), + ('mandatory', 'Mandatory'), + ], + fallback='disabled', + max_length=32, + null=True, + ), + ), + ( + 'greeting_text', + openwisp_utils.fields.FallbackCharField( + blank=True, + fallback='Welcome to OpenWISP!', + max_length=200, + null=True, + ), + ), + ( + 'password_reset_url', + openwisp_utils.fields.FallbackURLField( + blank=True, + fallback='http://localhost:8000/admin/password_change/', + null=True, + ), + ), + ( + 'extra_config', + openwisp_utils.fields.FallbackTextField( + blank=True, fallback='no data', max_length=200, null=True + ), + ), + ], + ), + ] diff --git a/tests/test_project/models.py b/tests/test_project/models.py index b95909341..4e91c7882 100644 --- a/tests/test_project/models.py +++ b/tests/test_project/models.py @@ -1,7 +1,19 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ -from openwisp_utils.base import KeyField, TimeStampedEditableModel, UUIDModel +from openwisp_utils.base import ( + FallbackModelMixin, + KeyField, + TimeStampedEditableModel, + UUIDModel, +) +from openwisp_utils.fields import ( + FallbackBooleanChoiceField, + FallbackCharChoiceField, + FallbackCharField, + FallbackTextField, + FallbackURLField, +) class Shelf(TimeStampedEditableModel): @@ -72,6 +84,44 @@ class RadiusAccounting(models.Model): ) +class OrganizationRadiusSettings(FallbackModelMixin, models.Model): + is_active = FallbackBooleanChoiceField( + null=True, + blank=True, + default=None, + fallback=False, + ) + is_first_name_required = FallbackCharChoiceField( + null=True, + blank=True, + max_length=32, + choices=( + ('disabled', _('Disabled')), + ('allowed', _('Allowed')), + ('mandatory', _('Mandatory')), + ), + fallback='disabled', + ) + greeting_text = FallbackCharField( + null=True, + blank=True, + max_length=200, + fallback='Welcome to OpenWISP!', + ) + password_reset_url = FallbackURLField( + null=True, + blank=True, + max_length=200, + fallback='http://localhost:8000/admin/password_change/', + ) + extra_config = FallbackTextField( + null=True, + blank=True, + max_length=200, + fallback='no data', + ) + + class Project(UUIDModel): name = models.CharField(max_length=64, null=True, blank=True) key = KeyField(unique=True, db_index=True, help_text=_('unique project key')) diff --git a/tests/test_project/tests/__init__.py b/tests/test_project/tests/__init__.py index 95f1cafec..8c48fd08f 100644 --- a/tests/test_project/tests/__init__.py +++ b/tests/test_project/tests/__init__.py @@ -40,3 +40,9 @@ def _create_radius_accounting(self, **kwargs): ra.full_clean() ra.save() return ra + + def _create_org_radius_settings(self, **kwargs): + org_rad_settings = self.org_radius_settings_model(**kwargs) + org_rad_settings.full_clean() + org_rad_settings.save() + return org_rad_settings diff --git a/tests/test_project/tests/test_admin.py b/tests/test_project/tests/test_admin.py index 76c89a507..5d84a89e8 100644 --- a/tests/test_project/tests/test_admin.py +++ b/tests/test_project/tests/test_admin.py @@ -13,7 +13,13 @@ from openwisp_utils.admin_theme.filters import InputFilter, SimpleInputFilter from ..admin import ProjectAdmin, ShelfAdmin -from ..models import Operator, Project, RadiusAccounting, Shelf +from ..models import ( + Operator, + OrganizationRadiusSettings, + Project, + RadiusAccounting, + Shelf, +) from . import AdminTestMixin, CreateMixin User = get_user_model() @@ -22,6 +28,7 @@ class TestAdmin(AdminTestMixin, CreateMixin, TestCase): TEST_KEY = 'w1gwJxKaHcamUw62TQIPgYchwLKn3AA0' accounting_model = RadiusAccounting + org_radius_settings_model = OrganizationRadiusSettings def test_radiusaccounting_change(self): options = dict(username='bobby', session_id='1') @@ -478,3 +485,81 @@ def test_ow_autocomplete_filter_uuid_exception(self): response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertContains(response, '“invalid” is not a valid UUID.') + + def test_organization_radius_settings_admin(self): + org_rad_settings = self._create_org_radius_settings( + is_active=True, + is_first_name_required=None, + greeting_text=None, + password_reset_url='http://localhost:8000/reset-password/', + ) + url = reverse( + 'admin:test_project_organizationradiussettings_change', + args=[org_rad_settings.pk], + ) + + with self.subTest('Test default values are rendered'): + response = self.client.get(url) + # Overridden value is selected for BooleanChoiceField + self.assertContains( + response, + '', + html=True, + ) + # Default value is selected for FallbackCharChoiceField + self.assertContains( + response, + '', + html=True, + ) + # Default value is used for FallbackCharField + self.assertContains( + response, + '', + ) + # Overridden value is used for the FallbackURLField + self.assertContains( + response, + '', + ) + + with self.subTest('Test overriding default values from admin'): + payload = { + # Setting the default value for FallbackBooleanChoiceField + 'is_active': '', + # Overriding the default value for FallbackCharChoiceField + 'is_first_name_required': 'allowed', + # Overriding the default value for FallbackCharField + 'greeting_text': 'Greeting text', + # Setting the default value for FallbackURLField + 'password_reset_url': '', + # Setting the default value for FallbackTextField + 'extra_config': '', + } + response = self.client.post(url, payload, follow=True) + self.assertEqual(response.status_code, 200) + org_rad_settings.refresh_from_db() + self.assertEqual(org_rad_settings.get_field_value('is_active'), False) + self.assertEqual( + org_rad_settings.get_field_value('is_first_name_required'), 'allowed' + ) + self.assertEqual( + org_rad_settings.get_field_value('greeting_text'), 'Greeting text' + ) + self.assertEqual( + org_rad_settings.get_field_value('password_reset_url'), + 'http://localhost:8000/admin/password_change/', + ) + self.assertEqual( + org_rad_settings.get_field_value('extra_config'), 'no data' + ) diff --git a/tests/test_project/tests/test_model.py b/tests/test_project/tests/test_model.py index 40252c84c..926006b09 100644 --- a/tests/test_project/tests/test_model.py +++ b/tests/test_project/tests/test_model.py @@ -1,7 +1,11 @@ +from unittest.mock import patch + from django.core.exceptions import ValidationError +from django.db import connection from django.test import TestCase -from ..models import Project +from ..models import OrganizationRadiusSettings, Project +from . import CreateMixin class TestModel(TestCase): @@ -20,3 +24,85 @@ def test_key_validator(self): p.full_clean() p.key = self.TEST_KEY p.full_clean() + + +class TestFallbackFields(CreateMixin, TestCase): + org_radius_settings_model = OrganizationRadiusSettings + + def test_fallback_field_falsy_values(self): + org_rad_settings = self._create_org_radius_settings() + + def _verify_none_database_value(field_name): + setattr(org_rad_settings, field_name, '') + org_rad_settings.full_clean() + org_rad_settings.save() + with connection.cursor() as cursor: + cursor.execute( + f'SELECT {field_name} FROM' + f' {org_rad_settings._meta.app_label}_{org_rad_settings._meta.model_name}' + f' WHERE id = \'{org_rad_settings.id}\';', + ) + row = cursor.fetchone() + self.assertEqual(row[0], None) + + with self.subTest('Test "greeting_text" field'): + _verify_none_database_value('greeting_text') + + with self.subTest('Test "password_reset_url" field'): + _verify_none_database_value('password_reset_url') + + with self.subTest('Test "extra_config" field'): + _verify_none_database_value('extra_config') + + def test_fallback_boolean_choice_field(self): + org_rad_settings = self._create_org_radius_settings() + + with self.subTest('Test is_active set to None'): + org_rad_settings.is_active = None + # Ensure fallback value is returned + self.assertEqual(org_rad_settings.get_field_value('is_active'), False) + + with self.subTest('Test fallback value changed'): + with patch.object( + # The fallback value is set on project startup, hence + # it also requires mocking. + OrganizationRadiusSettings._meta.get_field('is_active'), + 'fallback', + True, + ): + org_rad_settings.is_active = None + self.assertEqual(org_rad_settings.get_field_value('is_active'), True) + + with self.subTest('Test overriding default value'): + org_rad_settings.is_active = True + self.assertEqual(org_rad_settings.get_field_value('is_active'), True) + + def test_fallback_char_choice_field(self): + org_rad_settings = self._create_org_radius_settings() + + with self.subTest('Test is_first_name_required set to None'): + org_rad_settings.is_first_name_required = None + # Ensure fallback value is returned + self.assertEqual( + org_rad_settings.get_field_value('is_first_name_required'), 'disabled' + ) + + with self.subTest('Test fallback value changed'): + with patch.object( + # The fallback value is set on project startup, hence + # it also requires mocking. + OrganizationRadiusSettings._meta.get_field('is_first_name_required'), + 'fallback', + 'mandatory', + ): + org_rad_settings.is_first_name_required = None + self.assertEqual( + org_rad_settings.get_field_value('is_first_name_required'), + 'mandatory', + ) + + with self.subTest('Test overriding default value'): + org_rad_settings.is_first_name_required = 'allowed' + self.assertEqual( + org_rad_settings.get_field_value('is_first_name_required'), 'allowed' + )