diff options
| author | Claude Paroz <claude@2xlibre.net> | 2013-03-16 11:39:18 +0100 |
|---|---|---|
| committer | Claude Paroz <claude@2xlibre.net> | 2013-05-17 13:33:40 +0200 |
| commit | b16b72d415808073da0418de93bf32f71ead959d (patch) | |
| tree | 7006c92c5672fbfd5ca09f9e3f175550ca951adb | |
| parent | d4d114563280b6e202b8459be0aa471621c7b563 (diff) | |
Fixed #5472 --Added OpenLayers-based widgets in contrib.gis
Largely inspired from django-floppyforms. Designed to not depend
on OpenLayers at code level.
| -rw-r--r-- | django/contrib/gis/db/models/fields.py | 13 | ||||
| -rw-r--r-- | django/contrib/gis/forms/__init__.py | 5 | ||||
| -rw-r--r-- | django/contrib/gis/forms/fields.py | 35 | ||||
| -rw-r--r-- | django/contrib/gis/forms/widgets.py | 112 | ||||
| -rw-r--r-- | django/contrib/gis/static/gis/js/OLMapWidget.js | 371 | ||||
| -rw-r--r-- | django/contrib/gis/templates/gis/openlayers-osm.html | 17 | ||||
| -rw-r--r-- | django/contrib/gis/templates/gis/openlayers.html | 34 | ||||
| -rw-r--r-- | django/contrib/gis/tests/test_geoforms.py | 189 | ||||
| -rw-r--r-- | docs/ref/contrib/gis/forms-api.txt | 165 | ||||
| -rw-r--r-- | docs/ref/contrib/gis/index.txt | 1 | ||||
| -rw-r--r-- | docs/ref/forms/fields.txt | 2 | ||||
| -rw-r--r-- | docs/releases/1.6.txt | 7 |
12 files changed, 931 insertions, 20 deletions
diff --git a/django/contrib/gis/db/models/fields.py b/django/contrib/gis/db/models/fields.py index 249617f771..2e221b7477 100644 --- a/django/contrib/gis/db/models/fields.py +++ b/django/contrib/gis/db/models/fields.py @@ -44,6 +44,7 @@ class GeometryField(Field): # The OpenGIS Geometry name. geom_type = 'GEOMETRY' + form_class = forms.GeometryField # Geodetic units. geodetic_units = ('Decimal Degree', 'degree') @@ -201,11 +202,14 @@ class GeometryField(Field): return connection.ops.geo_db_type(self) def formfield(self, **kwargs): - defaults = {'form_class' : forms.GeometryField, + defaults = {'form_class' : self.form_class, 'geom_type' : self.geom_type, 'srid' : self.srid, } defaults.update(kwargs) + if (self.dim > 2 and not 'widget' in kwargs and + not getattr(defaults['form_class'].widget, 'supports_3d', False)): + defaults['widget'] = forms.Textarea return super(GeometryField, self).formfield(**defaults) def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False): @@ -267,28 +271,35 @@ class GeometryField(Field): # The OpenGIS Geometry Type Fields class PointField(GeometryField): geom_type = 'POINT' + form_class = forms.PointField description = _("Point") class LineStringField(GeometryField): geom_type = 'LINESTRING' + form_class = forms.LineStringField description = _("Line string") class PolygonField(GeometryField): geom_type = 'POLYGON' + form_class = forms.PolygonField description = _("Polygon") class MultiPointField(GeometryField): geom_type = 'MULTIPOINT' + form_class = forms.MultiPointField description = _("Multi-point") class MultiLineStringField(GeometryField): geom_type = 'MULTILINESTRING' + form_class = forms.MultiLineStringField description = _("Multi-line string") class MultiPolygonField(GeometryField): geom_type = 'MULTIPOLYGON' + form_class = forms.MultiPolygonField description = _("Multi polygon") class GeometryCollectionField(GeometryField): geom_type = 'GEOMETRYCOLLECTION' + form_class = forms.GeometryCollectionField description = _("Geometry collection") diff --git a/django/contrib/gis/forms/__init__.py b/django/contrib/gis/forms/__init__.py index 82971da6be..93a2d3847b 100644 --- a/django/contrib/gis/forms/__init__.py +++ b/django/contrib/gis/forms/__init__.py @@ -1,2 +1,5 @@ from django.forms import * -from django.contrib.gis.forms.fields import GeometryField +from .fields import (GeometryField, GeometryCollectionField, PointField, + MultiPointField, LineStringField, MultiLineStringField, PolygonField, + MultiPolygonField) +from .widgets import BaseGeometryWidget, OpenLayersWidget, OSMWidget diff --git a/django/contrib/gis/forms/fields.py b/django/contrib/gis/forms/fields.py index d3feac83e7..6e2cbd59f5 100644 --- a/django/contrib/gis/forms/fields.py +++ b/django/contrib/gis/forms/fields.py @@ -9,6 +9,7 @@ from django.utils.translation import ugettext_lazy as _ # While this couples the geographic forms to the GEOS library, # it decouples from database (by not importing SpatialBackend). from django.contrib.gis.geos import GEOSException, GEOSGeometry, fromstr +from .widgets import OpenLayersWidget class GeometryField(forms.Field): @@ -17,7 +18,8 @@ class GeometryField(forms.Field): accepted by GEOSGeometry is accepted by this form. By default, this includes WKT, HEXEWKB, WKB (in a buffer), and GeoJSON. """ - widget = forms.Textarea + widget = OpenLayersWidget + geom_type = 'GEOMETRY' default_error_messages = { 'required' : _('No geometry value provided.'), @@ -31,12 +33,13 @@ class GeometryField(forms.Field): # Pop out attributes from the database field, or use sensible # defaults (e.g., allow None). self.srid = kwargs.pop('srid', None) - self.geom_type = kwargs.pop('geom_type', 'GEOMETRY') + self.geom_type = kwargs.pop('geom_type', self.geom_type) if 'null' in kwargs: kwargs.pop('null', True) warnings.warn("Passing 'null' keyword argument to GeometryField is deprecated.", DeprecationWarning, stacklevel=2) super(GeometryField, self).__init__(**kwargs) + self.widget.attrs['geom_type'] = self.geom_type def to_python(self, value): """ @@ -98,3 +101,31 @@ class GeometryField(forms.Field): else: # Check for change of state of existence return bool(initial) != bool(data) + + +class GeometryCollectionField(GeometryField): + geom_type = 'GEOMETRYCOLLECTION' + + +class PointField(GeometryField): + geom_type = 'POINT' + + +class MultiPointField(GeometryField): + geom_type = 'MULTIPOINT' + + +class LineStringField(GeometryField): + geom_type = 'LINESTRING' + + +class MultiLineStringField(GeometryField): + geom_type = 'MULTILINESTRING' + + +class PolygonField(GeometryField): + geom_type = 'POLYGON' + + +class MultiPolygonField(GeometryField): + geom_type = 'MULTIPOLYGON' diff --git a/django/contrib/gis/forms/widgets.py b/django/contrib/gis/forms/widgets.py new file mode 100644 index 0000000000..d50c7c005a --- /dev/null +++ b/django/contrib/gis/forms/widgets.py @@ -0,0 +1,112 @@ +from __future__ import unicode_literals + +import logging + +from django.conf import settings +from django.contrib.gis import gdal +from django.contrib.gis.geos import GEOSGeometry, GEOSException +from django.forms.widgets import Widget +from django.template import loader +from django.utils import six +from django.utils import translation + +logger = logging.getLogger('django.contrib.gis') + + +class BaseGeometryWidget(Widget): + """ + The base class for rich geometry widgets. + Renders a map using the WKT of the geometry. + """ + geom_type = 'GEOMETRY' + map_srid = 4326 + map_width = 600 + map_height = 400 + display_wkt = False + + supports_3d = False + template_name = '' # set on subclasses + + def __init__(self, attrs=None): + self.attrs = {} + for key in ('geom_type', 'map_srid', 'map_width', 'map_height', 'display_wkt'): + self.attrs[key] = getattr(self, key) + if attrs: + self.attrs.update(attrs) + + def render(self, name, value, attrs=None): + # If a string reaches here (via a validation error on another + # field) then just reconstruct the Geometry. + if isinstance(value, six.string_types): + try: + value = GEOSGeometry(value) + except (GEOSException, ValueError) as err: + logger.error( + "Error creating geometry from value '%s' (%s)" % ( + value, err) + ) + value = None + + wkt = '' + if value: + # Check that srid of value and map match + if value.srid != self.map_srid: + try: + ogr = value.ogr + ogr.transform(self.map_srid) + wkt = ogr.wkt + except gdal.OGRException as err: + logger.error( + "Error transforming geometry from srid '%s' to srid '%s' (%s)" % ( + value.srid, self.map_srid, err) + ) + else: + wkt = value.wkt + + context = self.build_attrs(attrs, + name=name, + module='geodjango_%s' % name.replace('-','_'), # JS-safe + wkt=wkt, + geom_type=gdal.OGRGeomType(self.attrs['geom_type']), + STATIC_URL=settings.STATIC_URL, + LANGUAGE_BIDI=translation.get_language_bidi(), + ) + return loader.render_to_string(self.template_name, context) + + +class OpenLayersWidget(BaseGeometryWidget): + template_name = 'gis/openlayers.html' + class Media: + js = ( + 'http://openlayers.org/api/2.11/OpenLayers.js', + 'gis/js/OLMapWidget.js', + ) + + +class OSMWidget(BaseGeometryWidget): + """ + An OpenLayers/OpenStreetMap-based widget. + """ + template_name = 'gis/openlayers-osm.html' + default_lon = 5 + default_lat = 47 + + class Media: + js = ( + 'http://openlayers.org/api/2.11/OpenLayers.js', + 'http://www.openstreetmap.org/openlayers/OpenStreetMap.js', + 'gis/js/OLMapWidget.js', + ) + + @property + def map_srid(self): + # Use the official spherical mercator projection SRID on versions + # of GDAL that support it; otherwise, fallback to 900913. + if gdal.HAS_GDAL and gdal.GDAL_VERSION >= (1, 7): + return 3857 + else: + return 900913 + + def render(self, name, value, attrs=None): + return super(self, OSMWidget).render(name, value, + {'default_lon': self.default_lon, 'default_lat': self.default_lat}) diff --git a/django/contrib/gis/static/gis/js/OLMapWidget.js b/django/contrib/gis/static/gis/js/OLMapWidget.js new file mode 100644 index 0000000000..252196b369 --- /dev/null +++ b/django/contrib/gis/static/gis/js/OLMapWidget.js @@ -0,0 +1,371 @@ +(function() { +/** + * Transforms an array of features to a single feature with the merged + * geometry of geom_type + */ +OpenLayers.Util.properFeatures = function(features, geom_type) { + if (features.constructor == Array) { + var geoms = []; + for (var i=0; i<features.length; i++) { + geoms.push(features[i].geometry); + } + var geom = new geom_type(geoms); + features = new OpenLayers.Feature.Vector(geom); + } + return features; +} + +/** + * @requires OpenLayers/Format/WKT.js + */ + +/** + * Class: OpenLayers.Format.DjangoWKT + * Class for reading Well-Known Text, with workarounds to successfully parse + * geometries and collections as returnes by django.contrib.gis.geos. + * + * Inherits from: + * - <OpenLayers.Format.WKT> + */ + +OpenLayers.Format.DjangoWKT = OpenLayers.Class(OpenLayers.Format.WKT, { + initialize: function(options) { + OpenLayers.Format.WKT.prototype.initialize.apply(this, [options]); + this.regExes.justComma = /\s*,\s*/; + }, + + parse: { + 'point': function(str) { + var coords = OpenLayers.String.trim(str).split(this.regExes.spaces); + return new OpenLayers.Feature.Vector( + new OpenLayers.Geometry.Point(coords[0], coords[1]) + ); + }, + + 'multipoint': function(str) { + var point; + var points = OpenLayers.String.trim(str).split(this.regExes.justComma); + var components = []; + for(var i=0, len=points.length; i<len; ++i) { + point = points[i].replace(this.regExes.trimParens, '$1'); + components.push(this.parse.point.apply(this, [point]).geometry); + } + return new OpenLayers.Feature.Vector( + new OpenLayers.Geometry.MultiPoint(components) + ); + }, + + 'linestring': function(str) { + var points = OpenLayers.String.trim(str).split(','); + var components = []; + for(var i=0, len=points.length; i<len; ++i) { + components.push(this.parse.point.apply(this, [points[i]]).geometry); + } + return new OpenLayers.Feature.Vector( + new OpenLayers.Geometry.LineString(components) + ); + }, + + 'multilinestring': function(str) { + var line; + var lines = OpenLayers.String.trim(str).split(this.regExes.parenComma); + var components = []; + for(var i=0, len=lines.length; i<len; ++i) { + line = lines[i].replace(this.regExes.trimParens, '$1'); + components.push(this.parse.linestring.apply(this, [line]).geometry); + } + return new OpenLayers.Feature.Vector( + new OpenLayers.Geometry.MultiLineString(components) + ); + }, + + 'polygon': function(str) { + var ring, linestring, linearring; + var rings = OpenLayers.String.trim(str).split(this.regExes.parenComma); + var components = []; + for(var i=0, len=rings.length; i<len; ++i) { + ring = rings[i].replace(this.regExes.trimParens, '$1'); + linestring = this.parse.linestring.apply(this, [ring]).geometry; + linearring = new OpenLayers.Geometry.LinearRing(linestring.components); + components.push(linearring); + } + return new OpenLayers.Feature.Vector( + new OpenLayers.Geometry.Polygon(components) + ); + }, + + 'multipolygon': function(str) { + var polygon; + var polygons = OpenLayers.String.trim(str).split(this.regExes.doubleParenComma); + var components = []; + for(var i=0, len=polygons.length; i<len; ++i) { + polygon = polygons[i].replace(this.regExes.trimParens, '$1'); + components.push(this.parse.polygon.apply(this, [polygon]).geometry); + } + return new OpenLayers.Feature.Vector( + new OpenLayers.Geometry.MultiPolygon(components) + ); + }, + + 'geometrycollection': function(str) { + // separate components of the collection with | + str = str.replace(/,\s*([A-Za-z])/g, '|$1'); + var wktArray = OpenLayers.String.trim(str).split('|'); + var components = []; + for(var i=0, len=wktArray.length; i<len; ++i) { + components.push(OpenLayers.Format.WKT.prototype.read.apply(this,[wktArray[i]])); + } + return components; + } + }, + + extractGeometry: function(geometry) { + var type = geometry.CLASS_NAME.split('.')[2].toLowerCase(); + if (!this.extract[type]) { + return null; + } + if (this.internalProjection && this.externalProjection) { + geometry = geometry.clone(); + geometry.transform(this.internalProjection, this.externalProjection); + } + var wktType = type == 'collection' ? 'GEOMETRYCOLLECTION' : type.toUpperCase(); + var data = wktType + '(' + this.extract[type].apply(this, [geometry]) + ')'; + return data; + }, + + /** + * Patched write: successfully writes WKT for geometries and + * geometrycollections. + */ + write: function(features) { + var collection, geometry, type, data, isCollection; + isCollection = features.geometry.CLASS_NAME == "OpenLayers.Geometry.Collection"; + var pieces = []; + if (isCollection) { + collection = features.geometry.components; + pieces.push('GEOMETRYCOLLECTION('); + for (var i=0, len=collection.length; i<len; ++i) { + if (i>0) { + pieces.push(','); + } + pieces.push(this.extractGeometry(collection[i])); + } + pieces.push(')'); + } else { + pieces.push(this.extractGeometry(features.geometry)); + } + return pieces.join(''); + }, + + CLASS_NAME: "OpenLayers.Format.DjangoWKT" +}); + +function MapWidget(options) { + this.map = null; + this.controls = null; + this.panel = null; + this.layers = {}; + this.wkt_f = new OpenLayers.Format.DjangoWKT(); + + // Mapping from OGRGeomType name to OpenLayers.Geometry name + if (options['geom_name'] == 'Unknown') options['geom_type'] = OpenLayers.Geometry; + else if (options['geom_name'] == 'GeometryCollection') options['geom_type'] = OpenLayers.Geometry.Collection; + else options['geom_type'] = eval('OpenLayers.Geometry' + options['geom_name']); + + // Default options + this.options = { + color: 'ee9900', + default_lat: 0, + default_lon: 0, + default_zoom: 4, + is_collection: options['geom_type'] instanceof OpenLayers.Geometry.Collection, + layerswitcher: false, + map_options: {}, + map_srid: 4326, + modifiable: true, + mouse_position: false, + opacity: 0.4, + point_zoom: 12, + scale_text: false, + scrollable: true + }; + + // Altering using user-provied options + for (var property in options) { + if (options.hasOwnProperty(property)) { + this.options[property] = options[property]; + } + } + + this.map = new OpenLayers.Map(this.options.map_id, this.options.map_options); + if (this.options.base_layer) this.layers.base = this.options.base_layer; + else this.layers.base = new OpenLayers.Layer.WMS('OpenLayers WMS', 'http://vmap0.tiles.osgeo.org/wms/vmap0', {layers: 'basic'}); + this.map.addLayer(this.layers.base); + + var defaults_style = { + 'fillColor': '#' + this.options.color, + 'fillOpacity': this.options.opacity, + 'strokeColor': '#' + this.options.color, + }; + if (this.options.geom_name == 'LineString') { + defaults_style['strokeWidth'] = 3; + } + var styleMap = new OpenLayers.StyleMap({'default': OpenLayers.Util.applyDefaults(defaults_style, OpenLayers.Feature.Vector.style['default'])}); + this.layers.vector = new OpenLayers.Layer.Vector(" " + this.options.name, {styleMap: styleMap}); + this.map.addLayer(this.layers.vector); + wkt = document.getElementById(this.options.id).value; + if (wkt) { + var feat = OpenLayers.Util.properFeatures(this.read_wkt(wkt), this.options.geom_type); + this.write_wkt(feat); + if (this.options.is_collection) { + for (var i=0; i<this.num_geom; i++) { + this.layers.vector.addFeatures([new OpenLayers.Feature.Vector(feat.geometry.components[i].clone())]); + } + } else { + this.layers.vector.addFeatures([feat]); + } + this.map.zoomToExtent(feat.geometry.getBounds()); + if (this.options.geom_name == 'Point') { + this.map.zoomTo(this.options.point_zoom); + } + } else { + this.map.setCenter(this.defaultCenter(), this.options.default_zoom); + } + this.layers.vector.events.on({'featuremodified': this.modify_wkt, scope: this}); + this.layers.vector.events.on({'featureadded': this.add_wkt, scope: this}); + + this.getControls(this.layers.vector); + this.panel.addControls(this.controls); + this.map.addControl(this.panel); + this.addSelectControl(); + + if (this.options.mouse_position) { + this.map.addControl(new OpenLayers.Control.MousePosition()); + } + if (this.options.scale_text) { + this.map.addControl(new OpenLayers.Control.Scale()); + } + if (this.options.layerswitcher) { + this.map.addControl(new OpenLayers.Control.LayerSwitcher()); + } + if (!this.options.scrollable) { + this.map.getControlsByClass('OpenLayers.Control.Navigation')[0].disableZoomWheel(); + } + if (wkt) { + if (this.options.modifiable) { + this.enableEditing(); + } + } else { + this.enableDrawing(); + } +} + +MapWidget.prototype.get_ewkt = function(feat) { + return "SRID=" + this.options.map_srid + ";" + this.wkt_f.write(feat); +}; + +MapWidget.prototype.read_wkt = function(wkt) { + var prefix = 'SRID=' + this.options.map_srid + ';' + if (wkt.indexOf(prefix) === 0) { + wkt = wkt.slice(prefix.length); + } + return this.wkt_f.read(wkt); +}; + +MapWidget.prototype.write_wkt = function(feat) { + feat = OpenLayers.Util.properFeatures(feat, this.options.geom_type); + if (this.options.is_collection) { + this.num_geom = feat.geometry.components.length; + } else { + this.num_geom = 1; + } + document.getElementById(this.options.id).value = this.get_ewkt(feat); +}; + +MapWidget.prototype.add_wkt = function(event) { + if (this.options.is_collection) { + var feat = new OpenLayers.Feature.Vector(new this.options.geom_type()); + for (var i=0; i<this.layers.vector.features.length; i++) { + feat.geometry.addComponents([this.layers.vector.features[i].geometry]); + } + this.write_wkt(feat); + } else { + if (this.layers.vector.features.length > 1) { + old_feats = [this.layers.vector.features[0]]; + this.layers.vector.removeFeatures(old_feats); + this.layers.vector.destroyFeatures(old_feats); + } + this.write_wkt(event.feature); + } +}; + +MapWidget.prototype.modify_wkt = function(event) { + if (this.options.is_collection) { + if (this.options.geom_name == 'MultiPoint') { + this.add_wkt(event); + return; + } else { + var feat = new OpenLayers.Feature.Vector(new this.options.geom_type()); + for (var i=0; i<this.num_geom; i++) { + feat.geometry.addComponents([this.layers.vector.features[i].geometry]); + } + this.write_wkt(feat); + } + } else { + this.write_wkt(event.feature); + } +}; + +MapWidget.prototype.deleteFeatures = function() { + this.layers.vector.removeFeatures(this.layers.vector.features); + this.layers.vector.destroyFeatures(); +}; + +MapWidget.prototype.clearFeatures = function() { + this.deleteFeatures(); + document.getElementById(this.options.id).value = ''; + this.map.setCenter(this.defaultCenter(), this.options.default_zoom); +}; + +MapWidget.prototype.defaultCenter = function() { + var center = new OpenLayers.LonLat(this.options.default_lon, this.options.default_lat); + if (this.options.map_srid) { + return center.transform(new OpenLayers.Projection("EPSG:4326"), this.map.getProjectionObject()); + } + return center; +}; + +MapWidget.prototype.addSelectControl = function() { + var select = new OpenLayers.Control.SelectFeature(this.layers.vector, {'toggle': true, 'clickout': true}); + this.map.addControl(select); + select.activate(); +}; + +MapWidget.prototype.enableDrawing = function () { + this.map.getControlsByClass('OpenLayers.Control.DrawFeature')[0].activate(); +}; + +MapWidget.prototype.enableEditing = function () { + this.map.getControlsByClass('OpenLayers.Control.ModifyFeature')[0].activate(); +}; + +MapWidget.prototype.getControls = function(layer) { + this.panel = new OpenLayers.Control.Panel({'displayClass': 'olControlEditingToolbar'}); + this.controls = [new OpenLayers.Control.Navigation()]; + if (!this.options.modifiable && layer.features.length) + return; + if (this.options.geom_name == 'LineString' || this.options.geom_name == 'Unknown') { + this.controls.push(new OpenLayers.Control.DrawFeature(layer, OpenLayers.Handler.Path, {'displayClass': 'olControlDrawFeaturePath'})); + } + if (this.options.geom_name == 'Polygon' || this.options.geom_name == 'Unknown') { + this.controls.push(new OpenLayers.Control.DrawFeature(layer, OpenLayers.Handler.Polygon, {'displayClass': 'olControlDrawFeaturePolygon'})); + } + if (this.options.geom_name == 'Point' || this.options.geom_name == 'Unknown') { + this.controls.push(new OpenLayers.Control.DrawFeature(layer, OpenLayers.Handler.Point, {'displayClass': 'olControlDrawFeaturePoint'})); + } + if (this.options.modifiable) { + this.controls.push(new OpenLayers.Control.ModifyFeature(layer, {'displayClass': 'olControlModifyFeature'})); + } +}; +window.MapWidget = MapWidget; +})(); diff --git a/django/contrib/gis/templates/gis/openlayers-osm.html b/django/contrib/gis/templates/gis/openlayers-osm.html new file mode 100644 index 0000000000..7b644aaa8d --- /dev/null +++ b/django/contrib/gis/templates/gis/openlayers-osm.html @@ -0,0 +1,17 @@ +{% extends "gis/openlayers.html" %} +{% load l10n %} + +{% block map_options %}var map_options = { + maxExtend: new OpenLayers.Bounds(-20037508,-20037508,20037508,20037508), + maxResolution: 156543.0339, + numZoomLevels: 20, + units: 'm' +};{% endblock %} + +{% block options %}{{ block.super }} +options['scale_text'] = true; +options['mouse_position'] = true; +options['default_lon'] = {{ default_lon|unlocalize }}; +options['default_lat'] = {{ default_lat|unlocalize }}; +options['base_layer'] = new OpenLayers.Layer.OSM.Mapnik("OpenStreetMap (Mapnik)"); +{% endblock %} diff --git a/django/contrib/gis/templates/gis/openlayers.html b/django/contrib/gis/templates/gis/openlayers.html new file mode 100644 index 0000000000..281c0badd6 --- /dev/null +++ b/django/contrib/gis/templates/gis/openlayers.html @@ -0,0 +1,34 @@ +<style type="text/css">{% block map_css %} + #{{ id }}_map { width: {{ map_width }}px; height: {{ map_height }}px; } + #{{ id }}_map .aligned label { float: inherit; } + #{{ id }}_div_map { position: relative; vertical-align: top; float: {{ LANGUAGE_BIDI|yesno:"right,left" }}; } + {% if not display_wkt %}#{{ id }} { display: none; }{% endif %} + .olControlEditingToolbar .olControlModifyFeatureItemActive { + background-image: url("{{ STATIC_URL }}admin/img/gis/move_vertex_on.png"); + background-repeat: no-repeat; + } + .olControlEditingToolbar .olControlModifyFeatureItemInactive { + background-image: url("{{ STATIC_URL }}admin/img/gis/move_vertex_off.png"); + background-repeat: no-repeat; + }{% endblock %} +</style> + +<div id="{{ id }}_div_map"> + <div id="{{ id }}_map"></div> + <span class="clear_features"><a href="javascript:{{ module }}.clearFeatures()">Delete all Features</a></span> + {% if display_wkt %}<p> WKT debugging window:</p>{% endif %} + <textarea id="{{ id }}" class="vWKTField required" cols="150" rows="10" name="{{ name }}">{{ wkt }}</textarea> + <script type="text/javascript"> + {% block map_options %}var map_options = {};{% endblock %} + {% block options %}var options = { + geom_name: '{{ geom_type }}', + id: '{{ id }}', + map_id: '{{ id }}_map', + map_options: map_options, + map_srid: {{ map_srid }}, + name: '{{ name }}' + }; + {% endblock %} + var {{ module }} = new MapWidget(options); + </script> +</div> diff --git a/django/contrib/gis/tests/test_geoforms.py b/django/contrib/gis/tests/test_geoforms.py index 24bb50c6bc..402d9b944b 100644 --- a/django/contrib/gis/tests/test_geoforms.py +++ b/django/contrib/gis/tests/test_geoforms.py @@ -1,24 +1,25 @@ from django.forms import ValidationError from django.contrib.gis.gdal import HAS_GDAL from django.contrib.gis.tests.utils import HAS_SPATIALREFSYS +from django.test import SimpleTestCase from django.utils import six -from django.utils import unittest +from django.utils.unittest import skipUnless if HAS_SPATIALREFSYS: from django.contrib.gis import forms from django.contrib.gis.geos import GEOSGeometry -@unittest.skipUnless(HAS_GDAL and HAS_SPATIALREFSYS, "GeometryFieldTest needs gdal support and a spatial database") -class GeometryFieldTest(unittest.TestCase): +@skipUnless(HAS_GDAL and HAS_SPATIALREFSYS, "GeometryFieldTest needs gdal support and a spatial database") +class GeometryFieldTest(SimpleTestCase): - def test00_init(self): + def test_init(self): "Testing GeometryField initialization with defaults." fld = forms.GeometryField() for bad_default in ('blah', 3, 'FoO', None, 0): self.assertRaises(ValidationError, fld.clean, bad_default) - def test01_srid(self): + def test_srid(self): "Testing GeometryField with a SRID set." # Input that doesn't specify the SRID is assumed to be in the SRID # of the input field. @@ -34,7 +35,7 @@ class GeometryFieldTest(unittest.TestCase): cleaned_geom = fld.clean('SRID=4326;POINT (-95.363151 29.763374)') self.assertTrue(xform_geom.equals_exact(cleaned_geom, tol)) - def test02_null(self): + def test_null(self): "Testing GeometryField's handling of null (None) geometries." # Form fields, by default, are required (`required=True`) fld = forms.GeometryField() @@ -46,7 +47,7 @@ class GeometryFieldTest(unittest.TestCase): fld = forms.GeometryField(required=False) self.assertIsNone(fld.clean(None)) - def test03_geom_type(self): + def test_geom_type(self): "Testing GeometryField's handling of different geometry types." # By default, all geometry types are allowed. fld = forms.GeometryField() @@ -60,7 +61,7 @@ class GeometryFieldTest(unittest.TestCase): # but rejected by `clean` self.assertRaises(forms.ValidationError, pnt_fld.clean, 'LINESTRING(0 0, 1 1)') - def test04_to_python(self): + def test_to_python(self): """ Testing to_python returns a correct GEOSGeometry object or a ValidationError @@ -74,13 +75,169 @@ class GeometryFieldTest(unittest.TestCase): self.assertRaises(forms.ValidationError, fld.to_python, wkt) -def suite(): - s = unittest.TestSuite() - s.addTest(unittest.makeSuite(GeometryFieldTest)) - return s +@skipUnless(HAS_GDAL and HAS_SPATIALREFSYS, + "SpecializedFieldTest needs gdal support and a spatial database") +class SpecializedFieldTest(SimpleTestCase): + def setUp(self): + self.geometries = { + 'point': GEOSGeometry("SRID=4326;POINT(9.052734375 42.451171875)"), + 'multipoint': GEOSGeometry("SRID=4326;MULTIPOINT(" + "(13.18634033203125 14.504356384277344)," + "(13.207969665527 14.490966796875)," + "(13.177070617675 14.454917907714))"), + 'linestring': GEOSGeometry("SRID=4326;LINESTRING(" + "-8.26171875 -0.52734375," + "-7.734375 4.21875," + "6.85546875 3.779296875," + "5.44921875 -3.515625)"), + 'multilinestring': GEOSGeometry("SRID=4326;MULTILINESTRING(" + "(-16.435546875 -2.98828125," + "-17.2265625 2.98828125," + "-0.703125 3.515625," + "-1.494140625 -3.33984375)," + "(-8.0859375 -5.9765625," + "8.525390625 -8.7890625," + "12.392578125 -0.87890625," + "10.01953125 7.646484375))"), + 'polygon': GEOSGeometry("SRID=4326;POLYGON(" + "(-1.669921875 6.240234375," + "-3.8671875 -0.615234375," + "5.9765625 -3.955078125," + "18.193359375 3.955078125," + "9.84375 9.4921875," + "-1.669921875 6.240234375))"), + 'multipolygon': GEOSGeometry("SRID=4326;MULTIPOLYGON(" + "((-17.578125 13.095703125," + "-17.2265625 10.8984375," + "-13.974609375 10.1953125," + "-13.359375 12.744140625," + "-15.732421875 13.7109375," + "-17.578125 13.095703125))," + "((-8.525390625 5.537109375," + "-8.876953125 2.548828125," + "-5.888671875 1.93359375," + "-5.09765625 4.21875," + "-6.064453125 6.240234375," + "-8.525390625 5.537109375)))"), + 'geometrycollection': GEOSGeometry("SRID=4326;GEOMETRYCOLLECTION(" + "POINT(5.625 -0.263671875)," + "POINT(6.767578125 -3.603515625)," + "POINT(8.525390625 0.087890625)," + "POINT(8.0859375 -2.13134765625)," + "LINESTRING(" + "6.273193359375 -1.175537109375," + "5.77880859375 -1.812744140625," + "7.27294921875 -2.230224609375," + "7.657470703125 -1.25244140625))"), + } -def run(verbosity=2): - unittest.TextTestRunner(verbosity=verbosity).run(suite()) + def assertMapWidget(self, form_instance): + """ + 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('gis/js/OLMapWidget.js', str(form_instance.media)) + + def assertTextarea(self, geom, rendered): + """Makes sure the wkt and a textarea are in the content""" + + self.assertIn('<textarea ', rendered) + self.assertIn('required', rendered) + self.assertIn(geom.wkt, rendered) + + def test_pointfield(self): + class PointForm(forms.Form): + p = forms.PointField() + + geom = self.geometries['point'] + form = PointForm(data={'p': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(PointForm().is_valid()) + invalid = PointForm(data={'p': 'some invalid geom'}) + self.assertFalse(invalid.is_valid()) + self.assertTrue('Invalid geometry value' in str(invalid.errors)) + + for invalid in [geom for key, geom in self.geometries.items() if key!='point']: + self.assertFalse(PointForm(data={'p': invalid.wkt}).is_valid()) + + def test_multipointfield(self): + class PointForm(forms.Form): + p = forms.MultiPointField() + + geom = self.geometries['multipoint'] + form = PointForm(data={'p': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(PointForm().is_valid()) + + for invalid in [geom for key, geom in self.geometries.items() if key!='multipoint']: + self.assertFalse(PointForm(data={'p': invalid.wkt}).is_valid()) + + def test_linestringfield(self): + class LineStringForm(forms.Form): + l = forms.LineStringField() + + geom = self.geometries['linestring'] + form = LineStringForm(data={'l': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(LineStringForm().is_valid()) + + for invalid in [geom for key, geom in self.geometries.items() if key!='linestring']: + self.assertFalse(LineStringForm(data={'p': invalid.wkt}).is_valid()) + + def test_multilinestringfield(self): + class LineStringForm(forms.Form): + l = forms.MultiLineStringField() + + geom = self.geometries['multilinestring'] + form = LineStringForm(data={'l': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(LineStringForm().is_valid()) + + for invalid in [geom for key, geom in self.geometries.items() if key!='multilinestring']: + self.assertFalse(LineStringForm(data={'p': invalid.wkt}).is_valid()) + + def test_polygonfield(self): + class PolygonForm(forms.Form): + p = forms.PolygonField() + + geom = self.geometries['polygon'] + form = PolygonForm(data={'p': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(PolygonForm().is_valid()) + + for invalid in [geom for key, geom in self.geometries.items() if key!='polygon']: + self.assertFalse(PolygonForm(data={'p': invalid.wkt}).is_valid()) + + def test_multipolygonfield(self): + class PolygonForm(forms.Form): + p = forms.MultiPolygonField() + + geom = self.geometries['multipolygon'] + form = PolygonForm(data={'p': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(PolygonForm().is_valid()) + + for invalid in [geom for key, geom in self.geometries.items() if key!='multipolygon']: + self.assertFalse(PolygonForm(data={'p': invalid.wkt}).is_valid()) + + def test_geometrycollectionfield(self): + class GeometryForm(forms.Form): + g = forms.GeometryCollectionField() + + geom = self.geometries['geometrycollection'] + form = GeometryForm(data={'g': geom}) + self.assertTextarea(geom, form.as_p()) + self.assertMapWidget(form) + self.assertFalse(GeometryForm().is_valid()) -if __name__=="__main__": - run() + for invalid in [geom for key, geom in self.geometries.items() if key!='geometrycollection']: + self.assertFalse(GeometryForm(data={'g': invalid.wkt}).is_valid()) diff --git a/docs/ref/contrib/gis/forms-api.txt b/docs/ref/contrib/gis/forms-api.txt new file mode 100644 index 0000000000..d0c671958f --- /dev/null +++ b/docs/ref/contrib/gis/forms-api.txt @@ -0,0 +1,165 @@ +.. _ref-gis-forms-api: + +=================== +GeoDjango Forms API +=================== + +.. module:: django.contrib.gis.forms + :synopsis: GeoDjango forms API. + +.. versionadded:: 1.6 + +GeoDjango provides some specialized form fields and widgets in order to visually +display and edit geolocalized data on a map. By default, they use +`OpenLayers`_-powered maps, with a base WMS layer provided by `Metacarta`_. + +.. _OpenLayers: http://openlayers.org/ +.. _Metacarta: http://metacarta.com/ + +Field arguments +=============== +In addition to the regular :ref:`form field arguments <core-field-arguments>`, +GeoDjango form fields take the following optional arguments. + +``srid`` +~~~~~~~~ + +.. attribute:: Field.srid + + This is the SRID code that the field value should be transformed to. For + example, if the map widget SRID is different from the SRID more generally + used by your application or database, the field will automatically convert + input values into that SRID. + +``geom_type`` +~~~~~~~~~~~~~ + +.. attribute:: Field.geom_type + + You generally shouldn't have to set or change that attribute which should + be setup depending on the field class. It matches the OpenGIS standard + geometry name. + +Form field classes +================== + +``GeometryField`` +~~~~~~~~~~~~~~~~~ + +.. class:: GeometryField + +``PointField`` +~~~~~~~~~~~~~~ + +.. class:: PointField + +``LineStringField`` +~~~~~~~~~~~~~~~~~~~ + +.. class:: LineStringField + +``PolygonField`` +~~~~~~~~~~~~~~~~ + +.. class:: PolygonField + +``MultiPointField`` +~~~~~~~~~~~~~~~~~~~ + +.. class:: MultiPointField + +``MultiLineStringField`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: MultiLineStringField + +``MultiPolygonField`` +~~~~~~~~~~~~~~~~~~~~~ + +.. class:: MultiPolygonField + +``GeometryCollectionField`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. class:: GeometryCollectionField + +Form widgets +============ + +.. module:: django.contrib.gis.widgets + :synopsis: GeoDjango widgets API. + +GeoDjango form widgets allow you to display and edit geographic data on a +visual map. +Note that none of the currently available widgets supports 3D geometries, hence +geometry fields will fallback using a simple ``Textarea`` widget for such data. + +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. + +.. attribute:: BaseGeometryWidget.map_height +.. attribute:: BaseGeometryWidget.map_width + + Height and width of the widget map (default is 400x600). + +.. attribute:: BaseGeometryWidget.map_srid + + SRID code used by the map (default is 4326). + +.. attribute:: BaseGeometryWidget.display_wkt + + Boolean value specifying if a textarea input showing the WKT representation + of the current geometry is visible, mainly for debugging purposes (default + is ``False``). + +.. attribute:: BaseGeometryWidget.supports_3d + + Indicates if the widget supports edition of 3D data (default is ``False``). + +.. attribute:: BaseGeometryWidget.template_name + + The template used to render the map widget. + +You can pass widget attributes in the same manner that for any other Django +widget. For example:: + + from django.contrib.gis import forms + + class MyGeoForm(forms.Form): + point = forms.PointField(widget= + forms.OSMWidget(attrs={'map_width': 800, 'map_height': 500})) + +Widget classes +~~~~~~~~~~~~~~ + +``BaseGeometryWidget`` + +.. class:: BaseGeometryWidget + + 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. + +``OpenLayersWidget`` + +.. class:: OpenLayersWidget + + This is the default widget used by all GeoDjango form fields. + ``template_name`` is ``gis/openlayers.html``. + +``OSMWidget`` + +.. class:: OSMWidget + + This widget uses an OpenStreetMap base layer (Mapnik) to display geographic + objects on. + ``template_name`` is ``gis/openlayers-osm.html``. diff --git a/docs/ref/contrib/gis/index.txt b/docs/ref/contrib/gis/index.txt index 6a1402bfab..c533aa459d 100644 --- a/docs/ref/contrib/gis/index.txt +++ b/docs/ref/contrib/gis/index.txt @@ -18,6 +18,7 @@ of spatially enabled data. install/index model-api db-api + forms-api geoquerysets measure geos diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 054f45c430..8e1a4b34d1 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -30,6 +30,8 @@ exception or returns the clean value:: ... ValidationError: [u'Enter a valid email address.'] +.. _core-field-arguments: + Core field arguments -------------------- diff --git a/docs/releases/1.6.txt b/docs/releases/1.6.txt index 7469783659..98889254cd 100644 --- a/docs/releases/1.6.txt +++ b/docs/releases/1.6.txt @@ -114,6 +114,13 @@ Django 1.6 adds support for savepoints in SQLite, with some :ref:`limitations A new :class:`django.db.models.BinaryField` model field allows to store raw binary data in the database. +GeoDjango form widgets +~~~~~~~~~~~~~~~~~~~~~~ + +GeoDjango now provides :ref:`form fields and widgets <ref-gis-forms-api>` for +its geo-specialized fields. They are OpenLayers-based by default, but they can +be customized to use any other JS framework. + Minor features ~~~~~~~~~~~~~~ |
