Skip to content

Commit

Permalink
Fixed #25706 -- Removed inline JS from openlayers templates.
Browse files Browse the repository at this point in the history
  • Loading branch information
claudep committed Aug 18, 2024
1 parent a57596e commit ff9186b
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 80 deletions.
41 changes: 24 additions & 17 deletions django/contrib/gis/forms/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 16,7 @@ class BaseGeometryWidget(Widget):
Render a map using the WKT of the geometry.
"""

base_layer_name = None
geom_type = "GEOMETRY"
map_srid = 4326
display_raw = False
Expand All @@ -40,6 41,15 @@ def deserialize(self, value):
logger.error("Error creating geometry from value '%s' (%s)", value, err)
return None

def widget_options(self):
"""Options as a dict that will be passed to the JS MapWidget constructor."""
geom_type = gdal.OGRGeomType(self.attrs["geom_type"]).name
return {
"base_layer": self.base_layer_name,
"geom_name": "Geometry" if geom_type == "Unknown" else geom_type,
"map_srid": self.attrs["map_srid"],
}

def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
# If a string reaches here (via a validation error on another
Expand All @@ -61,26 71,15 @@ def get_context(self, name, value, attrs):
self.map_srid,
err,
)

geom_type = gdal.OGRGeomType(self.attrs["geom_type"]).name
context.update(
self.build_attrs(
self.attrs,
{
"name": name,
"module": "geodjango_%s" % name.replace("-", "_"), # JS-safe
"serialized": self.serialize(value),
"geom_type": "Geometry" if geom_type == "Unknown" else geom_type,
"STATIC_URL": settings.STATIC_URL,
"LANGUAGE_BIDI": translation.get_language_bidi(),
**(attrs or {}),
},
)
)
context.update({
"serialized": self.serialize(value),
"widget_options": self.widget_options(),
})
return context


class OpenLayersWidget(BaseGeometryWidget):
base_layer_name = "nasaWorldview"
template_name = "gis/openlayers.html"
map_srid = 3857

Expand Down Expand Up @@ -112,7 111,7 @@ class OSMWidget(OpenLayersWidget):
An OpenLayers/OpenStreetMap-based widget.
"""

template_name = "gis/openlayers-osm.html"
base_layer_name = "osm"
default_lon = 5
default_lat = 47
default_zoom = 12
Expand All @@ -123,3 122,11 @@ def __init__(self, attrs=None):
self.attrs[key] = getattr(self, key)
if attrs:
self.attrs.update(attrs)

def widget_options(self):
return {
**super().widget_options(),
"default_lon": self.attrs.get("default_lon", self.default_lon),
"default_lat": self.attrs.get("default_lat", self.default_lat),
"default_zoom": self.attrs.get("default_zoom", self.default_zoom),
}
44 changes: 41 additions & 3 deletions django/contrib/gis/static/gis/js/OLMapWidget.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 38,23 @@ class GeometryTypeControl extends ol.control.Control {

// TODO: allow deleting individual features (#8972)
class MapWidget {
static layerBuilder = {
nasaWorldview: () => {
return new ol.layer.Tile({
source: new ol.source.XYZ({
attributions: "NASA Worldview",
maxZoom: 8,
url: "https://map1{a-c}.vis.earthdata.nasa.gov/wmts-webmerc/"
"BlueMarble_ShadedRelief_Bathymetry/default/{Time}/"
"GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg"
})
});
},
osm: () => {
return new ol.layer.Tile({source: new ol.source.OSM()});
}
};

constructor(options) {
this.map = null;
this.interactions = {draw: null, modify: null};
Expand All @@ -58,9 75,13 @@ class MapWidget {
this.options[property] = options[property];
}
}
// options.base_layer can be empty, or contain a layerBuilder key, or
// be a layer already consructed.
if (!options.base_layer) {
this.options.base_layer = new ol.layer.Tile({source: new ol.source.OSM()});
}
this.baseLayer = MapWidget.layerBuilder.osm();
} else if (typeof options.base_layer === 'string') {
this.baseLayer = MapWidget.layerBuilder[options.base_layer]();
} else this.baseLayer = this.options.base_layer;

this.map = this.createMap();
this.featureCollection = new ol.Collection();
Expand Down Expand Up @@ -120,7 141,7 @@ class MapWidget {
createMap() {
return new ol.Map({
target: this.options.map_id,
layers: [this.options.base_layer],
layers: [this.baseLayer],
view: new ol.View({
zoom: this.options.default_zoom
})
Expand Down Expand Up @@ -231,3 252,20 @@ class MapWidget {
document.getElementById(this.options.id).value = jsonFormat.writeGeometry(geometry);
}
}

{
function initMapWidgetInSection(section) {
section.querySelectorAll(".dj_map_wrapper").forEach((wrapper) => {
// Avoid initializing map widget on an empty form.
if (wrapper.id.includes('__prefix__')) return;
const options = JSON.parse(wrapper.querySelector("#mapwidget-options").textContent);
options["id"] = wrapper.querySelector("textarea").id;
options["map_id"] = wrapper.querySelector(".dj_map").id;
new MapWidget(options);
});
}
document.addEventListener("DOMContentLoaded", () => {
initMapWidgetInSection(document);
document.addEventListener('formset:added', (ev) => {initMapWidgetInSection(ev.target)});
});
}
12 changes: 0 additions & 12 deletions django/contrib/gis/templates/gis/openlayers-osm.html

This file was deleted.

36 changes: 7 additions & 29 deletions django/contrib/gis/templates/gis/openlayers.html
Original file line number Diff line number Diff line change
@@ -1,32 1,10 @@
{% load i18n l10n %}
{% load i18n %}

<div id="{{ id }}_div_map" class="dj_map_wrapper">
<div id="{{ id }}_map" class="dj_map"></div>
<div id="{{ widget.attrs.id }}_div_map" class="dj_map_wrapper">
<div id="{{ widget.attrs.id }}_map" class="dj_map"></div>
{% if not disabled %}<span class="clear_features"><a href="">{% translate "Delete all Features" %}</a></span>{% endif %}
{% if display_raw %}<p>{% translate "Debugging window (serialized value)" %}</p>{% endif %}
<textarea id="{{ id }}" class="vSerializedField required" cols="150" rows="10" name="{{ name }}"
{% if not display_raw %} hidden{% endif %}>{{ serialized }}</textarea>
<script>
{% block base_layer %}
var base_layer = new ol.layer.Tile({
source: new ol.source.XYZ({
attributions: "NASA Worldview",
maxZoom: 8,
url: "https://map1{a-c}.vis.earthdata.nasa.gov/wmts-webmerc/"
"BlueMarble_ShadedRelief_Bathymetry/default/{Time}/"
"GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg"
})
});
{% endblock %}
{% block options %}var options = {
base_layer: base_layer,
geom_name: '{{ geom_type }}',
id: '{{ id }}',
map_id: '{{ id }}_map',
map_srid: {{ map_srid|unlocalize }},
name: '{{ name }}'
};
{% endblock %}
var {{ module }} = new MapWidget(options);
</script>
{% if widget.attrs.display_raw %}<p>{% translate "Debugging window (serialized value)" %}</p>{% endif %}
<textarea id="{{ widget.attrs.id }}" class="vSerializedField required" cols="150" rows="10" name="{{ widget.name }}"
{% if not widget.attrs.display_raw %} hidden{% endif %}>{{ serialized }}</textarea>
{{ widget_options|json_script:"mapwidget-options" }}
</div>
28 changes: 27 additions & 1 deletion docs/ref/contrib/gis/forms-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 96,6 @@ Widget attributes
GeoDjango widgets are template-based, so their attributes are mostly different
from other Django widget attributes.


.. attribute:: BaseGeometryWidget.geom_type

The OpenGIS geometry type, generally set by the form field.
Expand Down Expand Up @@ -174,8 173,35 @@ Widget classes

The default map zoom is ``12``.

.. versionchanged:: 5.2

``OSMWidget`` doesn't use any custom template any longer. The
``gis/openlayers-osm.html`` template is removed from the Django source
code.

The :class:`OpenLayersWidget` note about JavaScript file hosting above also
applies here. See also this `FAQ answer`_ about ``https`` access to map
tiles.

.. _FAQ answer: https://help.openstreetmap.org/questions/10920/how-to-embed-a-map-in-my-https-site

Customizing the base layer used in OpenLayers-based widgets
-----------------------------------------------------------

If you need to customize the base layer shown in the OpenLayers-based geometry
widgets, you should write a custom JavaScript file to add a new layer builder.
For example::

MapWidget.layerBuilder.<custom_layer_name> = () => {
return new ol.layer.Tile({source: new ol.source.<ChosenSource>()});
}

Then you can subclass a standard widget and add at a minimum::

from django.contrib.gis-forms.widgets import OpenLayersWidget

class YourCustomWidget(OpenLayersWidget):
base_layer_name = "custom_layer_name"

class Media:
js = ["path.to.jsfile"]
5 changes: 5 additions & 0 deletions docs/releases/5.2.txt
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 290,11 @@ backends.

* Support for PostGIS 3.0 is removed.

* Rendering of geometry widgets is rewritten to get rid of inline JavaScript in
templates. If you customized any of the geometry widgets from
``django.contrib.gis.forms.widgets`` or their templates. You should check if
your version still runs with Django 5.2.

Dropped support for PostgreSQL 13
---------------------------------

Expand Down
42 changes: 24 additions & 18 deletions tests/gis_tests/test_geoforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,15 250,19 @@ def setUp(self):
),
}

def assertMapWidget(self, form_instance):
def assertMapWidget(self, form_instance, geom_type):
"""
Make sure the MapWidget js is passed in the form media and a MapWidget
is actually created
"""
self.assertTrue(form_instance.is_valid())
rendered = form_instance.as_p()
self.assertIn("new MapWidget(options);", rendered)
self.assertIn("map_srid: 3857,", rendered)
self.assertInHTML(
'<script id="mapwidget-options" type="application/json">'
f'{{"base_layer": "nasaWorldview", "geom_name": "{geom_type}", '
'"map_srid": 3857}</script>',
rendered,
)
self.assertIn("gis/js/OLMapWidget.js", str(form_instance.media))

def assertTextarea(self, geom, rendered):
Expand All @@ -279,7 283,7 @@ class PointForm(forms.Form):
geom = self.geometries["point"]
form = PointForm(data={"p": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "Point")
self.assertFalse(PointForm().is_valid())
invalid = PointForm(data={"p": "some invalid geom"})
self.assertFalse(invalid.is_valid())
Expand All @@ -295,7 299,7 @@ class PointForm(forms.Form):
geom = self.geometries["multipoint"]
form = PointForm(data={"p": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "MultiPoint")
self.assertFalse(PointForm().is_valid())

for invalid in [
Expand All @@ -310,7 314,7 @@ class LineStringForm(forms.Form):
geom = self.geometries["linestring"]
form = LineStringForm(data={"f": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "LineString")
self.assertFalse(LineStringForm().is_valid())

for invalid in [
Expand All @@ -325,7 329,7 @@ class LineStringForm(forms.Form):
geom = self.geometries["multilinestring"]
form = LineStringForm(data={"f": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "MultiLineString")
self.assertFalse(LineStringForm().is_valid())

for invalid in [
Expand All @@ -340,7 344,7 @@ class PolygonForm(forms.Form):
geom = self.geometries["polygon"]
form = PolygonForm(data={"p": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "Polygon")
self.assertFalse(PolygonForm().is_valid())

for invalid in [
Expand All @@ -355,7 359,7 @@ class PolygonForm(forms.Form):
geom = self.geometries["multipolygon"]
form = PolygonForm(data={"p": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "MultiPolygon")
self.assertFalse(PolygonForm().is_valid())

for invalid in [
Expand All @@ -370,7 374,7 @@ class GeometryForm(forms.Form):
geom = self.geometries["geometrycollection"]
form = GeometryForm(data={"g": geom})
self.assertTextarea(geom, form.as_p())
self.assertMapWidget(form)
self.assertMapWidget(form, "GeometryCollection")
self.assertFalse(GeometryForm().is_valid())

for invalid in [
Expand All @@ -393,8 397,8 @@ class PointForm(forms.Form):
form = PointForm(data={"p": geom})
rendered = form.as_p()

self.assertIn("ol.source.OSM()", rendered)
self.assertIn("id: 'id_p',", rendered)
self.assertIn('"base_layer": "osm"', rendered)
self.assertIn('<textarea id="id_p"', rendered)

def test_default_lat_lon(self):
self.assertEqual(forms.OSMWidget.default_lon, 5)
Expand All @@ -415,25 419,27 @@ class PointForm(forms.Form):
form = PointForm()
rendered = form.as_p()

self.assertIn("options['default_lon'] = 20;", rendered)
self.assertIn("options['default_lat'] = 30;", rendered)
self.assertIn("options['default_zoom'] = 17;", rendered)
self.assertIn(
'{"base_layer": "osm", "geom_name": "Point", "map_srid": 3857, '
'"default_lon": 20, "default_lat": 30, "default_zoom": 17}',
rendered,
)


class GeometryWidgetTests(SimpleTestCase):
def test_get_context_attrs(self):
# The Widget.get_context() attrs argument overrides self.attrs.
widget = BaseGeometryWidget(attrs={"geom_type": "POINT"})
context = widget.get_context("point", None, attrs={"geom_type": "POINT2"})
self.assertEqual(context["geom_type"], "POINT2")
self.assertEqual(context["widget"]["attrs"]["geom_type"], "POINT2")
# Widget.get_context() returns expected name for geom_type.
widget = BaseGeometryWidget(attrs={"geom_type": "POLYGON"})
context = widget.get_context("polygon", None, None)
self.assertEqual(context["geom_type"], "Polygon")
self.assertEqual(context["widget_options"]["geom_name"], "Polygon")
# Widget.get_context() returns 'Geometry' instead of 'Unknown'.
widget = BaseGeometryWidget(attrs={"geom_type": "GEOMETRY"})
context = widget.get_context("geometry", None, None)
self.assertEqual(context["geom_type"], "Geometry")
self.assertEqual(context["widget_options"]["geom_name"], "Geometry")

def test_subwidgets(self):
widget = forms.BaseGeometryWidget()
Expand Down

0 comments on commit ff9186b

Please sign in to comment.