From e2d19b295c44167bd0482cc87cbc0276253dd66e Mon Sep 17 00:00:00 2001 From: Johannes Maron Date: Thu, 7 Nov 2024 10:21:25 +0100 Subject: [PATCH] Fixed #35886 -- Added support for object-based form media script assets. --- django/forms/widgets.py | 50 ++++++- docs/releases/5.2.txt | 4 + docs/topics/forms/media.txt | 63 ++++++--- tests/forms_tests/tests/test_media.py | 190 +++++++++++++++----------- 4 files changed, 213 insertions(+), 94 deletions(-) diff --git a/django/forms/widgets.py b/django/forms/widgets.py index ca5f2724db..94e6880481 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -9,7 +9,7 @@ from collections import defaultdict from graphlib import CycleError, TopologicalSorter from itertools import chain -from django.forms.utils import to_current_timezone +from django.forms.utils import flatatt, to_current_timezone from django.templatetags.static import static from django.utils import formats from django.utils.choices import normalize_choices @@ -23,6 +23,7 @@ from django.utils.translation import gettext_lazy as _ from .renderers import get_default_renderer __all__ = ( + "Script", "Media", "MediaDefiningClass", "Widget", @@ -61,6 +62,53 @@ class MediaOrderConflictWarning(RuntimeWarning): pass +@html_safe +class MediaAsset: + element_template = "{path}" + + def __init__(self, path, **attributes): + self._path = path + self.attributes = attributes + + def __eq__(self, other): + # Compare the path only, to ensure performant comparison in Media.merge. + return (self.__class__ is other.__class__ and self.path == other.path) or ( + isinstance(other, str) and self._path == other + ) + + def __hash__(self): + # Hash the path only, to ensure performant comparison in Media.merge. + return hash(self._path) + + def __str__(self): + return format_html( + self.element_template, + path=self.path, + attributes=flatatt(self.attributes), + ) + + def __repr__(self): + return f"{type(self).__qualname__}({self._path!r})" + + @property + def path(self): + """ + Ensure an absolute path. + Relative paths are resolved via the {% static %} template tag. + """ + if self._path.startswith(("http://", "https://", "/")): + return self._path + return static(self._path) + + +class Script(MediaAsset): + element_template = '' + + def __init__(self, src, **attributes): + # We alter the signature to allow src to be passed as a keyword argument. + super().__init__(src, **attributes) + + @html_safe class Media: def __init__(self, media=None, css=None, js=None): diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index aaf47ff8e8..de99832f13 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -259,6 +259,10 @@ Forms * An :attr:`~django.forms.BoundField.aria_describedby` property is added to ``BoundField`` to ease use of this HTML attribute in templates. +* The new asset object :class:`~django.forms.Script` is available for adding + custom HTML-attributes to JavaScript in form media. See + :ref:`paths as objects ` for more details. + Generic Views ~~~~~~~~~~~~~ diff --git a/docs/topics/forms/media.txt b/docs/topics/forms/media.txt index 3fddf2d4bb..17bd385ed0 100644 --- a/docs/topics/forms/media.txt +++ b/docs/topics/forms/media.txt @@ -2,6 +2,8 @@ Form Assets (the ``Media`` class) ================================= +.. currentmodule:: django.forms + Rendering an attractive and easy-to-use web form requires more than just HTML - it also requires CSS stylesheets, and if you want to use fancy widgets, you may also need to include some JavaScript on each page. The exact @@ -130,6 +132,24 @@ A tuple describing the required JavaScript files. See :ref:`the section on paths ` for details of how to specify paths to these files. +``Script`` objects +~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 5.2 + +.. class:: Script(src, **attributes) + + Represents a script file. + + The first parameter, ``src``, is the string path to the script file. See + :ref:`the section on paths ` for details on how to + specify paths to these files. + + The optional keyword arguments, ``**attributes``, are HTML attributes that + are set on the rendered `` +.. _form-media-asset-objects: + Paths as objects ---------------- -Asset paths may also be given as hashable objects implementing an -``__html__()`` method. The ``__html__()`` method is typically added using the -:func:`~django.utils.html.html_safe` decorator. The object is responsible for -outputting the complete HTML `` + +.. versionchanged:: 5.2 + + The object class ``Script`` was added. ``Media`` objects ================= diff --git a/tests/forms_tests/tests/test_media.py b/tests/forms_tests/tests/test_media.py index 061adc09ca..032deedf0d 100644 --- a/tests/forms_tests/tests/test_media.py +++ b/tests/forms_tests/tests/test_media.py @@ -1,8 +1,96 @@ from django.forms import CharField, Form, Media, MultiWidget, TextInput +from django.forms.widgets import MediaAsset, Script from django.template import Context, Template -from django.templatetags.static import static from django.test import SimpleTestCase, override_settings -from django.utils.html import format_html, html_safe +from django.utils.html import html_safe + + +class CSS(MediaAsset): + element_template = '' + + def __init__(self, href, **attributes): + super().__init__(href, **attributes) + self.attributes["rel"] = "stylesheet" + + +@override_settings( + STATIC_URL="http://media.example.com/static/", +) +class MediaAssetTestCase(SimpleTestCase): + def test_init(self): + attributes = {"media": "all", "is": "magic-css"} + asset = MediaAsset("path/to/css", **attributes) + self.assertEqual(asset._path, "path/to/css") + self.assertEqual(asset.attributes, attributes) + self.assertIsNot(asset.attributes, attributes) + + def test_eq(self): + self.assertEqual(MediaAsset("path/to/css"), MediaAsset("path/to/css")) + self.assertEqual(MediaAsset("path/to/css"), "path/to/css") + self.assertEqual( + MediaAsset("path/to/css", media="all"), MediaAsset("path/to/css") + ) + + self.assertNotEqual(MediaAsset("path/to/css"), MediaAsset("path/to/other.css")) + self.assertNotEqual(MediaAsset("path/to/css"), "path/to/other.css") + self.assertNotEqual(MediaAsset("path/to/css", media="all"), CSS("path/to/css")) + + def test_hash(self): + self.assertEqual(hash(MediaAsset("path/to/css")), hash("path/to/css")) + self.assertEqual( + hash(MediaAsset("path/to/css")), hash(MediaAsset("path/to/css")) + ) + + def test_str(self): + self.assertEqual( + str(MediaAsset("path/to/css")), + "http://media.example.com/static/path/to/css", + ) + self.assertEqual( + str(MediaAsset("http://media.other.com/path/to/css")), + "http://media.other.com/path/to/css", + ) + + def test_repr(self): + self.assertEqual(repr(MediaAsset("path/to/css")), "MediaAsset('path/to/css')") + self.assertEqual( + repr(MediaAsset("http://media.other.com/path/to/css")), + "MediaAsset('http://media.other.com/path/to/css')", + ) + + def test_path(self): + asset = MediaAsset("path/to/css") + self.assertEqual(asset.path, "http://media.example.com/static/path/to/css") + + asset = MediaAsset("http://media.other.com/path/to/css") + self.assertEqual(asset.path, "http://media.other.com/path/to/css") + + asset = MediaAsset("https://secure.other.com/path/to/css") + self.assertEqual(asset.path, "https://secure.other.com/path/to/css") + + asset = MediaAsset("/absolute/path/to/css") + self.assertEqual(asset.path, "/absolute/path/to/css") + + asset = MediaAsset("//absolute/path/to/css") + self.assertEqual(asset.path, "//absolute/path/to/css") + + +@override_settings(STATIC_URL="http://media.example.com/static/") +class ScriptTestCase(SimpleTestCase): + def test_init_with_src_kwarg(self): + self.assertEqual( + Script(src="path/to/js").path, "http://media.example.com/static/path/to/js" + ) + + def test_str(self): + self.assertHTMLEqual( + str(Script("path/to/js")), + '', + ) + self.assertHTMLEqual( + str(Script("path/to/js", **{"async": True, "deferred": False})), + '', + ) @override_settings( @@ -714,63 +802,6 @@ class FormsMediaTestCase(SimpleTestCase): self.assertEqual(merged._js_lists, [["a"]]) -@html_safe -class Asset: - def __init__(self, path): - self.path = path - - def __eq__(self, other): - return (self.__class__ == other.__class__ and self.path == other.path) or ( - other.__class__ == str and self.path == other - ) - - def __hash__(self): - return hash(self.path) - - def __str__(self): - return self.absolute_path(self.path) - - def absolute_path(self, path): - """ - Given a relative or absolute path to a static asset, return an absolute - path. An absolute path will be returned unchanged while a relative path - will be passed to django.templatetags.static.static(). - """ - if path.startswith(("http://", "https://", "/")): - return path - return static(path) - - def __repr__(self): - return f"{self.path!r}" - - -class CSS(Asset): - def __init__(self, path, medium): - super().__init__(path) - self.medium = medium - - def __str__(self): - path = super().__str__() - return format_html( - '', - self.absolute_path(path), - self.medium, - ) - - -class JS(Asset): - def __init__(self, path, integrity=None): - super().__init__(path) - self.integrity = integrity or "" - - def __str__(self, integrity=None): - path = super().__str__() - template = '' % ( - ' integrity="{}"' if self.integrity else "{}" - ) - return format_html(template, self.absolute_path(path), self.integrity) - - @override_settings( STATIC_URL="http://media.example.com/static/", ) @@ -779,17 +810,22 @@ class FormsMediaObjectTestCase(SimpleTestCase): def test_construction(self): m = Media( - css={"all": (CSS("path/to/css1", "all"), CSS("/path/to/css2", "all"))}, + css={ + "all": ( + CSS("path/to/css1", media="all"), + CSS("/path/to/css2", media="all"), + ) + }, js=( - JS("/path/to/js1"), - JS("http://media.other.com/path/to/js2"), - JS( + Script("/path/to/js1"), + Script("http://media.other.com/path/to/js2"), + Script( "https://secure.other.com/path/to/js3", integrity="9d947b87fdeb25030d56d01f7aa75800", ), ), ) - self.assertEqual( + self.assertHTMLEqual( str(m), '\n' @@ -801,9 +837,9 @@ class FormsMediaObjectTestCase(SimpleTestCase): ) self.assertEqual( repr(m), - "Media(css={'all': ['path/to/css1', '/path/to/css2']}, " - "js=['/path/to/js1', 'http://media.other.com/path/to/js2', " - "'https://secure.other.com/path/to/js3'])", + "Media(css={'all': [CSS('path/to/css1'), CSS('/path/to/css2')]}, " + "js=[Script('/path/to/js1'), Script('http://media.other.com/path/to/js2'), " + "Script('https://secure.other.com/path/to/js3')])", ) def test_simplest_class(self): @@ -823,22 +859,24 @@ class FormsMediaObjectTestCase(SimpleTestCase): def test_combine_media(self): class MyWidget1(TextInput): class Media: - css = {"all": (CSS("path/to/css1", "all"), "/path/to/css2")} + css = {"all": (CSS("path/to/css1", media="all"), "/path/to/css2")} js = ( "/path/to/js1", "http://media.other.com/path/to/js2", "https://secure.other.com/path/to/js3", - JS("/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"), + Script( + "/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800" + ), ) class MyWidget2(TextInput): class Media: - css = {"all": (CSS("/path/to/css2", "all"), "/path/to/css3")} - js = (JS("/path/to/js1"), "/path/to/js4") + css = {"all": (CSS("/path/to/css2", media="all"), "/path/to/css3")} + js = (Script("/path/to/js1"), "/path/to/js4") w1 = MyWidget1() w2 = MyWidget2() - self.assertEqual( + self.assertHTMLEqual( str(w1.media + w2.media), '\n' @@ -857,14 +895,14 @@ class FormsMediaObjectTestCase(SimpleTestCase): media = Media( css={ "all": ( - CSS("/path/to/css1", "all"), - CSS("/path/to/css1", "all"), + CSS("/path/to/css1", media="all"), + CSS("/path/to/css1", media="all"), "/path/to/css1", ) }, - js=(JS("/path/to/js1"), JS("/path/to/js1"), "/path/to/js1"), + js=(Script("/path/to/js1"), Script("/path/to/js1"), "/path/to/js1"), ) - self.assertEqual( + self.assertHTMLEqual( str(media), '\n' '',