Browse Source

Fixed #29490 -- Added support for object-based Media CSS and JS paths.

Claude Paroz 3 years ago
parent
commit
4c76ffc2d6
4 changed files with 196 additions and 3 deletions
  1. 6 2
      django/forms/widgets.py
  2. 5 0
      docs/releases/4.1.txt
  3. 26 1
      docs/topics/forms/media.txt
  4. 159 0
      tests/forms_tests/tests/test_media.py

+ 6 - 2
django/forms/widgets.py

@@ -101,7 +101,9 @@ class Media:
 
     def render_js(self):
         return [
-            format_html('<script src="{}"></script>', self.absolute_path(path))
+            path.__html__()
+            if hasattr(path, "__html__")
+            else format_html('<script src="{}"></script>', self.absolute_path(path))
             for path in self._js
         ]
 
@@ -111,7 +113,9 @@ class Media:
         media = sorted(self._css)
         return chain.from_iterable(
             [
-                format_html(
+                path.__html__()
+                if hasattr(path, "__html__")
+                else format_html(
                     '<link href="{}" media="{}" rel="stylesheet">',
                     self.absolute_path(path),
                     medium,

+ 5 - 0
docs/releases/4.1.txt

@@ -192,6 +192,11 @@ Forms
 * The new ``edit_only`` argument for :func:`.modelformset_factory` and
   :func:`.inlineformset_factory` allows preventing new objects creation.
 
+* The ``js`` and ``css`` class attributes of :doc:`Media </topics/forms/media>`
+  now allow using hashable objects, not only path strings, as long as those
+  objects implement the ``__html__()`` method (typically when decorated with
+  the :func:`~django.utils.html.html_safe` decorator).
+
 Generic Views
 ~~~~~~~~~~~~~
 

+ 26 - 1
docs/topics/forms/media.txt

@@ -206,7 +206,10 @@ return values for dynamic ``media`` properties.
 Paths in asset definitions
 ==========================
 
-Paths used to specify assets can be either relative or absolute. If a
+Paths as strings
+----------------
+
+String paths used to specify assets can be either relative or absolute. If a
 path starts with ``/``, ``http://`` or ``https://``, it will be
 interpreted as an absolute path, and left as-is. All other paths will
 be prepended with the value of the appropriate prefix. If the
@@ -254,6 +257,28 @@ Or if :mod:`~django.contrib.staticfiles` is configured using the
     <script src="https://static.example.com/animations.27e20196a850.js"></script>
     <script src="http://othersite.com/actions.js"></script>
 
+Paths as objects
+----------------
+
+.. versionadded:: 4.1
+
+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::
+
+    >>> 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" rel="stylesheet">'
+
+    >>> class SomeWidget(forms.TextInput):
+    ...     class Media:
+    ...         js = (JSPath(),)
+
 ``Media`` objects
 =================
 

+ 159 - 0
tests/forms_tests/tests/test_media.py

@@ -1,6 +1,8 @@
 from django.forms import CharField, Form, Media, MultiWidget, TextInput
 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
 
 
 @override_settings(
@@ -710,3 +712,160 @@ class FormsMediaTestCase(SimpleTestCase):
         merged = media + empty_media
         self.assertEqual(merged._css_lists, [{"screen": ["a.css"]}])
         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(
+    STATIC_URL="http://media.example.com/static/",
+)
+class FormsMediaObjectTestCase(SimpleTestCase):
+    """Media handling when media are objects instead of raw strings."""
+
+    def test_construction(self):
+        m = Media(
+            css={"all": (CSS("path/to/css1", "all"), CSS("/path/to/css2", "all"))},
+            js=(
+                JS("/path/to/js1"),
+                JS("http://media.other.com/path/to/js2"),
+                JS(
+                    "https://secure.other.com/path/to/js3",
+                    integrity="9d947b87fdeb25030d56d01f7aa75800",
+                ),
+            ),
+        )
+        self.assertEqual(
+            str(m),
+            '<link href="http://media.example.com/static/path/to/css1" media="all" '
+            'rel="stylesheet">\n'
+            '<link href="/path/to/css2" media="all" rel="stylesheet">\n'
+            '<script src="/path/to/js1"></script>\n'
+            '<script src="http://media.other.com/path/to/js2"></script>\n'
+            '<script src="https://secure.other.com/path/to/js3" '
+            'integrity="9d947b87fdeb25030d56d01f7aa75800"></script>',
+        )
+        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'])",
+        )
+
+    def test_simplest_class(self):
+        @html_safe
+        class SimpleJS:
+            """The simplest possible asset class."""
+
+            def __str__(self):
+                return '<script src="https://example.org/asset.js" rel="stylesheet">'
+
+        m = Media(js=(SimpleJS(),))
+        self.assertEqual(
+            str(m),
+            '<script src="https://example.org/asset.js" rel="stylesheet">',
+        )
+
+    def test_combine_media(self):
+        class MyWidget1(TextInput):
+            class Media:
+                css = {"all": (CSS("path/to/css1", "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"),
+                )
+
+        class MyWidget2(TextInput):
+            class Media:
+                css = {"all": (CSS("/path/to/css2", "all"), "/path/to/css3")}
+                js = (JS("/path/to/js1"), "/path/to/js4")
+
+        w1 = MyWidget1()
+        w2 = MyWidget2()
+        self.assertEqual(
+            str(w1.media + w2.media),
+            '<link href="http://media.example.com/static/path/to/css1" media="all" '
+            'rel="stylesheet">\n'
+            '<link href="/path/to/css2" media="all" rel="stylesheet">\n'
+            '<link href="/path/to/css3" media="all" rel="stylesheet">\n'
+            '<script src="/path/to/js1"></script>\n'
+            '<script src="http://media.other.com/path/to/js2"></script>\n'
+            '<script src="https://secure.other.com/path/to/js3"></script>\n'
+            '<script src="/path/to/js4" integrity="9d947b87fdeb25030d56d01f7aa75800">'
+            "</script>",
+        )
+
+    def test_media_deduplication(self):
+        # The deduplication doesn't only happen at the point of merging two or
+        # more media objects.
+        media = Media(
+            css={
+                "all": (
+                    CSS("/path/to/css1", "all"),
+                    CSS("/path/to/css1", "all"),
+                    "/path/to/css1",
+                )
+            },
+            js=(JS("/path/to/js1"), JS("/path/to/js1"), "/path/to/js1"),
+        )
+        self.assertEqual(
+            str(media),
+            '<link href="/path/to/css1" media="all" rel="stylesheet">\n'
+            '<script src="/path/to/js1"></script>',
+        )