1
0
mirror of https://github.com/django/django.git synced 2025-06-14 16:09:12 +00:00

Fixed #25706 -- Refactored geometry widgets to remove inline JavaScript.

Refactored GIS-related JavaScript initialization to eliminate inline
scripts from templates. Added support for specifying a base layer using
the new `base_layer_name` attribute on `BaseGeometryWidget`, allowing
custom map tile providers via user-defined JavaScript.

As a result, the `gis/openlayers-osm.html` template was removed.

Thanks Sarah Boyce for reviews.

Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
This commit is contained in:
Claude Paroz 2024-08-18 15:29:30 +02:00 committed by nessita
parent e80b33ae4d
commit f2f6046c0f
8 changed files with 308 additions and 96 deletions

View File

@ -1,11 +1,9 @@
import logging
from django.conf import settings
from django.contrib.gis import gdal
from django.contrib.gis.geometry import json_regex
from django.contrib.gis.geos import GEOSException, GEOSGeometry
from django.forms.widgets import Widget
from django.utils import translation
logger = logging.getLogger("django.contrib.gis")
@ -16,6 +14,7 @@ class BaseGeometryWidget(Widget):
Render a map using the WKT of the geometry.
"""
base_layer = None
geom_type = "GEOMETRY"
map_srid = 4326
display_raw = False
@ -24,9 +23,10 @@ class BaseGeometryWidget(Widget):
template_name = "" # set on subclasses
def __init__(self, attrs=None):
self.attrs = {}
for key in ("geom_type", "map_srid", "display_raw"):
self.attrs[key] = getattr(self, key)
self.attrs = {
key: getattr(self, key)
for key in ("base_layer", "geom_type", "map_srid", "display_raw")
}
if attrs:
self.attrs.update(attrs)
@ -61,26 +61,16 @@ class BaseGeometryWidget(Widget):
self.map_srid,
err,
)
context["serialized"] = self.serialize(value)
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["widget"]["attrs"]["geom_name"] = (
"Geometry" if geom_type == "Unknown" else geom_type
)
return context
class OpenLayersWidget(BaseGeometryWidget):
base_layer = "nasaWorldview"
template_name = "gis/openlayers.html"
map_srid = 3857
@ -112,14 +102,15 @@ class OSMWidget(OpenLayersWidget):
An OpenLayers/OpenStreetMap-based widget.
"""
template_name = "gis/openlayers-osm.html"
base_layer = "osm"
default_lon = 5
default_lat = 47
default_zoom = 12
def __init__(self, attrs=None):
super().__init__()
for key in ("default_lon", "default_lat", "default_zoom"):
self.attrs[key] = getattr(self, key)
if attrs:
self.attrs.update(attrs)
if attrs is None:
attrs = {}
attrs.setdefault("default_lon", self.default_lon)
attrs.setdefault("default_lat", self.default_lat)
attrs.setdefault("default_zoom", self.default_zoom)
super().__init__(attrs=attrs)

View File

@ -58,8 +58,16 @@ class MapWidget {
this.options[property] = options[property];
}
}
if (!options.base_layer) {
this.options.base_layer = new ol.layer.Tile({source: new ol.source.OSM()});
// Options' base_layer can be empty, or contain a layerBuilder key, or
// be a layer already constructed.
const base_layer = options.base_layer;
if (typeof base_layer === 'string' && base_layer in MapWidget.layerBuilder) {
this.baseLayer = MapWidget.layerBuilder[base_layer]();
} else if (base_layer && typeof base_layer !== 'string') {
this.baseLayer = base_layer;
} else {
this.baseLayer = MapWidget.layerBuilder.osm();
}
this.map = this.createMap();
@ -120,7 +128,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
})
@ -231,3 +239,43 @@ class MapWidget {
document.getElementById(this.options.id).value = jsonFormat.writeGeometry(geometry);
}
}
// Static property assignment (ES6-compatible)
MapWidget.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/%7BTime%7D/" +
"GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg"
})
});
},
osm: () => {
return new ol.layer.Tile({source: new ol.source.OSM()});
}
};
function initMapWidgetInSection(section) {
const maps = [];
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;
maps.push(new MapWidget(options));
});
return maps;
};
document.addEventListener("DOMContentLoaded", () => {
initMapWidgetInSection(document);
document.addEventListener('formset:added', (ev) => {initMapWidgetInSection(ev.target);});
});

View File

@ -1,12 +0,0 @@
{% extends "gis/openlayers.html" %}
{% load l10n %}
{% block options %}{{ block.super }}
options['default_lon'] = {{ default_lon|unlocalize }};
options['default_lat'] = {{ default_lat|unlocalize }};
options['default_zoom'] = {{ default_zoom|unlocalize }};
{% endblock %}
{% block base_layer %}
var base_layer = new ol.layer.Tile({source: new ol.source.OSM()});
{% endblock %}

View File

@ -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/%7BTime%7D/" +
"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.attrs|json_script:"mapwidget-options" }}
</div>

View File

@ -96,6 +96,14 @@ Widget attributes
GeoDjango widgets are template-based, so their attributes are mostly different
from other Django widget attributes.
.. attribute:: BaseGeometryWidget.base_layer
.. versionadded:: 6.0
A string that specifies the identifier for the default base map layer to be
used by the corresponding JavaScript map widget. It is passed as part of
the widget options when rendering, allowing the ``MapWidget`` to determine
which map tile provider or base layer to initialize (default is ``None``).
.. attribute:: BaseGeometryWidget.geom_type
@ -137,15 +145,29 @@ Widget classes
This is an abstract base widget containing the logic needed by subclasses.
You cannot directly use this widget for a geometry field.
Note that the rendering of GeoDjango widgets is based on a template,
identified by the :attr:`template_name` class attribute.
Note that the rendering of GeoDjango widgets is based on a base layer name,
identified by the :attr:`base_layer` class attribute.
``OpenLayersWidget``
.. class:: OpenLayersWidget
This is the default widget used by all GeoDjango form fields.
``template_name`` is ``gis/openlayers.html``.
This is the default widget used by all GeoDjango form fields. Attributes
are:
.. attribute:: base_layer
.. versionadded:: 6.0
``nasaWorldview``
.. attribute:: template_name
``gis/openlayers.html``.
.. attribute:: map_srid
``3857``
``OpenLayersWidget`` and :class:`OSMWidget` use the ``ol.js`` file hosted
on the ``cdn.jsdelivr.net`` content-delivery network. You can subclass
@ -157,12 +179,14 @@ Widget classes
.. class:: OSMWidget
This widget uses an OpenStreetMap base layer to display geographic objects
on. Attributes are:
This widget specialized :class:`OpenLayersWidget` and uses an OpenStreetMap
base layer to display geographic objects on. Attributes are:
.. attribute:: template_name
.. attribute:: base_layer
``gis/openlayers-osm.html``
.. versionadded:: 6.0
``osm``
.. attribute:: default_lat
.. attribute:: default_lon
@ -179,3 +203,37 @@ Widget classes
tiles.
.. _FAQ answer: https://help.openstreetmap.org/questions/10920/how-to-embed-a-map-in-my-https-site
.. versionchanged:: 6.0
The ``OSMWidget`` no longer uses a custom template. Consequently, the
``gis/openlayers-osm.html`` template was removed.
.. _geometry-widgets-customization:
Customizing the base layer used in OpenLayers-based widgets
-----------------------------------------------------------
.. versionadded:: 6.0
To customize the base layer displayed in OpenLayers-based geometry widgets,
define a new layer builder in a custom JavaScript file. For example:
.. code-block:: javascript
:caption: ``path-to-file.js``
MapWidget.layerBuilder.custom_layer_name = function () {
// Return an OpenLayers layer instance.
return new ol.layer.Tile({source: new ol.source.<ChosenSource>()});
};
Then, subclass a standard geometry widget and set the ``base_layer``::
from django.contrib.gis.forms.widgets import OpenLayersWidget
class YourCustomWidget(OpenLayersWidget):
base_layer = "custom_layer_name"
class Media:
js = ["path-to-file.js"]

View File

@ -73,6 +73,9 @@ Minor features
function rotates a geometry by a specified angle around the origin or a
specified point.
* The new :attr:`.BaseGeometryWidget.base_layer` attribute allows specifying a
JavaScript map base layer, enabling customization of map tile providers.
:mod:`django.contrib.messages`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@ -332,6 +335,11 @@ Miscellaneous
refactored to use Python's :py:class:`email.message.Message` for parsing.
Input headers exceeding 10000 characters will now raise :exc:`ValueError`.
* Widgets from :mod:`django.contrib.gis.forms.widgets` now render without
inline JavaScript in templates. If you have customized any geometry widgets
or their templates, you may need to :ref:`update them
<geometry-widgets-customization>` to match the new layout.
.. _deprecated-features-6.0:
Features deprecated in 6.0

View File

@ -1,4 +1,4 @@
/* global QUnit, MapWidget */
/* global QUnit, MapWidget, ol */
'use strict';
QUnit.module('gis.OLMapWidget');
@ -91,3 +91,84 @@ QUnit.test('MapWidget.IsCollection', function(assert) {
widget = new MapWidget(options);
assert.ok(widget.options.is_collection);
});
QUnit.test('MapWidget.layerBuilder.osm returns OSM layer', function(assert) {
const layer = MapWidget.layerBuilder.osm();
assert.ok(layer instanceof ol.layer.Tile, 'Layer is Tile');
assert.ok(layer.getSource() instanceof ol.source.OSM, 'Source is OSM');
});
QUnit.test('MapWidget.layerBuilder.nasaWorldview returns XYZ layer', function(assert) {
const layer = MapWidget.layerBuilder.nasaWorldview();
assert.ok(layer instanceof ol.layer.Tile, 'Layer is Tile');
assert.ok(layer.getSource() instanceof ol.source.XYZ, 'Source is XYZ');
assert.ok(layer.getSource().getUrls()[0].includes('earthdata.nasa.gov'), 'URL is NASA-hosted');
});
QUnit.test('MapWidget uses default OSM base layer when none specified', function(assert) {
const widget = new MapWidget({
id: 'id_point',
map_id: 'id_point_map',
geom_name: 'Point'
});
assert.ok(widget.baseLayer.getSource() instanceof ol.source.OSM, 'Default base layer is OSM');
});
QUnit.test('MapWidget uses named base layer from layerBuilder', function(assert) {
const widget = new MapWidget({
id: 'id_point',
map_id: 'id_point_map',
geom_name: 'Point',
base_layer: 'nasaWorldview'
});
assert.ok(widget.baseLayer.getSource() instanceof ol.source.XYZ, 'Uses named base layer from builder');
});
QUnit.test('MapWidget uses passed-in base layer object directly', function(assert) {
const customLayer = new ol.layer.Tile({source: new ol.source.OSM()});
const widget = new MapWidget({
id: 'id_point',
map_id: 'id_point_map',
geom_name: 'Point',
base_layer: customLayer
});
assert.strictEqual(widget.baseLayer, customLayer, 'Uses provided layer object');
});
QUnit.test('initMapWidgetInSection initializes widgets and skips __prefix__', function(assert) {
const wrapper1 = document.createElement('div');
wrapper1.className = 'dj_map_wrapper';
wrapper1.id = 'id_point_map_wrapper';
wrapper1.innerHTML = `
<textarea id="id_point"></textarea>
<div class="dj_map" id="id_point_map"></div>
<script type="application/json" id="mapwidget-options">
{ "geom_name": "Point" }
</script>
`;
document.body.appendChild(wrapper1);
const wrapper2 = document.createElement('div');
wrapper2.className = 'dj_map_wrapper';
wrapper2.id = 'form-__prefix__-map_wrapper';
wrapper2.innerHTML = `
<textarea id="id_fake"></textarea>
<div class="dj_map" id="id_fake_map"></div>
<script type="application/json" id="mapwidget-options">
{ "geom_name": "MultiPoint" }
</script>
`;
document.body.appendChild(wrapper2);
const maps = window.initMapWidgetInSection(document);
assert.equal(maps.length, 1, 'Only one map widget is initialized');
assert.ok(maps[0] instanceof MapWidget, 'Map is instance of MapWidget');
assert.equal(maps[0].options.id, 'id_point', 'Correct widget was initialized');
assert.equal(maps[0].options.map_id, 'id_point_map', 'Map ID is correct');
// Clean up
wrapper1.remove();
wrapper2.remove();
});

View File

@ -4,6 +4,7 @@ from django.contrib.gis import forms
from django.contrib.gis.forms import BaseGeometryWidget, OpenLayersWidget
from django.contrib.gis.geos import GEOSGeometry
from django.core.exceptions import ValidationError
from django.template.defaultfilters import json_script
from django.test import SimpleTestCase, override_settings
from django.utils.html import escape
@ -183,6 +184,37 @@ class GeometryFieldTest(SimpleTestCase):
"unrecognized as WKT EWKT, and HEXEWKB.)",
)
def test_override_attrs(self):
self.assertIsNone(forms.BaseGeometryWidget.base_layer)
self.assertEqual(forms.BaseGeometryWidget.geom_type, "GEOMETRY")
self.assertEqual(forms.BaseGeometryWidget.map_srid, 4326)
self.assertIs(forms.BaseGeometryWidget.display_raw, False)
class PointForm(forms.Form):
p = forms.PointField(
widget=forms.OpenLayersWidget(
attrs={
"base_layer": "some-test-file",
"map_srid": 1234,
}
),
)
form = PointForm()
rendered = form.as_p()
attrs = {
"base_layer": "some-test-file",
"geom_type": "POINT",
"map_srid": 1234,
"display_raw": False,
"required": True,
"id": "id_p",
"geom_name": "Point",
}
expected = json_script(attrs, "mapwidget-options")
self.assertInHTML(expected, rendered)
class SpecializedFieldTest(SimpleTestCase):
def setUp(self):
@ -250,15 +282,29 @@ class SpecializedFieldTest(SimpleTestCase):
),
}
def assertMapWidget(self, form_instance):
def assertMapWidget(self, form_instance, geom_name):
"""
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)
map_fields = [
f for f in form_instance if isinstance(f.field, forms.GeometryField)
]
for map_field in map_fields:
attrs = {
"base_layer": "nasaWorldview",
"geom_type": map_field.field.geom_type,
"map_srid": 3857,
"display_raw": False,
"required": True,
"id": map_field.id_for_label,
"geom_name": geom_name,
}
expected = json_script(attrs, "mapwidget-options")
self.assertInHTML(expected, rendered)
self.assertIn("gis/js/OLMapWidget.js", str(form_instance.media))
def assertTextarea(self, geom, rendered):
@ -279,7 +325,7 @@ class SpecializedFieldTest(SimpleTestCase):
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())
@ -295,7 +341,7 @@ class SpecializedFieldTest(SimpleTestCase):
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 [
@ -310,7 +356,7 @@ class SpecializedFieldTest(SimpleTestCase):
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 [
@ -325,7 +371,7 @@ class SpecializedFieldTest(SimpleTestCase):
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 [
@ -340,7 +386,7 @@ class SpecializedFieldTest(SimpleTestCase):
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 [
@ -355,7 +401,7 @@ class SpecializedFieldTest(SimpleTestCase):
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 [
@ -370,7 +416,7 @@ class SpecializedFieldTest(SimpleTestCase):
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 [
@ -393,8 +439,8 @@ class OSMWidgetTest(SimpleTestCase):
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)
@ -415,9 +461,20 @@ class OSMWidgetTest(SimpleTestCase):
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)
attrs = {
"base_layer": "osm",
"geom_type": "POINT",
"map_srid": 3857,
"display_raw": False,
"default_lon": 20,
"default_lat": 30,
"default_zoom": 17,
"required": True,
"id": "id_p",
"geom_name": "Point",
}
expected = json_script(attrs, "mapwidget-options")
self.assertInHTML(expected, rendered)
class GeometryWidgetTests(SimpleTestCase):
@ -425,15 +482,15 @@ class GeometryWidgetTests(SimpleTestCase):
# 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"]["attrs"]["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"]["attrs"]["geom_name"], "Geometry")
def test_subwidgets(self):
widget = forms.BaseGeometryWidget()
@ -443,7 +500,10 @@ class GeometryWidgetTests(SimpleTestCase):
{
"is_hidden": False,
"attrs": {
"base_layer": None,
"display_raw": False,
"map_srid": 4326,
"geom_name": "Geometry",
"geom_type": "GEOMETRY",
"display_raw": False,
},