django-gisserver 2.0__py3-none-any.whl → 2.1.1__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.
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/METADATA +27 -10
- django_gisserver-2.1.1.dist-info/RECORD +68 -0
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/conf.py +23 -1
- gisserver/crs.py +452 -0
- gisserver/db.py +78 -6
- gisserver/exceptions.py +106 -2
- gisserver/extensions/functions.py +122 -28
- gisserver/extensions/queries.py +15 -10
- gisserver/features.py +46 -33
- gisserver/geometries.py +64 -306
- gisserver/management/commands/loadgeojson.py +41 -21
- gisserver/operations/base.py +11 -7
- gisserver/operations/wfs20.py +31 -93
- gisserver/output/__init__.py +6 -2
- gisserver/output/base.py +28 -13
- gisserver/output/csv.py +18 -6
- gisserver/output/geojson.py +7 -6
- gisserver/output/gml32.py +86 -27
- gisserver/output/results.py +25 -39
- gisserver/output/utils.py +9 -2
- gisserver/parsers/ast.py +177 -68
- gisserver/parsers/fes20/__init__.py +76 -4
- gisserver/parsers/fes20/expressions.py +97 -27
- gisserver/parsers/fes20/filters.py +9 -6
- gisserver/parsers/fes20/identifiers.py +27 -7
- gisserver/parsers/fes20/lookups.py +8 -6
- gisserver/parsers/fes20/operators.py +101 -49
- gisserver/parsers/fes20/sorting.py +14 -6
- gisserver/parsers/gml/__init__.py +10 -19
- gisserver/parsers/gml/base.py +32 -14
- gisserver/parsers/gml/geometries.py +54 -21
- gisserver/parsers/ows/kvp.py +10 -2
- gisserver/parsers/ows/requests.py +6 -4
- gisserver/parsers/query.py +6 -2
- gisserver/parsers/values.py +61 -4
- gisserver/parsers/wfs20/__init__.py +2 -0
- gisserver/parsers/wfs20/adhoc.py +28 -18
- gisserver/parsers/wfs20/base.py +12 -7
- gisserver/parsers/wfs20/projection.py +3 -3
- gisserver/parsers/wfs20/requests.py +1 -0
- gisserver/parsers/wfs20/stored.py +3 -2
- gisserver/parsers/xml.py +12 -0
- gisserver/projection.py +17 -7
- gisserver/static/gisserver/index.css +27 -6
- gisserver/templates/gisserver/base.html +15 -0
- gisserver/templates/gisserver/index.html +10 -16
- gisserver/templates/gisserver/service_description.html +12 -6
- gisserver/templates/gisserver/wfs/feature_field.html +1 -1
- gisserver/templates/gisserver/wfs/feature_type.html +44 -13
- gisserver/types.py +152 -82
- gisserver/views.py +47 -24
- django_gisserver-2.0.dist-info/RECORD +0 -66
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info/licenses}/LICENSE +0 -0
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/top_level.txt +0 -0
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import operator
|
|
7
|
+
from argparse import ArgumentTypeError
|
|
7
8
|
from functools import reduce
|
|
8
9
|
from itertools import islice
|
|
9
10
|
from urllib.request import urlopen
|
|
@@ -15,22 +16,22 @@ from django.core.exceptions import FieldDoesNotExist
|
|
|
15
16
|
from django.core.management import BaseCommand, CommandError, CommandParser
|
|
16
17
|
from django.db import DEFAULT_DB_ALIAS, connections, models, transaction
|
|
17
18
|
|
|
18
|
-
from gisserver.
|
|
19
|
+
from gisserver.crs import CRS, CRS84
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def _parse_model(value):
|
|
22
23
|
try:
|
|
23
24
|
return apps.get_model(value)
|
|
24
25
|
except LookupError as e:
|
|
25
|
-
raise
|
|
26
|
+
raise ArgumentTypeError(str(e)) from e
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
def _parse_fields(value):
|
|
29
30
|
field_map = {}
|
|
30
31
|
for pair in value.split(","):
|
|
31
32
|
geojson_name, _, field_name = pair.strip().partition("=")
|
|
32
|
-
if not
|
|
33
|
-
raise
|
|
33
|
+
if not field_name:
|
|
34
|
+
raise ArgumentTypeError("Expect property=field,property2=field2 format")
|
|
34
35
|
field_map[geojson_name] = field_name
|
|
35
36
|
return field_map
|
|
36
37
|
|
|
@@ -72,7 +73,7 @@ class Command(BaseCommand):
|
|
|
72
73
|
parser.add_argument(
|
|
73
74
|
"-f",
|
|
74
75
|
"--field",
|
|
75
|
-
|
|
76
|
+
action="append",
|
|
76
77
|
dest="map_fields",
|
|
77
78
|
type=_parse_fields,
|
|
78
79
|
metavar="NAME=FIELD",
|
|
@@ -93,9 +94,7 @@ class Command(BaseCommand):
|
|
|
93
94
|
self.connection = connections[self.using]
|
|
94
95
|
model: type[models.Model] = options["model"]
|
|
95
96
|
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
|
-
)
|
|
97
|
+
field_map = self._parse_field_map(model, options["map_fields"])
|
|
99
98
|
|
|
100
99
|
# Read the file
|
|
101
100
|
geojson = self._load_geojson(options["geojson-file"])
|
|
@@ -188,7 +187,7 @@ class Command(BaseCommand):
|
|
|
188
187
|
"""Find the CRS that should be used for all geometry data."""
|
|
189
188
|
crs = geojson.get("crs")
|
|
190
189
|
if not crs:
|
|
191
|
-
return
|
|
190
|
+
return CRS84 # default for GeoJSON
|
|
192
191
|
|
|
193
192
|
try:
|
|
194
193
|
type = crs["type"]
|
|
@@ -202,6 +201,9 @@ class Command(BaseCommand):
|
|
|
202
201
|
|
|
203
202
|
def _parse_field_map(self, model, map_fields: list[dict]) -> dict:
|
|
204
203
|
"""Validate that the provided field mapping points to model fields."""
|
|
204
|
+
if not map_fields:
|
|
205
|
+
return {}
|
|
206
|
+
|
|
205
207
|
field_map: dict = reduce(operator.or_, map_fields)
|
|
206
208
|
for field_name in field_map.values():
|
|
207
209
|
try:
|
|
@@ -262,27 +264,45 @@ class Command(BaseCommand):
|
|
|
262
264
|
}
|
|
263
265
|
|
|
264
266
|
# Store the geometry, note GEOS only parses stringified JSON.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
if geometry_data is None:
|
|
268
|
+
geometry = None
|
|
269
|
+
else:
|
|
270
|
+
try:
|
|
271
|
+
# NOTE: this looses the axis ordering information,
|
|
272
|
+
# and assumes x/y as the standard prescribes.
|
|
273
|
+
geometry = GEOSGeometry(json.dumps(geometry_data), srid=self.crs.srid)
|
|
274
|
+
except ValueError as e:
|
|
275
|
+
raise CommandError(
|
|
276
|
+
f"Unable to parse geometry data: {geometry_data!r}: {e}"
|
|
277
|
+
) from e
|
|
278
|
+
geometry.srid = self.crs.srid # override default
|
|
279
|
+
|
|
267
280
|
field_values[main_geometry_field] = geometry
|
|
268
281
|
|
|
269
282
|
# Try to decode the identifier if it's present
|
|
270
|
-
if
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
f"Feature id value '{id_value}' can't be stored in the primary key field. ignoring."
|
|
277
|
-
)
|
|
278
|
-
)
|
|
283
|
+
if (
|
|
284
|
+
pk_field not in field_values
|
|
285
|
+
and (raw_value := feature.get("id")) is not None
|
|
286
|
+
and (id_value := self._parse_id(model._meta.pk, raw_value)) is not None
|
|
287
|
+
):
|
|
288
|
+
field_values[pk_field] = id_value
|
|
279
289
|
|
|
280
290
|
# Pass as Django model fields
|
|
281
291
|
yield field_values
|
|
282
292
|
|
|
283
293
|
def _parse_id(self, pk_field: models.Field, id_value):
|
|
284
294
|
# Allow TypeName.id format, see if it parses
|
|
285
|
-
|
|
295
|
+
try:
|
|
296
|
+
return pk_field.get_db_prep_save(
|
|
297
|
+
id_value.rpartition(".")[2], connection=self.connection
|
|
298
|
+
)
|
|
299
|
+
except (ValueError, TypeError):
|
|
300
|
+
self.stderr.write(
|
|
301
|
+
self.style.WARNING(
|
|
302
|
+
f"Feature id value '{id_value}' can't be stored in the primary key field. ignoring."
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
return None
|
|
286
306
|
|
|
287
307
|
def _parse_value(self, field: models.Field, value):
|
|
288
308
|
try:
|
gisserver/operations/base.py
CHANGED
|
@@ -50,7 +50,9 @@ __all__ = (
|
|
|
50
50
|
class Parameter:
|
|
51
51
|
"""The ``<ows:Parameter>`` tag to output in ``GetCapabilities``."""
|
|
52
52
|
|
|
53
|
+
#: The name of the parameter
|
|
53
54
|
name: str
|
|
55
|
+
#: The values to report in ``<ows:AllowedValues><ows:Value>``.
|
|
54
56
|
allowed_values: list[str]
|
|
55
57
|
|
|
56
58
|
|
|
@@ -58,10 +60,13 @@ class OutputFormat(typing.Generic[R]):
|
|
|
58
60
|
"""Declare an output format for the method.
|
|
59
61
|
|
|
60
62
|
These formats are used in the :meth:`get_output_formats` for
|
|
61
|
-
any
|
|
63
|
+
any :class:`WFSOperation` that implements :class:`OutputFormatMixin`.
|
|
62
64
|
This also connects the output format with a :attr:`renderer_class`.
|
|
63
65
|
"""
|
|
64
66
|
|
|
67
|
+
#: The class that performs the output rendering.
|
|
68
|
+
renderer_class: type[R] | None = None
|
|
69
|
+
|
|
65
70
|
def __init__(
|
|
66
71
|
self,
|
|
67
72
|
content_type,
|
|
@@ -161,7 +166,7 @@ class WFSOperation:
|
|
|
161
166
|
@cached_property
|
|
162
167
|
def all_feature_types_by_name(self) -> dict[str, FeatureType]:
|
|
163
168
|
"""Create a lookup for feature types by name.
|
|
164
|
-
This can be cached as the
|
|
169
|
+
This can be cached as the :class:`WFSOperation` is instantiated with each request.
|
|
165
170
|
"""
|
|
166
171
|
return {ft.xml_name: ft for ft in self.view.get_bound_feature_types()}
|
|
167
172
|
|
|
@@ -194,12 +199,14 @@ class XmlTemplateMixin:
|
|
|
194
199
|
"""Mixin to support methods that render using a template."""
|
|
195
200
|
|
|
196
201
|
#: Default template to use for rendering
|
|
202
|
+
#: This is resolved as :samp:`gisserver/{service}/{version}/{xml_template_name}`.
|
|
197
203
|
xml_template_name = None
|
|
198
204
|
|
|
199
|
-
#:
|
|
205
|
+
#: The content-type to render.
|
|
200
206
|
xml_content_type = "text/xml; charset=utf-8"
|
|
201
207
|
|
|
202
208
|
def process_request(self, ows_request: ows.BaseOwsRequest):
|
|
209
|
+
"""Process the request by rendering a Django template."""
|
|
203
210
|
context = self.get_context_data()
|
|
204
211
|
return self.render_xml(context, ows_request)
|
|
205
212
|
|
|
@@ -208,10 +215,7 @@ class XmlTemplateMixin:
|
|
|
208
215
|
return {}
|
|
209
216
|
|
|
210
217
|
def render_xml(self, context, ows_request: ows.BaseOwsRequest):
|
|
211
|
-
"""
|
|
212
|
-
|
|
213
|
-
This is the default method when the OutputFormat class doesn't have a renderer_class
|
|
214
|
-
"""
|
|
218
|
+
"""Render the response using a template."""
|
|
215
219
|
return HttpResponse(
|
|
216
220
|
render_to_string(
|
|
217
221
|
self._get_xml_template_name(ows_request),
|
gisserver/operations/wfs20.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
"""Operations that implement the WFS 2.0
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
"""Operations that implement the WFS 2.0 specification.
|
|
2
|
+
|
|
3
|
+
These operations are called by the :class:`~gisserver.views.WFSView`,
|
|
4
|
+
and receive the request that was parsed using :mod:`gisserver.parsers.wfs20`.
|
|
5
|
+
Thus, there is nearly no difference between GET/POST requests here.
|
|
6
|
+
|
|
7
|
+
In a way this looks like an MVC (Model-View-Controller) design:
|
|
8
|
+
|
|
9
|
+
* :mod:`gisserver.parsers.wfs20` parsed the request, and provides the "model".
|
|
10
|
+
* :mod:`gisserver.operations.wfs20` orchestrates here what to do (the controller).
|
|
11
|
+
* :mod:`gisserver.output` performs the rendering (the view).
|
|
9
12
|
"""
|
|
10
13
|
|
|
11
14
|
from __future__ import annotations
|
|
@@ -16,18 +19,14 @@ import re
|
|
|
16
19
|
import typing
|
|
17
20
|
from urllib.parse import urlencode
|
|
18
21
|
|
|
19
|
-
from django.core.exceptions import
|
|
20
|
-
from django.db import InternalError, ProgrammingError
|
|
21
|
-
from django.db.models import QuerySet
|
|
22
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
22
23
|
from django.utils.module_loading import import_string
|
|
23
24
|
|
|
24
25
|
from gisserver import conf, output
|
|
25
26
|
from gisserver.exceptions import (
|
|
26
|
-
ExternalParsingError,
|
|
27
|
-
ExternalValueError,
|
|
28
27
|
InvalidParameterValue,
|
|
29
|
-
OperationParsingFailed,
|
|
30
28
|
VersionNegotiationFailed,
|
|
29
|
+
wrap_filter_errors,
|
|
31
30
|
)
|
|
32
31
|
from gisserver.extensions.functions import function_registry
|
|
33
32
|
from gisserver.extensions.queries import stored_query_registry
|
|
@@ -42,6 +41,16 @@ logger = logging.getLogger(__name__)
|
|
|
42
41
|
SAFE_VERSION = re.compile(r"\A[0-9.]+\Z")
|
|
43
42
|
RE_SAFE_FILENAME = re.compile(r"\A[A-Za-z0-9]+[A-Za-z0-9.]*") # no dot at the start.
|
|
44
43
|
|
|
44
|
+
__all__ = [
|
|
45
|
+
"GetCapabilities",
|
|
46
|
+
"DescribeFeatureType",
|
|
47
|
+
"BaseWFSGetDataOperation",
|
|
48
|
+
"GetFeature",
|
|
49
|
+
"GetPropertyValue",
|
|
50
|
+
"ListStoredQueries",
|
|
51
|
+
"DescribeStoredQueries",
|
|
52
|
+
]
|
|
53
|
+
|
|
45
54
|
|
|
46
55
|
class GetCapabilities(XmlTemplateMixin, OutputFormatMixin, WFSOperation):
|
|
47
56
|
"""This operation returns map features, and available operations this WFS server supports."""
|
|
@@ -256,6 +265,8 @@ class BaseWFSGetDataOperation(OutputFormatMixin, WFSOperation):
|
|
|
256
265
|
else:
|
|
257
266
|
raise NotImplementedError()
|
|
258
267
|
|
|
268
|
+
# assert False, str(collection.results[0].queryset.query)
|
|
269
|
+
|
|
259
270
|
# Initialize the renderer.
|
|
260
271
|
# This can also decorate the querysets with projection information,
|
|
261
272
|
# such as converting geometries to the correct CRS or add prefetch_related logic.
|
|
@@ -275,7 +286,9 @@ class BaseWFSGetDataOperation(OutputFormatMixin, WFSOperation):
|
|
|
275
286
|
start, count = self.get_pagination()
|
|
276
287
|
results = []
|
|
277
288
|
for query in self.ows_request.queries:
|
|
278
|
-
|
|
289
|
+
with wrap_filter_errors(query):
|
|
290
|
+
queryset = query.get_queryset()
|
|
291
|
+
|
|
279
292
|
results.append(
|
|
280
293
|
output.SimpleFeatureCollection(
|
|
281
294
|
source_query=query,
|
|
@@ -300,7 +313,9 @@ class BaseWFSGetDataOperation(OutputFormatMixin, WFSOperation):
|
|
|
300
313
|
results = []
|
|
301
314
|
for query in self.ows_request.queries:
|
|
302
315
|
# The querysets are not executed yet, until the output is reading them.
|
|
303
|
-
|
|
316
|
+
with wrap_filter_errors(query):
|
|
317
|
+
queryset = query.get_queryset()
|
|
318
|
+
|
|
304
319
|
results.append(
|
|
305
320
|
output.SimpleFeatureCollection(
|
|
306
321
|
source_query=query,
|
|
@@ -375,83 +390,6 @@ class BaseWFSGetDataOperation(OutputFormatMixin, WFSOperation):
|
|
|
375
390
|
new_params.update(updates)
|
|
376
391
|
return f"{self.view.server_url}?{urlencode(new_params)}"
|
|
377
392
|
|
|
378
|
-
def _get_queryset(self, query: wfs20.QueryExpression) -> QuerySet: # noqa:C901
|
|
379
|
-
"""Generate the queryset, and trap many parser errors in the making of it."""
|
|
380
|
-
try:
|
|
381
|
-
return query.get_queryset()
|
|
382
|
-
except ExternalParsingError as e:
|
|
383
|
-
# Bad input data
|
|
384
|
-
self._log_filter_error(query, logging.ERROR, e)
|
|
385
|
-
raise OperationParsingFailed(str(e), locator=query.query_locator) from e
|
|
386
|
-
except ExternalValueError as e:
|
|
387
|
-
# Bad input data
|
|
388
|
-
self._log_filter_error(query, logging.ERROR, e)
|
|
389
|
-
raise InvalidParameterValue(str(e), locator=query.query_locator) from e
|
|
390
|
-
except ValidationError as e:
|
|
391
|
-
# Bad input data
|
|
392
|
-
self._log_filter_error(query, logging.ERROR, e)
|
|
393
|
-
raise OperationParsingFailed(
|
|
394
|
-
"\n".join(map(str, e.messages)),
|
|
395
|
-
locator=query.query_locator,
|
|
396
|
-
) from e
|
|
397
|
-
except FieldError as e:
|
|
398
|
-
# e.g. doing a LIKE on a foreign key, or requesting an unknown field.
|
|
399
|
-
if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
|
|
400
|
-
raise
|
|
401
|
-
self._log_filter_error(query, logging.ERROR, e)
|
|
402
|
-
raise InvalidParameterValue(
|
|
403
|
-
"Internal error when processing filter",
|
|
404
|
-
locator=query.query_locator,
|
|
405
|
-
) from e
|
|
406
|
-
except (InternalError, ProgrammingError) as e:
|
|
407
|
-
# e.g. comparing datetime against integer
|
|
408
|
-
if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
|
|
409
|
-
raise
|
|
410
|
-
logger.exception("WFS request failed: %s\nRequest: %r", str(e), self.ows_request)
|
|
411
|
-
msg = str(e)
|
|
412
|
-
locator = "srsName" if "Cannot find SRID" in msg else query.query_locator
|
|
413
|
-
raise InvalidParameterValue(f"Invalid request: {msg}", locator=locator) from e
|
|
414
|
-
except (TypeError, ValueError) as e:
|
|
415
|
-
# TypeError/ValueError could reference a datatype mismatch in an
|
|
416
|
-
# ORM query, but it could also be an internal bug. In most cases,
|
|
417
|
-
# this is already caught by XsdElement.validate_comparison().
|
|
418
|
-
if self._is_orm_error(e):
|
|
419
|
-
if query.query_locator == "STOREDQUERY_ID":
|
|
420
|
-
# This is a fallback, ideally the stored query performs its own validation.
|
|
421
|
-
raise InvalidParameterValue(
|
|
422
|
-
f"Invalid stored query parameter: {e}", locator=query.query_locator
|
|
423
|
-
) from e
|
|
424
|
-
else:
|
|
425
|
-
raise InvalidParameterValue(
|
|
426
|
-
f"Invalid filter query: {e}", locator=query.query_locator
|
|
427
|
-
) from e
|
|
428
|
-
raise
|
|
429
|
-
|
|
430
|
-
def _is_orm_error(self, exception: Exception):
|
|
431
|
-
traceback = exception.__traceback__
|
|
432
|
-
while traceback.tb_next is not None:
|
|
433
|
-
traceback = traceback.tb_next
|
|
434
|
-
if "/django/db/models/query" in traceback.tb_frame.f_code.co_filename:
|
|
435
|
-
return True
|
|
436
|
-
return False
|
|
437
|
-
|
|
438
|
-
def _log_filter_error(self, query, level, exc):
|
|
439
|
-
"""Report a filtering parsing error in the logging"""
|
|
440
|
-
filter = getattr(query, "filter", None) # AdhocQuery only
|
|
441
|
-
fes_xml = filter.source if filter is not None else "(not provided)"
|
|
442
|
-
try:
|
|
443
|
-
sql = exc.__cause__.cursor.query.decode()
|
|
444
|
-
except AttributeError:
|
|
445
|
-
logger.log(level, "WFS query failed: %s\nFilter:\n%s", exc, fes_xml)
|
|
446
|
-
else:
|
|
447
|
-
logger.log(
|
|
448
|
-
level,
|
|
449
|
-
"WFS query failed: %s\nSQL Query: %s\n\nFilter:\n%s",
|
|
450
|
-
exc,
|
|
451
|
-
sql,
|
|
452
|
-
fes_xml,
|
|
453
|
-
)
|
|
454
|
-
|
|
455
393
|
|
|
456
394
|
class GetFeature(BaseWFSGetDataOperation):
|
|
457
395
|
"""This returns all properties of the feature.
|
gisserver/output/__init__.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The output rendering classes for all response types.
|
|
2
|
+
|
|
3
|
+
This also includes collection classes for iterating over the Django QuerySet results.
|
|
4
|
+
"""
|
|
2
5
|
|
|
3
6
|
from .results import FeatureCollection, SimpleFeatureCollection # isort: skip (fixes import loops)
|
|
4
7
|
|
|
5
|
-
from .base import CollectionOutputRenderer, OutputRenderer
|
|
8
|
+
from .base import CollectionOutputRenderer, OutputRenderer, XmlOutputRenderer
|
|
6
9
|
from .csv import CSVRenderer, DBCSVRenderer
|
|
7
10
|
from .geojson import DBGeoJsonRenderer, GeoJsonRenderer
|
|
8
11
|
from .gml32 import (
|
|
@@ -28,6 +31,7 @@ __all__ = [
|
|
|
28
31
|
"GeoJsonRenderer",
|
|
29
32
|
"GML32Renderer",
|
|
30
33
|
"GML32ValueRenderer",
|
|
34
|
+
"XmlOutputRenderer",
|
|
31
35
|
"XmlSchemaRenderer",
|
|
32
36
|
"ListStoredQueriesRenderer",
|
|
33
37
|
"DescribeStoredQueriesRenderer",
|
gisserver/output/base.py
CHANGED
|
@@ -3,13 +3,16 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
import math
|
|
5
5
|
import typing
|
|
6
|
+
from collections.abc import Iterator
|
|
6
7
|
from io import BytesIO, StringIO
|
|
8
|
+
from itertools import chain
|
|
7
9
|
|
|
8
10
|
from django.conf import settings
|
|
9
11
|
from django.db import models
|
|
10
12
|
from django.http import HttpResponse, StreamingHttpResponse
|
|
11
13
|
from django.http.response import HttpResponseBase # Django 3.2 import location
|
|
12
14
|
|
|
15
|
+
from gisserver.exceptions import wrap_filter_errors
|
|
13
16
|
from gisserver.features import FeatureType
|
|
14
17
|
from gisserver.parsers.values import fix_type_name
|
|
15
18
|
from gisserver.parsers.xml import split_ns
|
|
@@ -23,15 +26,15 @@ if typing.TYPE_CHECKING:
|
|
|
23
26
|
from gisserver.operations.base import WFSOperation
|
|
24
27
|
from gisserver.projection import FeatureProjection, FeatureRelation
|
|
25
28
|
|
|
26
|
-
from .results import FeatureCollection
|
|
29
|
+
from .results import FeatureCollection, SimpleFeatureCollection
|
|
27
30
|
|
|
28
31
|
|
|
29
32
|
class OutputRenderer:
|
|
30
33
|
"""Base class for rendering content.
|
|
31
34
|
|
|
32
35
|
Note most rendering logic will generally
|
|
33
|
-
need to use :class
|
|
34
|
-
as their base class
|
|
36
|
+
need to use :class:`~gisserver.output.XmlOutputRenderer`
|
|
37
|
+
or :class:`~gisserver.output.CollectionOutputRenderer` as their base class
|
|
35
38
|
"""
|
|
36
39
|
|
|
37
40
|
#: Default content type for the HTTP response
|
|
@@ -53,15 +56,23 @@ class OutputRenderer:
|
|
|
53
56
|
)
|
|
54
57
|
else:
|
|
55
58
|
# An actual generator.
|
|
59
|
+
# Peek the generator so initial exceptions can still be handled,
|
|
60
|
+
# and get rendered as normal HTTP responses with the proper status.
|
|
61
|
+
try:
|
|
62
|
+
start = next(stream) # peek, so any raised OWSException here is handled by OWSView
|
|
63
|
+
stream = chain([start], self._trap_exceptions(stream))
|
|
64
|
+
except StopIteration:
|
|
65
|
+
pass
|
|
66
|
+
|
|
56
67
|
# Handover to WSGI server (starts streaming when reading the contents)
|
|
57
|
-
stream = self._trap_exceptions(stream)
|
|
58
68
|
return StreamingHttpResponse(
|
|
59
69
|
streaming_content=stream,
|
|
60
70
|
content_type=self.content_type,
|
|
61
71
|
headers=self.get_headers(),
|
|
62
72
|
)
|
|
63
73
|
|
|
64
|
-
def get_headers(self):
|
|
74
|
+
def get_headers(self) -> dict[str, str]:
|
|
75
|
+
"""Override to define HTTP headers to add."""
|
|
65
76
|
return {}
|
|
66
77
|
|
|
67
78
|
def _trap_exceptions(self, stream):
|
|
@@ -136,10 +147,7 @@ class XmlOutputRenderer(OutputRenderer):
|
|
|
136
147
|
|
|
137
148
|
|
|
138
149
|
class CollectionOutputRenderer(OutputRenderer):
|
|
139
|
-
"""Base class to create streaming responses.
|
|
140
|
-
|
|
141
|
-
It receives the collected 'context' data of the WFSMethod.
|
|
142
|
-
"""
|
|
150
|
+
"""Base class to create streaming responses."""
|
|
143
151
|
|
|
144
152
|
#: Allow to override the maximum page size.
|
|
145
153
|
#: This value can be 'math.inf' to support endless pages by default.
|
|
@@ -152,7 +160,7 @@ class CollectionOutputRenderer(OutputRenderer):
|
|
|
152
160
|
"""
|
|
153
161
|
Receive the collected data to render.
|
|
154
162
|
|
|
155
|
-
:param operation: The calling WFS
|
|
163
|
+
:param operation: The calling WFS Operation (e.g. GetFeature class)
|
|
156
164
|
:param collection: The collected data for rendering.
|
|
157
165
|
"""
|
|
158
166
|
super().__init__(operation)
|
|
@@ -160,7 +168,10 @@ class CollectionOutputRenderer(OutputRenderer):
|
|
|
160
168
|
self.apply_projection()
|
|
161
169
|
|
|
162
170
|
def apply_projection(self):
|
|
163
|
-
"""Perform presentation-layer logic enhancements on
|
|
171
|
+
"""Perform presentation-layer logic enhancements on all results.
|
|
172
|
+
This calls :meth:`decorate_queryset` for
|
|
173
|
+
each :class:`~gisserver.output.SimpleFeatureCollection`.
|
|
174
|
+
"""
|
|
164
175
|
for sub_collection in self.collection.results:
|
|
165
176
|
queryset = self.decorate_queryset(
|
|
166
177
|
projection=sub_collection.projection,
|
|
@@ -180,9 +191,8 @@ class CollectionOutputRenderer(OutputRenderer):
|
|
|
180
191
|
|
|
181
192
|
This allows fine-tuning the queryset for any special needs of the output rendering type.
|
|
182
193
|
|
|
183
|
-
:param
|
|
194
|
+
:param projection: The projection information, including feature that is being rendered.
|
|
184
195
|
:param queryset: The constructed queryset so far.
|
|
185
|
-
:param output_crs: The projected output
|
|
186
196
|
"""
|
|
187
197
|
if queryset._result_cache is not None:
|
|
188
198
|
raise RuntimeError(
|
|
@@ -266,3 +276,8 @@ class CollectionOutputRenderer(OutputRenderer):
|
|
|
266
276
|
"date": self.collection.date.strftime("%Y-%m-%d %H.%M.%S%z"),
|
|
267
277
|
"timestamp": self.collection.timestamp,
|
|
268
278
|
}
|
|
279
|
+
|
|
280
|
+
def read_features(self, sub_collection: SimpleFeatureCollection) -> Iterator[models.Model]:
|
|
281
|
+
"""A wrapper to read features from a collection, while raising WFS exceptions on query errors."""
|
|
282
|
+
with wrap_filter_errors(sub_collection.source_query):
|
|
283
|
+
yield from sub_collection
|
gisserver/output/csv.py
CHANGED
|
@@ -40,7 +40,7 @@ class CSVRenderer(CollectionOutputRenderer):
|
|
|
40
40
|
# First, make sure no array or m2m elements exist,
|
|
41
41
|
# as these are not possible to render in CSV.
|
|
42
42
|
projection.remove_fields(
|
|
43
|
-
lambda e: e.is_many
|
|
43
|
+
lambda e: (e.is_many and not e.is_array)
|
|
44
44
|
or e.type == XsdTypes.gmlCodeType # gml:name
|
|
45
45
|
or e.type == XsdTypes.gmlBoundingShapeType # gml:boundedBy
|
|
46
46
|
)
|
|
@@ -66,7 +66,7 @@ class CSVRenderer(CollectionOutputRenderer):
|
|
|
66
66
|
writer.writerow(self.get_header(projection, xsd_elements))
|
|
67
67
|
|
|
68
68
|
# Write all rows
|
|
69
|
-
for instance in sub_collection:
|
|
69
|
+
for instance in self.read_features(sub_collection):
|
|
70
70
|
writer.writerow(self.get_row(instance, projection, xsd_elements))
|
|
71
71
|
|
|
72
72
|
# Only perform a 'yield' every once in a while,
|
|
@@ -113,7 +113,7 @@ class CSVRenderer(CollectionOutputRenderer):
|
|
|
113
113
|
append = values.append
|
|
114
114
|
for xsd_element in xsd_elements:
|
|
115
115
|
if xsd_element.type.is_geometry:
|
|
116
|
-
append(self.render_geometry(instance, xsd_element))
|
|
116
|
+
append(self.render_geometry(projection, instance, xsd_element))
|
|
117
117
|
continue
|
|
118
118
|
|
|
119
119
|
value = xsd_element.get_value(instance)
|
|
@@ -134,9 +134,16 @@ class CSVRenderer(CollectionOutputRenderer):
|
|
|
134
134
|
append(value)
|
|
135
135
|
return values
|
|
136
136
|
|
|
137
|
-
def render_geometry(
|
|
137
|
+
def render_geometry(
|
|
138
|
+
self,
|
|
139
|
+
projection: FeatureProjection,
|
|
140
|
+
instance: models.Model,
|
|
141
|
+
geo_element: GeometryXsdElement,
|
|
142
|
+
) -> str:
|
|
138
143
|
"""Render the contents of a geometry value."""
|
|
139
|
-
|
|
144
|
+
geometry = geo_element.get_value(instance)
|
|
145
|
+
projection.output_crs.apply_to(geometry)
|
|
146
|
+
return geometry.ewkt
|
|
140
147
|
|
|
141
148
|
|
|
142
149
|
class DBCSVRenderer(CSVRenderer):
|
|
@@ -167,6 +174,11 @@ class DBCSVRenderer(CSVRenderer):
|
|
|
167
174
|
queryset, feature_relation.geometry_elements, projection.output_crs, AsEWKT
|
|
168
175
|
)
|
|
169
176
|
|
|
170
|
-
def render_geometry(
|
|
177
|
+
def render_geometry(
|
|
178
|
+
self,
|
|
179
|
+
projection: FeatureProjection,
|
|
180
|
+
instance: models.Model,
|
|
181
|
+
geo_element: GeometryXsdElement,
|
|
182
|
+
):
|
|
171
183
|
"""Render the geometry using a database-rendered version."""
|
|
172
184
|
return get_db_rendered_geometry(instance, geo_element, AsEWKT)
|
gisserver/output/geojson.py
CHANGED
|
@@ -6,12 +6,13 @@ from io import BytesIO
|
|
|
6
6
|
|
|
7
7
|
import orjson
|
|
8
8
|
from django.contrib.gis.db.models.functions import AsGeoJSON
|
|
9
|
+
from django.contrib.gis.gdal import AxisOrder
|
|
9
10
|
from django.db import models
|
|
10
11
|
from django.utils.functional import Promise
|
|
11
12
|
|
|
12
13
|
from gisserver import conf
|
|
14
|
+
from gisserver.crs import CRS84, WGS84
|
|
13
15
|
from gisserver.db import get_db_geometry_target
|
|
14
|
-
from gisserver.geometries import CRS84, WGS84
|
|
15
16
|
from gisserver.projection import FeatureProjection
|
|
16
17
|
from gisserver.types import XsdElement
|
|
17
18
|
|
|
@@ -31,7 +32,7 @@ class GeoJsonRenderer(CollectionOutputRenderer):
|
|
|
31
32
|
The complex encoding bits are handled by the C-library "orjson"
|
|
32
33
|
and the geojson property of GEOSGeometry.
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
While Django has a GeoJSON serializer
|
|
35
36
|
(see https://docs.djangoproject.com/en/3.0/ref/contrib/gis/serializers/),
|
|
36
37
|
it does not offer streaming response handling.
|
|
37
38
|
"""
|
|
@@ -84,7 +85,7 @@ class GeoJsonRenderer(CollectionOutputRenderer):
|
|
|
84
85
|
output.write(b",\n")
|
|
85
86
|
|
|
86
87
|
is_first = True
|
|
87
|
-
for instance in sub_collection:
|
|
88
|
+
for instance in self.read_features(sub_collection):
|
|
88
89
|
if is_first:
|
|
89
90
|
is_first = False
|
|
90
91
|
else:
|
|
@@ -155,9 +156,9 @@ class GeoJsonRenderer(CollectionOutputRenderer):
|
|
|
155
156
|
if geometry is None:
|
|
156
157
|
return b"null"
|
|
157
158
|
|
|
158
|
-
# The
|
|
159
|
-
#
|
|
160
|
-
projection.output_crs.apply_to(geometry)
|
|
159
|
+
# The GeoJSON spec requires coordinates to be x,y (longitude,latitude),
|
|
160
|
+
# so web-based clients don't have to ship projection tables.
|
|
161
|
+
projection.output_crs.apply_to(geometry, axis_order=AxisOrder.TRADITIONAL)
|
|
161
162
|
return geometry.json.encode()
|
|
162
163
|
|
|
163
164
|
def get_header(self) -> dict:
|