Browse Source

Fixed #5472 --Added OpenLayers-based widgets in contrib.gis

Largely inspired from django-floppyforms. Designed to not depend
on OpenLayers at code level.
Claude Paroz 12 years ago
parent
commit
b16b72d415

+ 12 - 1
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")

+ 4 - 1
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

+ 33 - 2
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'

+ 112 - 0
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})

+ 371 - 0
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;
+})();

+ 17 - 0
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 %}

+ 34 - 0
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>

+ 173 - 16
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())

+ 165 - 0
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``.

+ 1 - 0
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

+ 2 - 0
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
 --------------------
 

+ 7 - 0
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
 ~~~~~~~~~~~~~~