Repository: openwisp/openwisp-radius (language: python)

Issue:
[bug] Site key of organization's password reset url is not formatted automatically 
The default value for `OPENWISP_RADIUS_PASSWORD_RESET_URLS` contains `{site}` placeholder. The documentation hints that the value for `{site}` is taken from the Django site object. 

> {site}: site domain as defined in the [django site framework](https://docs.djangoproject.com/en/dev/ref/contrib/sites/) (defaults to example.com and an be changed through the django admin)

I couldn't find the source code that deals with this. Also, when I try to save a new organization I get the following error

![Screenshot from 2022-06-28 16-14-42](https://user-images.githubusercontent.com/32094433/176165840-3fcac646-caf7-427a-b0fc-0dec20e941d5.png)

The URL field validation fails because of `{site}` placeholder. 


[bug] Site key of organization's password reset url is not formatted automatically 
The default value for `OPENWISP_RADIUS_PASSWORD_RESET_URLS` contains `{site}` placeholder. The documentation hints that the value for `{site}` is taken from the Django site object. 

> {site}: site domain as defined in the [django site framework](https://docs.djangoproject.com/en/dev/ref/contrib/sites/) (defaults to example.com and an be changed through the django admin)

I couldn't find the source code that deals with this. Also, when I try to save a new organization I get the following error

![Screenshot from 2022-06-28 16-14-42](https://user-images.githubusercontent.com/32094433/176165840-3fcac646-caf7-427a-b0fc-0dec20e941d5.png)

The URL field validation fails because of `{site}` placeholder. 




Discussion / hints:
I wonder if we can resolve the `{site}` and `{organization}` placeholders only for validation purposes?

Or we could copy the original value, update the value to make the validation pass and then restore the original value before saving.

The first option would be cleaner if doable.
I wonder if we can resolve the `{site}` and `{organization}` placeholders only for validation purposes?

Or we could copy the original value, update the value to make the validation pass and then restore the original value before saving.

The first option would be cleaner if doable.

Fix patch:
diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py
index 8880245b..d198b351 100644
--- a/openwisp_radius/base/models.py
+++ b/openwisp_radius/base/models.py
@@ -50,10 +50,10 @@
 from .fields import (
     FallbackBooleanChoiceField,
     FallbackCharChoiceField,
+    FallbackCharField,
     FallbackTextField,
-    FallbackURLField,
 )
-from .validators import ipv6_network_validator
+from .validators import ipv6_network_validator, password_reset_url_validator
 
 logger = logging.getLogger(__name__)
 User = get_user_model()
@@ -1189,13 +1189,15 @@ class AbstractOrganizationRadiusSettings(UUIDModel):
         blank=True,
         help_text=_STATUS_URL_HELP_TEXT,
     )
-    password_reset_url = FallbackURLField(
+    password_reset_url = FallbackCharField(
         verbose_name=_('Password reset URL'),
         null=True,
         blank=True,
+        max_length=200,
         help_text=_PASSWORD_RESET_URL_HELP_TEXT,
         default=DEFAULT_PASSWORD_RESET_URL,
         fallback=DEFAULT_PASSWORD_RESET_URL,
+        validators=[password_reset_url_validator],
     )
 
     class Meta:
diff --git a/openwisp_radius/base/validators.py b/openwisp_radius/base/validators.py
index b7de15fc..e430f44a 100644
--- a/openwisp_radius/base/validators.py
+++ b/openwisp_radius/base/validators.py
@@ -1,8 +1,11 @@
 from ipaddress import IPv6Network, ip_network
 
 from django.core.exceptions import ValidationError
+from django.core.validators import URLValidator
 from django.utils.translation import gettext_lazy as _
 
+URL_VALIDATOR = URLValidator()
+
 
 def ipv6_network_validator(value):
     try:
@@ -11,3 +14,15 @@ def ipv6_network_validator(value):
         raise ValidationError(_('Invalid ipv6 prefix: {error}').format(error=error))
     if not isinstance(network, IPv6Network):
         raise ValidationError(_('{value} is not an IPv6 prefix').format(value=value))
+
+
+def password_reset_url_validator(value):
+    """
+    Substitutes the site placeholder before running URL
+    validation on the value.
+    """
+    from django.contrib.sites.models import Site
+
+    site = Site.objects.get_current()
+    value = value.replace('{site}', site.domain)
+    URL_VALIDATOR(value)
diff --git a/openwisp_radius/migrations/0033_alter_organizationradiussettings_password_reset_url.py b/openwisp_radius/migrations/0033_alter_organizationradiussettings_password_reset_url.py
new file mode 100644
index 00000000..d2a6a752
--- /dev/null
+++ b/openwisp_radius/migrations/0033_alter_organizationradiussettings_password_reset_url.py
@@ -0,0 +1,33 @@
+# Generated by Django 4.0.5 on 2022-09-16 10:42
+
+from django.db import migrations
+
+import openwisp_radius.base.fields
+import openwisp_radius.base.validators
+from openwisp_radius import settings as app_settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('openwisp_radius', '0032_organizationradiussettings_sms_message'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='organizationradiussettings',
+            name='password_reset_url',
+            field=openwisp_radius.base.fields.FallbackCharField(
+                blank=True,
+                default=app_settings.DEFAULT_PASSWORD_RESET_URL,
+                fallback=app_settings.DEFAULT_PASSWORD_RESET_URL,
+                help_text='Enter the URL where users can reset their password',
+                max_length=200,
+                null=True,
+                validators=[
+                    openwisp_radius.base.validators.password_reset_url_validator
+                ],
+                verbose_name='Password reset URL',
+            ),
+        ),
+    ]


Test patch:
diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py
index c72f712e..38f4e3d5 100644
--- a/openwisp_radius/tests/test_admin.py
+++ b/openwisp_radius/tests/test_admin.py
@@ -589,6 +589,25 @@ def test_organization_radsettings_password_reset_url(self):
             radsetting.refresh_from_db()
             self.assertEqual(radsetting.password_reset_url, PASSWORD_RESET_URL)
 
+        with self.subTest('Test "site" placeholder does not break validation'):
+            with mock.patch.object(
+                app_settings,
+                'DEFAULT_PASSWORD_RESET_URL',
+                'https://{site}/{organization}/password/reset/confirm/{uid}/{token}',
+            ):
+                payload = form_data.copy()
+                payload.update(
+                    {
+                        'radius_settings-0-password_reset_url': (
+                            app_settings.DEFAULT_PASSWORD_RESET_URL
+                        ),
+                        '_continue': True,
+                    }
+                )
+                response = self.client.post(url, payload, follow=True)
+                self.assertEqual(response.status_code, 200)
+                self.assertNotContains(response, 'Enter a valid URL.')
+
     def test_backward_compatible_default_password_reset_url(self):
         # this test is only for backward compatible change
         # it will be removed in the future
diff --git a/tests/openwisp2/sample_radius/migrations/0028_alter_organizationradiussettings_allowed_mobile_prefixes_and_more.py b/tests/openwisp2/sample_radius/migrations/0028_alter_organizationradiussettings_allowed_mobile_prefixes_and_more.py
index d5d28369..66ea54b3 100644
--- a/tests/openwisp2/sample_radius/migrations/0028_alter_organizationradiussettings_allowed_mobile_prefixes_and_more.py
+++ b/tests/openwisp2/sample_radius/migrations/0028_alter_organizationradiussettings_allowed_mobile_prefixes_and_more.py
@@ -146,13 +146,17 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='organizationradiussettings',
             name='password_reset_url',
-            field=openwisp_radius.base.fields.FallbackURLField(
+            field=openwisp_radius.base.fields.FallbackCharField(
                 blank=True,
                 default=app_settings.DEFAULT_PASSWORD_RESET_URL,
                 fallback=app_settings.DEFAULT_PASSWORD_RESET_URL,
                 help_text='Enter the URL where users can reset their password',
                 null=True,
                 verbose_name='Password reset URL',
+                max_length=200,
+                validators=[
+                    openwisp_radius.base.validators.password_reset_url_validator
+                ],
             ),
         ),
         migrations.AlterField(