Fixed #28377 -- Made combining form Media retain relative asset order.

Thanks Florian Apolloner, Mariusz Felisiak, and Tim Graham for reviews.
This commit is contained in:
Johannes Hoppe 2017-07-20 17:06:30 +02:00 committed by Tim Graham
parent f86b6f351d
commit c19b56f633
8 changed files with 154 additions and 42 deletions

View File

@ -304,7 +304,7 @@ class InlineAdminFormSet:
@property @property
def media(self): def media(self):
media = self.opts.media + self.formset.media media = self.formset.media + self.opts.media
for fs in self: for fs in self:
media = media + fs.media media = media + fs.media
return media return media

View File

@ -579,9 +579,9 @@ class ModelAdmin(BaseModelAdmin):
def media(self): def media(self):
extra = '' if settings.DEBUG else '.min' extra = '' if settings.DEBUG else '.min'
js = [ js = [
'core.js',
'vendor/jquery/jquery%s.js' % extra, 'vendor/jquery/jquery%s.js' % extra,
'jquery.init.js', 'jquery.init.js',
'core.js',
'admin/RelatedObjectLookups.js', 'admin/RelatedObjectLookups.js',
'actions%s.js' % extra, 'actions%s.js' % extra,
'urlify.js', 'urlify.js',

View File

@ -4,6 +4,7 @@ Form Widget classes specific to the Django admin site.
import copy import copy
from django import forms from django import forms
from django.conf import settings
from django.db.models.deletion import CASCADE from django.db.models.deletion import CASCADE
from django.urls import reverse from django.urls import reverse
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
@ -22,7 +23,14 @@ class FilteredSelectMultiple(forms.SelectMultiple):
""" """
@property @property
def media(self): def media(self):
js = ["core.js", "SelectBox.js", "SelectFilter2.js"] extra = '' if settings.DEBUG else '.min'
js = [
'vendor/jquery/jquery%s.js' % extra,
'jquery.init.js',
'core.js',
'SelectBox.js',
'SelectFilter2.js',
]
return forms.Media(js=["admin/js/%s" % path for path in js]) return forms.Media(js=["admin/js/%s" % path for path in js])
def __init__(self, verbose_name, is_stacked, attrs=None, choices=()): def __init__(self, verbose_name, is_stacked, attrs=None, choices=()):
@ -43,7 +51,13 @@ class FilteredSelectMultiple(forms.SelectMultiple):
class AdminDateWidget(forms.DateInput): class AdminDateWidget(forms.DateInput):
@property @property
def media(self): def media(self):
js = ["calendar.js", "admin/DateTimeShortcuts.js"] extra = '' if settings.DEBUG else '.min'
js = [
'vendor/jquery/jquery%s.js' % extra,
'jquery.init.js',
'calendar.js',
'admin/DateTimeShortcuts.js',
]
return forms.Media(js=["admin/js/%s" % path for path in js]) return forms.Media(js=["admin/js/%s" % path for path in js])
def __init__(self, attrs=None, format=None): def __init__(self, attrs=None, format=None):
@ -56,7 +70,13 @@ class AdminDateWidget(forms.DateInput):
class AdminTimeWidget(forms.TimeInput): class AdminTimeWidget(forms.TimeInput):
@property @property
def media(self): def media(self):
js = ["calendar.js", "admin/DateTimeShortcuts.js"] extra = '' if settings.DEBUG else '.min'
js = [
'vendor/jquery/jquery%s.js' % extra,
'jquery.init.js',
'calendar.js',
'admin/DateTimeShortcuts.js',
]
return forms.Media(js=["admin/js/%s" % path for path in js]) return forms.Media(js=["admin/js/%s" % path for path in js])
def __init__(self, attrs=None, format=None): def __init__(self, attrs=None, format=None):

View File

@ -2,6 +2,7 @@ from django.contrib.admin import ModelAdmin
from django.contrib.gis.admin.widgets import OpenLayersWidget from django.contrib.gis.admin.widgets import OpenLayersWidget
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.contrib.gis.gdal import OGRGeomType from django.contrib.gis.gdal import OGRGeomType
from django.forms import Media
spherical_mercator_srid = 3857 spherical_mercator_srid = 3857
@ -46,10 +47,7 @@ class GeoModelAdmin(ModelAdmin):
@property @property
def media(self): def media(self):
"Injects OpenLayers JavaScript into the admin." "Injects OpenLayers JavaScript into the admin."
media = super().media return super().media + Media(js=[self.openlayers_url] + self.extra_js)
media.add_js([self.openlayers_url])
media.add_js(self.extra_js)
return media
def formfield_for_dbfield(self, db_field, request, **kwargs): def formfield_for_dbfield(self, db_field, request, **kwargs):
""" """

View File

@ -5,6 +5,7 @@ HTML Widget classes
import copy import copy
import datetime import datetime
import re import re
import warnings
from contextlib import suppress from contextlib import suppress
from itertools import chain from itertools import chain
@ -33,19 +34,23 @@ __all__ = (
MEDIA_TYPES = ('css', 'js') MEDIA_TYPES = ('css', 'js')
class MediaOrderConflictWarning(RuntimeWarning):
pass
@html_safe @html_safe
class Media: class Media:
def __init__(self, media=None, **kwargs): def __init__(self, media=None, css=None, js=None):
if media: if media is not None:
media_attrs = media.__dict__ css = getattr(media, 'css', {})
js = getattr(media, 'js', [])
else: else:
media_attrs = kwargs if css is None:
css = {}
self._css = {} if js is None:
self._js = [] js = []
self._css = css
for name in MEDIA_TYPES: self._js = js
getattr(self, 'add_' + name)(media_attrs.get(name))
def __str__(self): def __str__(self):
return self.render() return self.render()
@ -88,24 +93,48 @@ class Media:
return Media(**{str(name): getattr(self, '_' + name)}) return Media(**{str(name): getattr(self, '_' + name)})
raise KeyError('Unknown media type "%s"' % name) raise KeyError('Unknown media type "%s"' % name)
def add_js(self, data): @staticmethod
if data: def merge(list_1, list_2):
for path in data: """
if path not in self._js: Merge two lists while trying to keep the relative order of the elements.
self._js.append(path) Warn if the lists have the same two elements in a different relative
order.
def add_css(self, data): For static assets it can be important to have them included in the DOM
if data: in a certain order. In JavaScript you may not be able to reference a
for medium, paths in data.items(): global or in CSS you might want to override a style.
for path in paths: """
if not self._css.get(medium) or path not in self._css[medium]: # Start with a copy of list_1.
self._css.setdefault(medium, []).append(path) combined_list = list(list_1)
last_insert_index = len(list_1)
# Walk list_2 in reverse, inserting each element into combined_list if
# it doesn't already exist.
for path in reversed(list_2):
try:
# Does path already exist in the list?
index = combined_list.index(path)
except ValueError:
# Add path to combined_list since it doesn't exist.
combined_list.insert(last_insert_index, path)
else:
if index > last_insert_index:
warnings.warn(
'Detected duplicate Media files in an opposite order:\n'
'%s\n%s' % (combined_list[last_insert_index], combined_list[index]),
MediaOrderConflictWarning,
)
# path already exists in the list. Update last_insert_index so
# that the following elements are inserted in front of this one.
last_insert_index = index
return combined_list
def __add__(self, other): def __add__(self, other):
combined = Media() combined = Media()
for name in MEDIA_TYPES: combined._js = self.merge(self._js, other._js)
getattr(combined, 'add_' + name)(getattr(self, '_' + name, None)) combined._css = {
getattr(combined, 'add_' + name)(getattr(other, '_' + name, None)) medium: self.merge(self._css.get(medium, []), other._css.get(medium, []))
for medium in self._css.keys() | other._css.keys()
}
return combined return combined

View File

@ -554,6 +554,11 @@ Miscellaneous
* Renamed ``BaseExpression._output_field`` to ``output_field``. You may need * Renamed ``BaseExpression._output_field`` to ``output_field``. You may need
to update custom expressions. to update custom expressions.
* In older versions, forms and formsets combine their ``Media`` with widget
``Media`` by concatenating the two. The combining now tries to :ref:`preserve
the relative order of elements in each list <form-media-asset-order>`.
``MediaOrderConflictWarning`` is issued if the order can't be preserved.
.. _deprecated-features-2.0: .. _deprecated-features-2.0:
Features deprecated in 2.0 Features deprecated in 2.0

View File

@ -305,6 +305,42 @@ specified by both::
<script type="text/javascript" src="http://static.example.com/actions.js"></script> <script type="text/javascript" src="http://static.example.com/actions.js"></script>
<script type="text/javascript" src="http://static.example.com/whizbang.js"></script> <script type="text/javascript" src="http://static.example.com/whizbang.js"></script>
.. _form-media-asset-order:
Order of assets
---------------
The order in which assets are inserted into the DOM if often important. For
example, you may have a script that depends on jQuery. Therefore, combining
``Media`` objects attempts to preserve the relative order in which assets are
defined in each ``Media`` class.
For example::
>>> from django import forms
>>> class CalendarWidget(forms.TextInput):
... class Media:
... js = ('jQuery.js', 'calendar.js', 'noConflict.js')
>>> class TimeWidget(forms.TextInput):
... class Media:
... js = ('jQuery.js', 'time.js', 'noConflict.js')
>>> w1 = CalendarWidget()
>>> w2 = TimeWidget()
>>> print(w1.media + w2.media)
<script type="text/javascript" src="http://static.example.com/jQuery.js"></script>
<script type="text/javascript" src="http://static.example.com/calendar.js"></script>
<script type="text/javascript" src="http://static.example.com/time.js"></script>
<script type="text/javascript" src="http://static.example.com/noConflict.js"></script>
Combining ``Media`` objects with assets in a conflicting order results in a
``MediaOrderConflictWarning``.
.. versionchanged:: 2.0
In older versions, the assets of ``Media`` objects are concatenated rather
than merged in a way that tries to preserve the relative ordering of the
elements in each list.
``Media`` on Forms ``Media`` on Forms
================== ==================

View File

@ -1,3 +1,5 @@
import warnings
from django.forms import CharField, Form, Media, MultiWidget, TextInput from django.forms import CharField, Form, Media, MultiWidget, TextInput
from django.template import Context, Template from django.template import Context, Template
from django.test import SimpleTestCase, override_settings from django.test import SimpleTestCase, override_settings
@ -106,7 +108,7 @@ class FormsMediaTestCase(SimpleTestCase):
class MyWidget3(TextInput): class MyWidget3(TextInput):
class Media: class Media:
css = { css = {
'all': ('/path/to/css3', 'path/to/css1') 'all': ('path/to/css1', '/path/to/css3')
} }
js = ('/path/to/js1', '/path/to/js4') js = ('/path/to/js1', '/path/to/js4')
@ -237,9 +239,9 @@ class FormsMediaTestCase(SimpleTestCase):
w8 = MyWidget8() w8 = MyWidget8()
self.assertEqual( self.assertEqual(
str(w8.media), str(w8.media),
"""<link href="http://media.example.com/static/path/to/css1" type="text/css" media="all" rel="stylesheet" /> """<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" />
<link href="http://media.example.com/static/path/to/css1" type="text/css" media="all" rel="stylesheet" />
<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> <link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" />
<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" />
<script type="text/javascript" src="/path/to/js1"></script> <script type="text/javascript" src="/path/to/js1"></script>
<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> <script type="text/javascript" src="http://media.other.com/path/to/js2"></script>
<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> <script type="text/javascript" src="https://secure.other.com/path/to/js3"></script>
@ -312,9 +314,9 @@ class FormsMediaTestCase(SimpleTestCase):
w11 = MyWidget11() w11 = MyWidget11()
self.assertEqual( self.assertEqual(
str(w11.media), str(w11.media),
"""<link href="http://media.example.com/static/path/to/css1" type="text/css" media="all" rel="stylesheet" /> """<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" />
<link href="http://media.example.com/static/path/to/css1" type="text/css" media="all" rel="stylesheet" />
<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> <link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" />
<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" />
<script type="text/javascript" src="/path/to/js1"></script> <script type="text/javascript" src="/path/to/js1"></script>
<script type="text/javascript" src="http://media.other.com/path/to/js2"></script> <script type="text/javascript" src="http://media.other.com/path/to/js2"></script>
<script type="text/javascript" src="https://secure.other.com/path/to/js3"></script> <script type="text/javascript" src="https://secure.other.com/path/to/js3"></script>
@ -341,9 +343,9 @@ class FormsMediaTestCase(SimpleTestCase):
w12 = MyWidget12() w12 = MyWidget12()
self.assertEqual( self.assertEqual(
str(w12.media), str(w12.media),
"""<link href="http://media.example.com/static/path/to/css1" type="text/css" media="all" rel="stylesheet" /> """<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" />
<link href="http://media.example.com/static/path/to/css1" type="text/css" media="all" rel="stylesheet" />
<link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" /> <link href="/path/to/css2" type="text/css" media="all" rel="stylesheet" />
<link href="/path/to/css3" type="text/css" media="all" rel="stylesheet" />
<script type="text/javascript" src="/path/to/js1"></script> <script type="text/javascript" src="/path/to/js1"></script>
<script type="text/javascript" src="/path/to/js4"></script>""" <script type="text/javascript" src="/path/to/js4"></script>"""
) )
@ -396,7 +398,7 @@ class FormsMediaTestCase(SimpleTestCase):
class MyWidget3(TextInput): class MyWidget3(TextInput):
class Media: class Media:
css = { css = {
'all': ('/path/to/css3', 'path/to/css1') 'all': ('path/to/css1', '/path/to/css3')
} }
js = ('/path/to/js1', '/path/to/js4') js = ('/path/to/js1', '/path/to/js4')
@ -441,7 +443,7 @@ class FormsMediaTestCase(SimpleTestCase):
class MyWidget3(TextInput): class MyWidget3(TextInput):
class Media: class Media:
css = { css = {
'all': ('/path/to/css3', 'path/to/css1') 'all': ('path/to/css1', '/path/to/css3')
} }
js = ('/path/to/js1', '/path/to/js4') js = ('/path/to/js1', '/path/to/js4')
@ -518,3 +520,25 @@ class FormsMediaTestCase(SimpleTestCase):
media = Media(css={'all': ['/path/to/css']}, js=['/path/to/js']) media = Media(css={'all': ['/path/to/css']}, js=['/path/to/js'])
self.assertTrue(hasattr(Media, '__html__')) self.assertTrue(hasattr(Media, '__html__'))
self.assertEqual(str(media), media.__html__()) self.assertEqual(str(media), media.__html__())
def test_merge(self):
test_values = (
(([1, 2], [3, 4]), [1, 2, 3, 4]),
(([1, 2], [2, 3]), [1, 2, 3]),
(([2, 3], [1, 2]), [1, 2, 3]),
(([1, 3], [2, 3]), [1, 2, 3]),
(([1, 2], [1, 3]), [1, 2, 3]),
(([1, 2], [3, 2]), [1, 3, 2]),
)
for (list1, list2), expected in test_values:
with self.subTest(list1=list1, list2=list2):
self.assertEqual(Media.merge(list1, list2), expected)
def test_merge_warning(self):
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter('always')
self.assertEqual(Media.merge([1, 2], [2, 1]), [1, 2])
self.assertEqual(
str(w[-1].message),
'Detected duplicate Media files in an opposite order:\n1\n2'
)