Browse Source

Fixed #34380 -- Allowed specifying a default URL scheme in forms.URLField.

This also deprecates "http" as the default scheme.
Coen van der Kamp 2 years ago
parent
commit
7bbbadc693

+ 17 - 3
django/forms/fields.py

@@ -10,6 +10,7 @@ import operator
 import os
 import re
 import uuid
+import warnings
 from decimal import Decimal, DecimalException
 from io import BytesIO
 from urllib.parse import urlsplit, urlunsplit
@@ -42,6 +43,7 @@ from django.forms.widgets import (
 )
 from django.utils import formats
 from django.utils.dateparse import parse_datetime, parse_duration
+from django.utils.deprecation import RemovedInDjango60Warning
 from django.utils.duration import duration_string
 from django.utils.ipv6 import clean_ipv6_address
 from django.utils.regex_helper import _lazy_re_compile
@@ -753,7 +755,19 @@ class URLField(CharField):
     }
     default_validators = [validators.URLValidator()]
 
-    def __init__(self, **kwargs):
+    def __init__(self, *, assume_scheme=None, **kwargs):
+        if assume_scheme is None:
+            warnings.warn(
+                "The default scheme will be changed from 'http' to 'https' in Django "
+                "6.0. Pass the forms.URLField.assume_scheme argument to silence this "
+                "warning.",
+                RemovedInDjango60Warning,
+                stacklevel=2,
+            )
+            assume_scheme = "http"
+        # RemovedInDjango60Warning: When the deprecation ends, replace with:
+        # self.assume_scheme = assume_scheme or "https"
+        self.assume_scheme = assume_scheme
         super().__init__(strip=True, **kwargs)
 
     def to_python(self, value):
@@ -773,8 +787,8 @@ class URLField(CharField):
         if value:
             url_fields = split_url(value)
             if not url_fields[0]:
-                # If no URL scheme given, assume http://
-                url_fields[0] = "http"
+                # If no URL scheme given, add a scheme.
+                url_fields[0] = self.assume_scheme
             if not url_fields[1]:
                 # Assume that if no domain is provided, that the path segment
                 # contains the domain.

+ 3 - 0
docs/internals/deprecation.txt

@@ -32,6 +32,9 @@ details on these changes.
 
 * The ``ForeignObject.get_reverse_joining_columns()`` method will be removed.
 
+* The default scheme for ``forms.URLField`` will change from ``"http"`` to
+  ``"https"``.
+
 .. _deprecation-removed-in-5.1:
 
 5.1

+ 12 - 2
docs/ref/forms/fields.txt

@@ -1071,8 +1071,18 @@ For each field, we describe the default widget used if you don't specify
       given value is a valid URL.
     * Error message keys: ``required``, ``invalid``
 
-    Has the optional arguments ``max_length``, ``min_length``, and
-    ``empty_value`` which work just as they do for :class:`CharField`.
+    Has the optional arguments ``max_length``, ``min_length``, ``empty_value``
+    which work just as they do for :class:`CharField`, and ``assume_scheme``
+    that defaults to ``"http"``.
+
+    .. versionchanged:: 5.0
+
+        The ``assume_scheme`` argument was added.
+
+    .. deprecated:: 5.0
+
+        The default value for ``assume_scheme`` will change from ``"http"`` to
+        ``"https"`` in Django 6.0.
 
 ``UUIDField``
 -------------

+ 6 - 0
docs/releases/5.0.txt

@@ -245,6 +245,9 @@ Forms
   :ref:`Choices classes <field-choices-enum-types>` directly instead of
   requiring expansion with the ``choices`` attribute.
 
+* The new ``assume_scheme`` argument for :class:`~django.forms.URLField` allows
+  specifying a default URL scheme.
+
 Generic Views
 ~~~~~~~~~~~~~
 
@@ -403,6 +406,9 @@ Miscellaneous
 
 * The ``ForeignObject.get_reverse_joining_columns()`` method is deprecated.
 
+* The default scheme for ``forms.URLField`` will change from ``"http"`` to
+  ``"https"`` in Django 6.0.
+
 Features removed in 5.0
 =======================
 

+ 7 - 0
tests/admin_views/tests.py

@@ -26,6 +26,7 @@ from django.forms.utils import ErrorList
 from django.template.response import TemplateResponse
 from django.test import (
     TestCase,
+    ignore_warnings,
     modify_settings,
     override_settings,
     skipUnlessDBFeature,
@@ -34,6 +35,7 @@ from django.test.utils import override_script_prefix
 from django.urls import NoReverseMatch, resolve, reverse
 from django.utils import formats, translation
 from django.utils.cache import get_max_age
+from django.utils.deprecation import RemovedInDjango60Warning
 from django.utils.encoding import iri_to_uri
 from django.utils.html import escape
 from django.utils.http import urlencode
@@ -6555,6 +6557,7 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase):
     def setUp(self):
         self.client.force_login(self.superuser)
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_readonly_get(self):
         response = self.client.get(reverse("admin:admin_views_post_add"))
         self.assertNotContains(response, 'name="posted"')
@@ -6615,6 +6618,7 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase):
         )
         self.assertContains(response, "%d amount of cool" % p.pk)
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_readonly_text_field(self):
         p = Post.objects.create(
             title="Readonly test",
@@ -6634,6 +6638,7 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase):
         # Checking readonly field in inline.
         self.assertContains(response, "test<br>link")
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_readonly_post(self):
         data = {
             "title": "Django Got Readonly Fields",
@@ -6774,6 +6779,7 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase):
         field = self.get_admin_readonly_field(response, "plotdetails")
         self.assertEqual(field.contents(), "-")  # default empty value
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_readonly_field_overrides(self):
         """
         Regression test for #22087 - ModelForm Meta overrides are ignored by
@@ -7233,6 +7239,7 @@ class CSSTest(TestCase):
     def setUp(self):
         self.client.force_login(self.superuser)
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_field_prefix_css_classes(self):
         """
         Fields have a CSS class name with a 'field-' prefix.

+ 6 - 1
tests/admin_widgets/tests.py

@@ -22,9 +22,10 @@ from django.db.models import (
     ManyToManyField,
     UUIDField,
 )
-from django.test import SimpleTestCase, TestCase, override_settings
+from django.test import SimpleTestCase, TestCase, ignore_warnings, override_settings
 from django.urls import reverse
 from django.utils import translation
+from django.utils.deprecation import RemovedInDjango60Warning
 
 from .models import (
     Advisor,
@@ -106,6 +107,7 @@ class AdminFormfieldForDBFieldTests(SimpleTestCase):
     def test_TextField(self):
         self.assertFormfield(Event, "description", widgets.AdminTextareaWidget)
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_URLField(self):
         self.assertFormfield(Event, "link", widgets.AdminURLFieldWidget)
 
@@ -320,6 +322,7 @@ class AdminForeignKeyRawIdWidget(TestDataMixin, TestCase):
     def setUp(self):
         self.client.force_login(self.superuser)
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_nonexistent_target_id(self):
         band = Band.objects.create(name="Bogey Blues")
         pk = band.pk
@@ -335,6 +338,7 @@ class AdminForeignKeyRawIdWidget(TestDataMixin, TestCase):
             "Select a valid choice. That choice is not one of the available choices.",
         )
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_invalid_target_id(self):
         for test_str in ("Iñtërnâtiônàlizætiøn", "1234'", -1234):
             # This should result in an error message, not a server exception.
@@ -1610,6 +1614,7 @@ class HorizontalVerticalFilterSeleniumTests(AdminWidgetSeleniumTestCase):
         self.assertCountSeleniumElements("#id_students_to > option", 2)
 
 
+@ignore_warnings(category=RemovedInDjango60Warning)
 class AdminRawIdWidgetSeleniumTests(AdminWidgetSeleniumTestCase):
     def setUp(self):
         super().setUp()

+ 30 - 5
tests/forms_tests/field_tests/test_urlfield.py

@@ -1,10 +1,12 @@
 from django.core.exceptions import ValidationError
 from django.forms import URLField
-from django.test import SimpleTestCase
+from django.test import SimpleTestCase, ignore_warnings
+from django.utils.deprecation import RemovedInDjango60Warning
 
 from . import FormFieldAssertionsMixin
 
 
+@ignore_warnings(category=RemovedInDjango60Warning)
 class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
     def test_urlfield_widget(self):
         f = URLField()
@@ -26,7 +28,9 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
             f.clean("http://abcdefghijklmnopqrstuvwxyz.com")
 
     def test_urlfield_clean(self):
-        f = URLField(required=False)
+        # RemovedInDjango60Warning: When the deprecation ends, remove the
+        # assume_scheme argument.
+        f = URLField(required=False, assume_scheme="https")
         tests = [
             ("http://localhost", "http://localhost"),
             ("http://example.com", "http://example.com"),
@@ -38,8 +42,8 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
                 "http://example.com?some_param=some_value",
                 "http://example.com?some_param=some_value",
             ),
-            ("valid-with-hyphens.com", "http://valid-with-hyphens.com"),
-            ("subdomain.domain.com", "http://subdomain.domain.com"),
+            ("valid-with-hyphens.com", "https://valid-with-hyphens.com"),
+            ("subdomain.domain.com", "https://subdomain.domain.com"),
             ("http://200.8.9.10", "http://200.8.9.10"),
             ("http://200.8.9.10:8000/test", "http://200.8.9.10:8000/test"),
             ("http://valid-----hyphens.com", "http://valid-----hyphens.com"),
@@ -49,7 +53,7 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
             ),
             (
                 "www.example.com/s/http://code.djangoproject.com/ticket/13804",
-                "http://www.example.com/s/http://code.djangoproject.com/ticket/13804",
+                "https://www.example.com/s/http://code.djangoproject.com/ticket/13804",
             ),
             # Normalization.
             ("http://example.com/     ", "http://example.com/"),
@@ -135,3 +139,24 @@ class URLFieldTest(FormFieldAssertionsMixin, SimpleTestCase):
         msg = "__init__() got multiple values for keyword argument 'strip'"
         with self.assertRaisesMessage(TypeError, msg):
             URLField(strip=False)
+
+    def test_urlfield_assume_scheme(self):
+        f = URLField()
+        # RemovedInDjango60Warning: When the deprecation ends, replace with:
+        # "https://example.com"
+        self.assertEqual(f.clean("example.com"), "http://example.com")
+        f = URLField(assume_scheme="http")
+        self.assertEqual(f.clean("example.com"), "http://example.com")
+        f = URLField(assume_scheme="https")
+        self.assertEqual(f.clean("example.com"), "https://example.com")
+
+
+class URLFieldAssumeSchemeDeprecationTest(FormFieldAssertionsMixin, SimpleTestCase):
+    def test_urlfield_raises_warning(self):
+        msg = (
+            "The default scheme will be changed from 'http' to 'https' in Django 6.0. "
+            "Pass the forms.URLField.assume_scheme argument to silence this warning."
+        )
+        with self.assertWarnsMessage(RemovedInDjango60Warning, msg):
+            f = URLField()
+            self.assertEqual(f.clean("example.com"), "http://example.com")

+ 4 - 2
tests/forms_tests/tests/test_error_messages.py

@@ -23,7 +23,8 @@ from django.forms import (
     utils,
 )
 from django.template import Context, Template
-from django.test import SimpleTestCase, TestCase
+from django.test import SimpleTestCase, TestCase, ignore_warnings
+from django.utils.deprecation import RemovedInDjango60Warning
 from django.utils.safestring import mark_safe
 
 from ..models import ChoiceModel
@@ -167,7 +168,8 @@ class FormsErrorMessagesTestCase(SimpleTestCase, AssertFormErrorsMixin):
             "invalid": "INVALID",
             "max_length": '"%(value)s" has more than %(limit_value)d characters.',
         }
-        f = URLField(error_messages=e, max_length=17)
+        with ignore_warnings(category=RemovedInDjango60Warning):
+            f = URLField(error_messages=e, max_length=17)
         self.assertFormErrors(["REQUIRED"], f.clean, "")
         self.assertFormErrors(["INVALID"], f.clean, "abc.c")
         self.assertFormErrors(

+ 14 - 1
tests/generic_inline_admin/tests.py

@@ -5,8 +5,15 @@ from django.contrib.contenttypes.admin import GenericTabularInline
 from django.contrib.contenttypes.models import ContentType
 from django.forms.formsets import DEFAULT_MAX_NUM
 from django.forms.models import ModelForm
-from django.test import RequestFactory, SimpleTestCase, TestCase, override_settings
+from django.test import (
+    RequestFactory,
+    SimpleTestCase,
+    TestCase,
+    ignore_warnings,
+    override_settings,
+)
 from django.urls import reverse
+from django.utils.deprecation import RemovedInDjango60Warning
 
 from .admin import MediaInline, MediaPermanentInline
 from .admin import site as admin_site
@@ -21,6 +28,7 @@ class TestDataMixin:
         )
 
 
+@ignore_warnings(category=RemovedInDjango60Warning)
 @override_settings(ROOT_URLCONF="generic_inline_admin.urls")
 class GenericAdminViewTest(TestDataMixin, TestCase):
     def setUp(self):
@@ -95,6 +103,7 @@ class GenericAdminViewTest(TestDataMixin, TestCase):
         self.assertEqual(response.status_code, 302)  # redirect somewhere
 
 
+@ignore_warnings(category=RemovedInDjango60Warning)
 @override_settings(ROOT_URLCONF="generic_inline_admin.urls")
 class GenericInlineAdminParametersTest(TestDataMixin, TestCase):
     factory = RequestFactory()
@@ -296,6 +305,7 @@ class GenericInlineAdminWithUniqueTogetherTest(TestDataMixin, TestCase):
 
 @override_settings(ROOT_URLCONF="generic_inline_admin.urls")
 class NoInlineDeletionTest(SimpleTestCase):
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_no_deletion(self):
         inline = MediaPermanentInline(EpisodePermanent, admin_site)
         fake_request = object()
@@ -321,6 +331,7 @@ class GenericInlineModelAdminTest(SimpleTestCase):
     def setUp(self):
         self.site = AdminSite()
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_get_formset_kwargs(self):
         media_inline = MediaInline(Media, AdminSite())
 
@@ -360,6 +371,7 @@ class GenericInlineModelAdminTest(SimpleTestCase):
             ["keywords", "id", "DELETE"],
         )
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_custom_form_meta_exclude(self):
         """
         The custom ModelForm's `Meta.exclude` is respected by
@@ -403,6 +415,7 @@ class GenericInlineModelAdminTest(SimpleTestCase):
             ["description", "keywords", "id", "DELETE"],
         )
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_get_fieldsets(self):
         # get_fieldsets is called when figuring out form fields.
         # Refs #18681.

+ 33 - 6
tests/model_forms/tests.py

@@ -21,8 +21,9 @@ from django.forms.models import (
     modelform_factory,
 )
 from django.template import Context, Template
-from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
+from django.test import SimpleTestCase, TestCase, ignore_warnings, skipUnlessDBFeature
 from django.test.utils import isolate_apps
+from django.utils.deprecation import RemovedInDjango60Warning
 
 from .models import (
     Article,
@@ -369,6 +370,7 @@ class ModelFormBaseTest(TestCase):
         obj = form.save()
         self.assertEqual(obj.name, "")
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_save_blank_null_unique_charfield_saves_null(self):
         form_class = modelform_factory(
             model=NullableUniqueCharFieldModel, fields="__all__"
@@ -907,6 +909,13 @@ class ModelFormBaseTest(TestCase):
         self.assertEqual(m2.date_published, datetime.date(2010, 1, 1))
 
 
+# RemovedInDjango60Warning.
+# It's a temporary workaround for the deprecation period.
+class HttpsURLField(forms.URLField):
+    def __init__(self, **kwargs):
+        super().__init__(assume_scheme="https", **kwargs)
+
+
 class FieldOverridesByFormMetaForm(forms.ModelForm):
     class Meta:
         model = Category
@@ -930,7 +939,7 @@ class FieldOverridesByFormMetaForm(forms.ModelForm):
             }
         }
         field_classes = {
-            "url": forms.URLField,
+            "url": HttpsURLField,
         }
 
 
@@ -2857,6 +2866,7 @@ class ModelOtherFieldTests(SimpleTestCase):
             },
         )
 
+    @ignore_warnings(category=RemovedInDjango60Warning)
     def test_url_on_modelform(self):
         "Check basic URL field validation on model forms"
 
@@ -2881,6 +2891,19 @@ class ModelOtherFieldTests(SimpleTestCase):
         )
         self.assertTrue(HomepageForm({"url": "http://example.com/foo/bar"}).is_valid())
 
+    def test_url_modelform_assume_scheme_warning(self):
+        msg = (
+            "The default scheme will be changed from 'http' to 'https' in Django "
+            "6.0. Pass the forms.URLField.assume_scheme argument to silence this "
+            "warning."
+        )
+        with self.assertWarnsMessage(RemovedInDjango60Warning, msg):
+
+            class HomepageForm(forms.ModelForm):
+                class Meta:
+                    model = Homepage
+                    fields = "__all__"
+
     def test_modelform_non_editable_field(self):
         """
         When explicitly including a non-editable field in a ModelForm, the
@@ -2900,23 +2923,27 @@ class ModelOtherFieldTests(SimpleTestCase):
                     model = Article
                     fields = ("headline", "created")
 
-    def test_http_prefixing(self):
+    def test_https_prefixing(self):
         """
-        If the http:// prefix is omitted on form input, the field adds it again.
+        If the https:// prefix is omitted on form input, the field adds it
+        again.
         """
 
         class HomepageForm(forms.ModelForm):
+            # RemovedInDjango60Warning.
+            url = forms.URLField(assume_scheme="https")
+
             class Meta:
                 model = Homepage
                 fields = "__all__"
 
         form = HomepageForm({"url": "example.com"})
         self.assertTrue(form.is_valid())
-        self.assertEqual(form.cleaned_data["url"], "http://example.com")
+        self.assertEqual(form.cleaned_data["url"], "https://example.com")
 
         form = HomepageForm({"url": "example.com/test"})
         self.assertTrue(form.is_valid())
-        self.assertEqual(form.cleaned_data["url"], "http://example.com/test")
+        self.assertEqual(form.cleaned_data["url"], "https://example.com/test")
 
 
 class OtherModelFormTests(TestCase):