django-gisserver 1.5.0__py3-none-any.whl → 2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/METADATA +14 -4
  2. django_gisserver-2.0.dist-info/RECORD +66 -0
  3. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/compat.py +23 -0
  6. gisserver/conf.py +7 -0
  7. gisserver/db.py +56 -47
  8. gisserver/exceptions.py +26 -2
  9. gisserver/extensions/__init__.py +4 -0
  10. gisserver/{parsers/fes20 → extensions}/functions.py +10 -4
  11. gisserver/extensions/queries.py +261 -0
  12. gisserver/features.py +220 -156
  13. gisserver/geometries.py +32 -37
  14. gisserver/management/__init__.py +0 -0
  15. gisserver/management/commands/__init__.py +0 -0
  16. gisserver/management/commands/loadgeojson.py +291 -0
  17. gisserver/operations/base.py +122 -308
  18. gisserver/operations/wfs20.py +423 -337
  19. gisserver/output/__init__.py +9 -48
  20. gisserver/output/base.py +178 -139
  21. gisserver/output/csv.py +65 -74
  22. gisserver/output/geojson.py +34 -35
  23. gisserver/output/gml32.py +254 -246
  24. gisserver/output/iters.py +207 -0
  25. gisserver/output/results.py +52 -26
  26. gisserver/output/stored.py +143 -0
  27. gisserver/output/utils.py +75 -170
  28. gisserver/output/xmlschema.py +85 -46
  29. gisserver/parsers/__init__.py +10 -10
  30. gisserver/parsers/ast.py +320 -0
  31. gisserver/parsers/fes20/__init__.py +13 -27
  32. gisserver/parsers/fes20/expressions.py +82 -38
  33. gisserver/parsers/fes20/filters.py +111 -43
  34. gisserver/parsers/fes20/identifiers.py +44 -26
  35. gisserver/parsers/fes20/lookups.py +144 -0
  36. gisserver/parsers/fes20/operators.py +331 -127
  37. gisserver/parsers/fes20/sorting.py +104 -33
  38. gisserver/parsers/gml/__init__.py +12 -11
  39. gisserver/parsers/gml/base.py +5 -2
  40. gisserver/parsers/gml/geometries.py +69 -35
  41. gisserver/parsers/ows/__init__.py +25 -0
  42. gisserver/parsers/ows/kvp.py +190 -0
  43. gisserver/parsers/ows/requests.py +158 -0
  44. gisserver/parsers/query.py +175 -0
  45. gisserver/parsers/values.py +26 -0
  46. gisserver/parsers/wfs20/__init__.py +37 -0
  47. gisserver/parsers/wfs20/adhoc.py +245 -0
  48. gisserver/parsers/wfs20/base.py +143 -0
  49. gisserver/parsers/wfs20/projection.py +103 -0
  50. gisserver/parsers/wfs20/requests.py +482 -0
  51. gisserver/parsers/wfs20/stored.py +192 -0
  52. gisserver/parsers/xml.py +249 -0
  53. gisserver/projection.py +357 -0
  54. gisserver/static/gisserver/index.css +12 -1
  55. gisserver/templates/gisserver/index.html +1 -1
  56. gisserver/templates/gisserver/service_description.html +2 -2
  57. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
  58. gisserver/templates/gisserver/wfs/feature_field.html +2 -2
  59. gisserver/templatetags/gisserver_tags.py +20 -0
  60. gisserver/types.py +322 -259
  61. gisserver/views.py +198 -56
  62. django_gisserver-1.5.0.dist-info/RECORD +0 -54
  63. gisserver/parsers/base.py +0 -149
  64. gisserver/parsers/fes20/query.py +0 -285
  65. gisserver/parsers/tags.py +0 -102
  66. gisserver/queries/__init__.py +0 -37
  67. gisserver/queries/adhoc.py +0 -185
  68. gisserver/queries/base.py +0 -186
  69. gisserver/queries/projection.py +0 -240
  70. gisserver/queries/stored.py +0 -206
  71. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  72. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  73. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/LICENSE +0 -0
  74. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/top_level.txt +0 -0
gisserver/geometries.py CHANGED
@@ -6,15 +6,16 @@ The bounding box can be calculated within Python, or read from a database result
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import logging
9
10
  import re
10
11
  from dataclasses import dataclass, field
11
12
  from decimal import Decimal
12
- from functools import lru_cache
13
+ from functools import cached_property, lru_cache
13
14
 
14
- from django.contrib.gis.gdal import CoordTransform, SpatialReference
15
- from django.contrib.gis.geos import GEOSGeometry, Polygon
15
+ from django.contrib.gis.gdal import AxisOrder, CoordTransform, SpatialReference
16
+ from django.contrib.gis.geos import GEOSGeometry
16
17
 
17
- from gisserver.exceptions import ExternalParsingError, ExternalValueError
18
+ from gisserver.exceptions import ExternalValueError
18
19
 
19
20
  CRS_URN_REGEX = re.compile(
20
21
  r"^urn:(?P<domain>[a-z]+)"
@@ -27,15 +28,27 @@ CRS_URN_REGEX = re.compile(
27
28
 
28
29
  __all__ = [
29
30
  "CRS",
31
+ "WEB_MERCATOR",
30
32
  "WGS84",
31
33
  "BoundingBox",
32
34
  ]
33
35
 
36
+ logger = logging.getLogger(__name__)
34
37
 
35
- @lru_cache(maxsize=200)
36
- def _get_spatial_reference(srs_input, srs_type="user", axis_order=None):
38
+
39
+ @lru_cache(maxsize=200) # Using lru-cache to avoid repeated GDAL c-object construction
40
+ def _get_spatial_reference(srs_input: str | int, srs_type="user", axis_order=None):
37
41
  """Construct an GDAL object reference"""
38
- # Using lru-cache to avoid repeated GDAL c-object construction
42
+ if axis_order is None:
43
+ # WFS 1.0 used x/y coordinates, WFS 2.0 uses the ordering from the CRS authority.
44
+ axis_order = AxisOrder.AUTHORITY
45
+
46
+ logger.debug(
47
+ "Constructed GDAL SpatialReference(%r, srs_type=%r, axis_order=%s)",
48
+ srs_input,
49
+ srs_type,
50
+ axis_order,
51
+ )
39
52
  return SpatialReference(srs_input, srs_type=srs_type, axis_order=axis_order)
40
53
 
41
54
 
@@ -162,6 +175,7 @@ class CRS:
162
175
  f"CRS URI [{urn}] should contain a numeric SRID value."
163
176
  ) from None
164
177
  elif authority == "OGC":
178
+ # urn:ogc:def:crs:OGC::CRS84 has x/y ordering (longitude/latitude)
165
179
  crsid = urn_match.group("id").upper()
166
180
  if crsid != "CRS84":
167
181
  raise ExternalValueError(f"OGC CRS URI from [{urn}] contains unknown id [{id}]")
@@ -216,7 +230,7 @@ class CRS:
216
230
  """Return a legacy string in the format "EPSG:<srid>"""
217
231
  return f"EPSG:{self.srid:d}"
218
232
 
219
- @property
233
+ @cached_property
220
234
  def urn(self):
221
235
  """Return The OGC URN corresponding to this CRS."""
222
236
  return f"urn:{self.domain}:def:crs:{self.authority}:{self.version or ''}:{self.crsid}"
@@ -227,7 +241,7 @@ class CRS:
227
241
  def __eq__(self, other):
228
242
  if isinstance(other, CRS):
229
243
  # CRS84 is NOT equivalent to EPSG:4326.
230
- # EPSG:4326 specifies coordinates in lat/long order and CRS:84 in long/lat order.
244
+ # EPSG:4326 specifies coordinates in lat/long order and CRS84 in long/lat order.
231
245
  return self.authority == other.authority and self.srid == other.srid
232
246
  else:
233
247
  return NotImplemented
@@ -267,12 +281,19 @@ class CRS:
267
281
  return geometry.transform(transform, clone=clone)
268
282
 
269
283
 
270
- WGS84 = CRS.from_srid(4326) # aka EPSG:4326
284
+ # Worldwide GPS, latitude/longitude (y/x). https://epsg.io/4326
285
+ WGS84 = CRS.from_string("urn:ogc:def:crs:EPSG::4326")
286
+
287
+ # GeoJSON default. This is like WGS84 but with longitude/latitude (x/y).
288
+ CRS84 = CRS.from_string("urn:ogc:def:crs:OGC::CRS84")
289
+
290
+ #: Spherical Mercator (Google Maps, Bing Maps, OpenStreetMap, ...), see https://epsg.io/3857
291
+ WEB_MERCATOR = CRS.from_string("urn:ogc:def:crs:EPSG::3857")
271
292
 
272
293
 
273
294
  @dataclass
274
295
  class BoundingBox:
275
- """A bounding box that describes the extent of a map layer"""
296
+ """A bounding box (or "envelope") that describes the extent of a map layer"""
276
297
 
277
298
  south: Decimal # longitude
278
299
  west: Decimal # latitude
@@ -280,25 +301,6 @@ class BoundingBox:
280
301
  east: Decimal # latitude
281
302
  crs: CRS | None = None
282
303
 
283
- @classmethod
284
- def from_string(cls, bbox):
285
- """Parse the bounding box from an input string.
286
-
287
- It can either be 4 coordinates, or 4 coordinates with a special reference system.
288
- """
289
- bbox = bbox.split(",")
290
- if not (4 <= len(bbox) <= 5):
291
- raise ExternalParsingError(
292
- f"Input does not contain bounding box, expected 4 or 5 values, not {bbox}."
293
- )
294
- return cls(
295
- Decimal(bbox[0]),
296
- Decimal(bbox[1]),
297
- Decimal(bbox[2]),
298
- Decimal(bbox[3]),
299
- CRS.from_string(bbox[4]) if len(bbox) == 5 else None,
300
- )
301
-
302
304
  @classmethod
303
305
  def from_geometry(cls, geometry: GEOSGeometry, crs: CRS | None = None):
304
306
  """Construct the bounding box for a geometry"""
@@ -349,10 +351,3 @@ class BoundingBox:
349
351
  )
350
352
  else:
351
353
  return NotImplemented
352
-
353
- def as_polygon(self) -> Polygon:
354
- """Convert the value into a GEOS polygon."""
355
- polygon = Polygon.from_bbox((self.south, self.west, self.north, self.east))
356
- if self.crs is not None:
357
- polygon.srid = self.crs.srid
358
- return polygon
File without changes
File without changes
@@ -0,0 +1,291 @@
1
+ """Quick utility to import GeoJSON data in the Django project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import operator
7
+ from functools import reduce
8
+ from itertools import islice
9
+ from urllib.request import urlopen
10
+
11
+ from django.apps import apps
12
+ from django.contrib.gis.db.models import GeometryField
13
+ from django.contrib.gis.geos import GEOSGeometry
14
+ from django.core.exceptions import FieldDoesNotExist
15
+ from django.core.management import BaseCommand, CommandError, CommandParser
16
+ from django.db import DEFAULT_DB_ALIAS, connections, models, transaction
17
+
18
+ from gisserver.geometries import CRS, WGS84
19
+
20
+
21
+ def _parse_model(value):
22
+ try:
23
+ return apps.get_model(value)
24
+ except LookupError as e:
25
+ raise ValueError(str(e)) from e
26
+
27
+
28
+ def _parse_fields(value):
29
+ field_map = {}
30
+ for pair in value.split(","):
31
+ geojson_name, _, field_name = pair.strip().partition("=")
32
+ if not value:
33
+ raise ValueError("Expect property=field,property2=field2 format")
34
+ field_map[geojson_name] = field_name
35
+ return field_map
36
+
37
+
38
+ class Command(BaseCommand):
39
+ """Quick command to import data in the WFS server."""
40
+
41
+ help = (
42
+ "Import GeoJSON data into a WFS feature collection. This can be done using:"
43
+ " manage.py loadgeojson --model=places.Province"
44
+ " -f name=name,prop2=field2 --geometry-field geometry provinces.json"
45
+ )
46
+
47
+ def add_arguments(self, parser: CommandParser):
48
+ parser.add_argument(
49
+ "--database",
50
+ default=DEFAULT_DB_ALIAS,
51
+ choices=tuple(connections),
52
+ help=(
53
+ "Nominates a specific database to load fixtures into. Defaults to the "
54
+ '"default" database.'
55
+ ),
56
+ )
57
+ parser.add_argument(
58
+ "-m",
59
+ "--model",
60
+ required=True,
61
+ metavar="MODEL",
62
+ type=_parse_model,
63
+ help="Django model, in the format: app_label.ModelName.",
64
+ )
65
+ parser.add_argument(
66
+ "-g",
67
+ "--geometry-field",
68
+ required=False,
69
+ metavar="NAME",
70
+ help="Name of the model's geometry field, auto-detected if not specified.",
71
+ )
72
+ parser.add_argument(
73
+ "-f",
74
+ "--field",
75
+ nargs="*",
76
+ dest="map_fields",
77
+ type=_parse_fields,
78
+ metavar="NAME=FIELD",
79
+ help=(
80
+ "Map GeoJSON properties to Django fields, in the format: property1=field1,property2=field2,... "
81
+ "By default, this autodetects common field names only. "
82
+ "Explicitly ignore fields by using -f property=,property2=field2."
83
+ ),
84
+ )
85
+ parser.add_argument(
86
+ "geojson-file",
87
+ help="GeoJSON file or URL to import. For debugging, it's better to download the file first.",
88
+ )
89
+
90
+ def handle(self, *args, **options):
91
+ # Get arguments
92
+ self.using = options["database"]
93
+ self.connection = connections[self.using]
94
+ model: type[models.Model] = options["model"]
95
+ main_geometry_field = self._get_geometry_field(model, options["geometry_field"])
96
+ field_map = (
97
+ self._parse_field_map(model, options["map_fields"]) if options["map_fields"] else {}
98
+ )
99
+
100
+ # Read the file
101
+ geojson = self._load_geojson(options["geojson-file"])
102
+ if not geojson["features"]:
103
+ self.stdout.write(self.style.NOTICE("Empty GeoJSON data"))
104
+ return
105
+
106
+ # See if properties match the Django field names, use those too.
107
+ # (unless these are mapped already via the command line args).
108
+ field_map.update(self._get_auto_field_map(model, geojson["features"][0], field_map))
109
+
110
+ # See if a CRS is declared
111
+ self.crs = self._read_crs(geojson)
112
+
113
+ # Import in chunks
114
+ num_imported = 0
115
+ id_field = model._meta.pk.name
116
+ with transaction.atomic(using=self.using):
117
+ features = iter(self._read_geojson(geojson, model, main_geometry_field, field_map))
118
+ while batch := list(islice(features, 100)):
119
+ if id_field in batch[0]:
120
+ # The ID field is provided, allow "on conflict update..."
121
+ unique_fields = [id_field]
122
+ update_fields = list(batch[0].keys())
123
+ update_fields.remove(id_field)
124
+
125
+ model.objects.using(self.using).bulk_create(
126
+ [model(**values) for values in batch],
127
+ update_conflicts=True,
128
+ unique_fields=unique_fields,
129
+ update_fields=update_fields,
130
+ )
131
+ else:
132
+ model.objects.using(self.using).bulk_create(
133
+ [model(**values) for values in batch]
134
+ )
135
+
136
+ num_imported += len(batch)
137
+
138
+ self.stdout.write(f"Installed {num_imported} feature(s)")
139
+
140
+ def _get_geometry_field(self, model: type[models.Model], field_name: str | None):
141
+ """Find the geometry field name, or validate it."""
142
+ if field_name:
143
+ # Field is given in CLI args, validate it.
144
+ try:
145
+ field = model._meta.get_field(field_name)
146
+ except FieldDoesNotExist as e:
147
+ raise CommandError(f"Invalid value for --geometry-field: {e}") from e
148
+
149
+ if not isinstance(field, GeometryField):
150
+ raise CommandError("Invalid value for --geometry-field: not a GeometryField")
151
+ else:
152
+ # Field is not given, auto-detect
153
+ try:
154
+ field_name = next(
155
+ f.name for f in model._meta.fields if isinstance(f, GeometryField)
156
+ )
157
+ except StopIteration:
158
+ raise CommandError(
159
+ f"Model {model._meta.label} does not have any GeometryField member."
160
+ ) from None
161
+
162
+ return field_name
163
+
164
+ def _load_geojson(self, filename) -> dict:
165
+ """Parse and validate the GeoJSON data into Python dict data."""
166
+ try:
167
+ if "://" in filename:
168
+ with urlopen(filename, timeout=60) as response: # noqa: S310
169
+ geojson = json.load(response)
170
+ else:
171
+ with open(filename) as fh:
172
+ geojson = json.load(fh)
173
+ except OSError as e: # FileNotFoundError or HTTP errors
174
+ raise CommandError(str(e)) from e
175
+ except (ValueError, TypeError) as e:
176
+ raise CommandError(f"Unable to parse GeoJSON: {e}") from e
177
+
178
+ if (
179
+ not isinstance(geojson, dict)
180
+ or geojson.get("type") != "FeatureCollection"
181
+ or "features" not in geojson
182
+ ):
183
+ raise CommandError("Invalid GeoJSON data, expected FeatureCollection element.")
184
+
185
+ return geojson
186
+
187
+ def _read_crs(self, geojson: dict) -> CRS:
188
+ """Find the CRS that should be used for all geometry data."""
189
+ crs = geojson.get("crs")
190
+ if not crs:
191
+ return WGS84
192
+
193
+ try:
194
+ type = crs["type"]
195
+ if type == "name":
196
+ return CRS.from_string(crs["properties"]["name"])
197
+ else:
198
+ # type 'link' with subtype 'proj4/ogcwkt/esriwkt' are not handled here.
199
+ raise CommandError(f"CRS type {type} is not supported.")
200
+ except KeyError as e:
201
+ raise CommandError(f"CRS is invalid, missing '{e}' field") from e
202
+
203
+ def _parse_field_map(self, model, map_fields: list[dict]) -> dict:
204
+ """Validate that the provided field mapping points to model fields."""
205
+ field_map: dict = reduce(operator.or_, map_fields)
206
+ for field_name in field_map.values():
207
+ try:
208
+ model._meta.get_field(field_name)
209
+ except FieldDoesNotExist as e:
210
+ raise CommandError(f"Field '{field_name}' does not exist in Django model.") from e
211
+
212
+ return field_map
213
+
214
+ def _get_auto_field_map(self, model, feature: dict, field_map: dict):
215
+ """Autodetect which properties match model field names."""
216
+ auto_field_map = {}
217
+ for geojson_name in feature.get("properties", ()):
218
+ if geojson_name in field_map:
219
+ continue
220
+
221
+ try:
222
+ model._meta.get_field(geojson_name)
223
+ except FieldDoesNotExist:
224
+ msg = (
225
+ f"GeoJSON property '{geojson_name}' is not mapped nor exists in Django model, skipping."
226
+ if field_map
227
+ else f"GeoJSON property '{geojson_name}' does not exist in Django model, skipping."
228
+ )
229
+ self.stderr.write(self.style.WARNING(msg))
230
+ else:
231
+ self.stdout.write(
232
+ f"GeoJSON property '{geojson_name}' also exists as Django model field, using."
233
+ )
234
+ auto_field_map[geojson_name] = geojson_name
235
+
236
+ return auto_field_map
237
+
238
+ def _read_geojson(
239
+ self, geojson: dict, model: type[models.Model], main_geometry_field: str, field_map: dict
240
+ ):
241
+ """Convert the GeoJSON data to model field names."""
242
+ pk_field = model._meta.pk.name
243
+
244
+ for feature in geojson["features"]:
245
+ # Validate basic layout
246
+ try:
247
+ feature_type = feature["type"]
248
+ geometry_data = feature["geometry"]
249
+ except KeyError as e:
250
+ raise CommandError(f"Feature does not have required '{e}' element.") from e
251
+ if feature_type != "Feature":
252
+ raise CommandError(f"Expected 'Feature', not {feature_type}")
253
+
254
+ # Collect all fields
255
+ properties = feature.get("properties", {})
256
+ field_values = {
257
+ field_name: self._parse_value(
258
+ model._meta.get_field(field_name), properties.get(geojson_name)
259
+ )
260
+ for geojson_name, field_name in field_map.items()
261
+ if field_name
262
+ }
263
+
264
+ # Store the geometry, note GEOS only parses stringified JSON.
265
+ geometry = GEOSGeometry(json.dumps(geometry_data), srid=self.crs.srid)
266
+ geometry.srid = self.crs.srid # override default
267
+ field_values[main_geometry_field] = geometry
268
+
269
+ # Try to decode the identifier if it's present
270
+ if pk_field not in field_values and (id_value := feature.get("id")):
271
+ try:
272
+ field_values[pk_field] = self._parse_id(model._meta.pk, id_value)
273
+ except (ValueError, TypeError):
274
+ self.stderr.write(
275
+ self.style.WARNING(
276
+ f"Feature id value '{id_value}' can't be stored in the primary key field. ignoring."
277
+ )
278
+ )
279
+
280
+ # Pass as Django model fields
281
+ yield field_values
282
+
283
+ def _parse_id(self, pk_field: models.Field, id_value):
284
+ # Allow TypeName.id format, see if it parses
285
+ return pk_field.get_db_prep_save(id_value.rpartition(".")[2], connection=self.connection)
286
+
287
+ def _parse_value(self, field: models.Field, value):
288
+ try:
289
+ return field.get_db_prep_save(value, connection=self.connection)
290
+ except (ValueError, TypeError) as e:
291
+ raise CommandError(f"Can't parse {value!r} in model field '{field.name}': {e}.") from e