Skip to content

Commit

Permalink
Fixed #29490 -- Added support for object-based Media CSS and JS paths.
Browse files Browse the repository at this point in the history
  • Loading branch information
claudep authored and felixxm committed Feb 10, 2022
1 parent cda81b7 commit 4c76ffc
Show file tree
Hide file tree
Showing 4 changed files with 196 additions and 3 deletions.
8 changes: 6 additions & 2 deletions django/forms/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ def render(self):

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
]

Expand All @@ -111,7 +113,9 @@ def render_css(self):
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,
Expand Down
5 changes: 5 additions & 0 deletions docs/releases/4.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~~~~

Expand Down
27 changes: 26 additions & 1 deletion docs/topics/forms/media.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
=================

Expand Down
159 changes: 159 additions & 0 deletions tests/forms_tests/tests/test_media.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -710,3 +712,160 @@ def test_add_empty(self):
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>',
)

0 comments on commit 4c76ffc

Please sign in to comment.