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.
Files changed (56) hide show
  1. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/METADATA +27 -10
  2. django_gisserver-2.1.1.dist-info/RECORD +68 -0
  3. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/conf.py +23 -1
  6. gisserver/crs.py +452 -0
  7. gisserver/db.py +78 -6
  8. gisserver/exceptions.py +106 -2
  9. gisserver/extensions/functions.py +122 -28
  10. gisserver/extensions/queries.py +15 -10
  11. gisserver/features.py +46 -33
  12. gisserver/geometries.py +64 -306
  13. gisserver/management/commands/loadgeojson.py +41 -21
  14. gisserver/operations/base.py +11 -7
  15. gisserver/operations/wfs20.py +31 -93
  16. gisserver/output/__init__.py +6 -2
  17. gisserver/output/base.py +28 -13
  18. gisserver/output/csv.py +18 -6
  19. gisserver/output/geojson.py +7 -6
  20. gisserver/output/gml32.py +86 -27
  21. gisserver/output/results.py +25 -39
  22. gisserver/output/utils.py +9 -2
  23. gisserver/parsers/ast.py +177 -68
  24. gisserver/parsers/fes20/__init__.py +76 -4
  25. gisserver/parsers/fes20/expressions.py +97 -27
  26. gisserver/parsers/fes20/filters.py +9 -6
  27. gisserver/parsers/fes20/identifiers.py +27 -7
  28. gisserver/parsers/fes20/lookups.py +8 -6
  29. gisserver/parsers/fes20/operators.py +101 -49
  30. gisserver/parsers/fes20/sorting.py +14 -6
  31. gisserver/parsers/gml/__init__.py +10 -19
  32. gisserver/parsers/gml/base.py +32 -14
  33. gisserver/parsers/gml/geometries.py +54 -21
  34. gisserver/parsers/ows/kvp.py +10 -2
  35. gisserver/parsers/ows/requests.py +6 -4
  36. gisserver/parsers/query.py +6 -2
  37. gisserver/parsers/values.py +61 -4
  38. gisserver/parsers/wfs20/__init__.py +2 -0
  39. gisserver/parsers/wfs20/adhoc.py +28 -18
  40. gisserver/parsers/wfs20/base.py +12 -7
  41. gisserver/parsers/wfs20/projection.py +3 -3
  42. gisserver/parsers/wfs20/requests.py +1 -0
  43. gisserver/parsers/wfs20/stored.py +3 -2
  44. gisserver/parsers/xml.py +12 -0
  45. gisserver/projection.py +17 -7
  46. gisserver/static/gisserver/index.css +27 -6
  47. gisserver/templates/gisserver/base.html +15 -0
  48. gisserver/templates/gisserver/index.html +10 -16
  49. gisserver/templates/gisserver/service_description.html +12 -6
  50. gisserver/templates/gisserver/wfs/feature_field.html +1 -1
  51. gisserver/templates/gisserver/wfs/feature_type.html +44 -13
  52. gisserver/types.py +152 -82
  53. gisserver/views.py +47 -24
  54. django_gisserver-2.0.dist-info/RECORD +0 -66
  55. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info/licenses}/LICENSE +0 -0
  56. {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.geometries import CRS, WGS84
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 ValueError(str(e)) from e
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 value:
33
- raise ValueError("Expect property=field,property2=field2 format")
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
- nargs="*",
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 WGS84
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
- geometry = GEOSGeometry(json.dumps(geometry_data), srid=self.crs.srid)
266
- geometry.srid = self.crs.srid # override default
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 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
- )
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
- return pk_field.get_db_prep_save(id_value.rpartition(".")[2], connection=self.connection)
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:
@@ -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 WFSMethod that implements :class:`OutputFormatMixin`.
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 WFSMethod is instantiated with each request
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
- #: Default content-type for render_xml()
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
- """Shortcut to render XML.
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),
@@ -1,11 +1,14 @@
1
- """Operations that implement the WFS 2.0 spec.
2
-
3
- Useful docs:
4
- * http://portal.opengeospatial.org/files/?artifact_id=39967
5
- * https://www.opengeospatial.org/standards/wfs#downloads
6
- * http://schemas.opengis.net/wfs/2.0/
7
- * https://mapserver.org/development/rfc/ms-rfc-105.html
8
- * https://enonline.supermap.com/iExpress9D/API/WFS/WFS200/WFS_2.0.0_introduction.htm
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 FieldError, ImproperlyConfigured, ValidationError
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
- queryset = self._get_queryset(query)
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
- queryset = self._get_queryset(query)
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.
@@ -1,8 +1,11 @@
1
- """All supported output formats"""
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:`XmlOutputRenderer` or :class:`ConnectionOutputRenderer`
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 Method (e.g. GetFeature class)
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 the queryset."""
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 feature_type: The feature that is being queried.
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(self, instance: models.Model, geo_element: GeometryXsdElement):
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
- return geo_element.get_value(instance)
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(self, instance: models.Model, geo_element: GeometryXsdElement):
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)
@@ -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
- NOTE: While Django has a GeoJSON serializer
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 .json property always outputs coordinates as x,y (longitude,latitude),
159
- # which the GeoJSON spec requires.
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: