浏览代码

Fixed #35886 -- Added support for object-based form media script assets.

Johannes Maron 4 月之前
父节点
当前提交
989329344a
共有 4 个文件被更改,包括 211 次插入94 次删除
  1. 49 1
      django/forms/widgets.py
  2. 4 0
      docs/releases/5.2.txt
  3. 46 17
      docs/topics/forms/media.txt
  4. 112 76
      tests/forms_tests/tests/test_media.py

+ 49 - 1
django/forms/widgets.py

@@ -9,7 +9,7 @@ from collections import defaultdict
 from graphlib import CycleError, TopologicalSorter
 from graphlib import CycleError, TopologicalSorter
 from itertools import chain
 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.templatetags.static import static
 from django.utils import formats
 from django.utils import formats
 from django.utils.choices import normalize_choices
 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
 from .renderers import get_default_renderer
 
 
 __all__ = (
 __all__ = (
+    "Script",
     "Media",
     "Media",
     "MediaDefiningClass",
     "MediaDefiningClass",
     "Widget",
     "Widget",
@@ -61,6 +62,53 @@ class MediaOrderConflictWarning(RuntimeWarning):
     pass
     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 = '<script src="{path}"{attributes}></script>'
+
+    def __init__(self, src, **attributes):
+        # Alter the signature to allow src to be passed as a keyword argument.
+        super().__init__(src, **attributes)
+
+
 @html_safe
 @html_safe
 class Media:
 class Media:
     def __init__(self, media=None, css=None, js=None):
     def __init__(self, media=None, css=None, js=None):

+ 4 - 0
docs/releases/5.2.txt

@@ -259,6 +259,10 @@ Forms
 * An :attr:`~django.forms.BoundField.aria_describedby` property is added to
 * An :attr:`~django.forms.BoundField.aria_describedby` property is added to
   ``BoundField`` to ease use of this HTML attribute in templates.
   ``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 <form-media-asset-objects>` for more details.
+
 Generic Views
 Generic Views
 ~~~~~~~~~~~~~
 ~~~~~~~~~~~~~
 
 

+ 46 - 17
docs/topics/forms/media.txt

@@ -2,6 +2,8 @@
 Form Assets (the ``Media`` class)
 Form Assets (the ``Media`` class)
 =================================
 =================================
 
 
+.. currentmodule:: django.forms
+
 Rendering an attractive and easy-to-use web form requires more than just
 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,
 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
 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 <form-asset-paths>` for details of how to specify
 section on paths <form-asset-paths>` for details of how to specify
 paths to these files.
 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 <form-asset-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 ``<script>`` tag.
+
+    See :ref:`form-media-asset-objects` for usage examples.
+
 ``extend``
 ``extend``
 ----------
 ----------
 
 
@@ -271,29 +291,38 @@ Or if :mod:`~django.contrib.staticfiles` is configured using the
     <script src="https://static.example.com/animations.27e20196a850.js"></script>
     <script src="https://static.example.com/animations.27e20196a850.js"></script>
     <script src="https://othersite.com/actions.js"></script>
     <script src="https://othersite.com/actions.js"></script>
 
 
+.. _form-media-asset-objects:
+
 Paths as 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 ``<script>`` or ``<link>`` tag content:
+Assets may also be object-based, using :class:`.Script`.
+Furthermore, these allow you to pass custom HTML attributes::
 
 
-.. code-block:: pycon
+    class Media:
+        js = [
+            Script(
+                "https://cdn.example.com/something.min.js",
+                **{
+                    "crossorigin": "anonymous",
+                    "async": True,
+                },
+            ),
+        ]
+
+If this Media definition were to be rendered, it would become the following
+HTML:
 
 
-    >>> from django import forms
-    >>> from django.utils.html import html_safe
-    >>>
-    >>> @html_safe
-    ... class JSPath:
-    ...     def __str__(self):
-    ...         return '<script src="https://example.org/asset.js" defer>'
-    ...
+.. code-block:: html+django
 
 
-    >>> class SomeWidget(forms.TextInput):
-    ...     class Media:
-    ...         js = [JSPath()]
-    ...
+    <script src="https://cdn.example.com/something.min.js"
+            crossorigin="anonymous"
+            async>
+    </script>
+
+.. versionchanged:: 5.2
+
+    The object class ``Script`` was added.
 
 
 ``Media`` objects
 ``Media`` objects
 =================
 =================

+ 112 - 76
tests/forms_tests/tests/test_media.py

@@ -1,8 +1,94 @@
 from django.forms import CharField, Form, Media, MultiWidget, TextInput
 from django.forms import CharField, Form, Media, MultiWidget, TextInput
+from django.forms.widgets import MediaAsset, Script
 from django.template import Context, Template
 from django.template import Context, Template
-from django.templatetags.static import static
 from django.test import SimpleTestCase, override_settings
 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 = '<link href="{path}"{attributes}>'
+
+    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")),
+            '<script src="http://media.example.com/static/path/to/js"></script>',
+        )
+        self.assertHTMLEqual(
+            str(Script("path/to/js", **{"async": True, "deferred": False})),
+            '<script src="http://media.example.com/static/path/to/js" async></script>',
+        )
 
 
 
 
 @override_settings(
 @override_settings(
@@ -714,63 +800,6 @@ class FormsMediaTestCase(SimpleTestCase):
         self.assertEqual(merged._js_lists, [["a"]])
         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(
-            '<link href="{}" media="{}" rel="stylesheet">',
-            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 = '<script src="{}"%s></script>' % (
-            ' integrity="{}"' if self.integrity else "{}"
-        )
-        return format_html(template, self.absolute_path(path), self.integrity)
-
-
 @override_settings(
 @override_settings(
     STATIC_URL="http://media.example.com/static/",
     STATIC_URL="http://media.example.com/static/",
 )
 )
@@ -779,17 +808,22 @@ class FormsMediaObjectTestCase(SimpleTestCase):
 
 
     def test_construction(self):
     def test_construction(self):
         m = Media(
         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=(
-                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",
                     "https://secure.other.com/path/to/js3",
                     integrity="9d947b87fdeb25030d56d01f7aa75800",
                     integrity="9d947b87fdeb25030d56d01f7aa75800",
                 ),
                 ),
             ),
             ),
         )
         )
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(m),
             str(m),
             '<link href="http://media.example.com/static/path/to/css1" media="all" '
             '<link href="http://media.example.com/static/path/to/css1" media="all" '
             'rel="stylesheet">\n'
             'rel="stylesheet">\n'
@@ -801,9 +835,9 @@ class FormsMediaObjectTestCase(SimpleTestCase):
         )
         )
         self.assertEqual(
         self.assertEqual(
             repr(m),
             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):
     def test_simplest_class(self):
@@ -823,22 +857,24 @@ class FormsMediaObjectTestCase(SimpleTestCase):
     def test_combine_media(self):
     def test_combine_media(self):
         class MyWidget1(TextInput):
         class MyWidget1(TextInput):
             class Media:
             class Media:
-                css = {"all": (CSS("path/to/css1", "all"), "/path/to/css2")}
+                css = {"all": (CSS("path/to/css1", media="all"), "/path/to/css2")}
                 js = (
                 js = (
                     "/path/to/js1",
                     "/path/to/js1",
                     "http://media.other.com/path/to/js2",
                     "http://media.other.com/path/to/js2",
                     "https://secure.other.com/path/to/js3",
                     "https://secure.other.com/path/to/js3",
-                    JS("/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"),
+                    Script(
+                        "/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"
+                    ),
                 )
                 )
 
 
         class MyWidget2(TextInput):
         class MyWidget2(TextInput):
             class Media:
             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()
         w1 = MyWidget1()
         w2 = MyWidget2()
         w2 = MyWidget2()
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(w1.media + w2.media),
             str(w1.media + w2.media),
             '<link href="http://media.example.com/static/path/to/css1" media="all" '
             '<link href="http://media.example.com/static/path/to/css1" media="all" '
             'rel="stylesheet">\n'
             'rel="stylesheet">\n'
@@ -857,14 +893,14 @@ class FormsMediaObjectTestCase(SimpleTestCase):
         media = Media(
         media = Media(
             css={
             css={
                 "all": (
                 "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",
                     "/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),
             str(media),
             '<link href="/path/to/css1" media="all" rel="stylesheet">\n'
             '<link href="/path/to/css1" media="all" rel="stylesheet">\n'
             '<script src="/path/to/js1"></script>',
             '<script src="/path/to/js1"></script>',