From 7bbbadc69383f0a2b99253e153b974f8783e876d Mon Sep 17 00:00:00 2001 From: Coen van der Kamp Date: Wed, 8 Mar 2023 20:12:34 +0100 Subject: [PATCH] Fixed #34380 -- Allowed specifying a default URL scheme in forms.URLField. This also deprecates "http" as the default scheme. --- django/forms/fields.py | 20 ++++++++-- docs/internals/deprecation.txt | 3 ++ docs/ref/forms/fields.txt | 14 ++++++- docs/releases/5.0.txt | 6 +++ tests/admin_views/tests.py | 7 ++++ tests/admin_widgets/tests.py | 7 +++- .../forms_tests/field_tests/test_urlfield.py | 35 ++++++++++++++--- .../forms_tests/tests/test_error_messages.py | 6 ++- tests/generic_inline_admin/tests.py | 15 ++++++- tests/model_forms/tests.py | 39 ++++++++++++++++--- 10 files changed, 132 insertions(+), 20 deletions(-) diff --git a/django/forms/fields.py b/django/forms/fields.py index 0143296533..d759da71ab 100644 --- a/django/forms/fields.py +++ b/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. diff --git a/docs/internals/deprecation.txt b/docs/internals/deprecation.txt index bb35bd4805..b27348adb0 100644 --- a/docs/internals/deprecation.txt +++ b/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 diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index f9a07b1626..4084ae78d5 100644 --- a/docs/ref/forms/fields.txt +++ b/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`` ------------- diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt index 48df713375..dafdc10d62 100644 --- a/docs/releases/5.0.txt +++ b/docs/releases/5.0.txt @@ -245,6 +245,9 @@ Forms :ref:`Choices classes ` 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 ======================= diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index ea38b62a72..903a8996d4 100644 --- a/tests/admin_views/tests.py +++ b/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
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. diff --git a/tests/admin_widgets/tests.py b/tests/admin_widgets/tests.py index fea7d72616..2977b64596 100644 --- a/tests/admin_widgets/tests.py +++ b/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() diff --git a/tests/forms_tests/field_tests/test_urlfield.py b/tests/forms_tests/field_tests/test_urlfield.py index 042a3bf586..058a2992ed 100644 --- a/tests/forms_tests/field_tests/test_urlfield.py +++ b/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") diff --git a/tests/forms_tests/tests/test_error_messages.py b/tests/forms_tests/tests/test_error_messages.py index 2ad356858f..e44c6d6668 100644 --- a/tests/forms_tests/tests/test_error_messages.py +++ b/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( diff --git a/tests/generic_inline_admin/tests.py b/tests/generic_inline_admin/tests.py index c90cf41224..1e633c03f9 100644 --- a/tests/generic_inline_admin/tests.py +++ b/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. diff --git a/tests/model_forms/tests.py b/tests/model_forms/tests.py index 2295530562..b807e90ef9 100644 --- a/tests/model_forms/tests.py +++ b/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):