django-gisserver 1.4.0__py3-none-any.whl → 1.5.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 (38) hide show
  1. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/METADATA +15 -13
  2. django_gisserver-1.5.0.dist-info/RECORD +54 -0
  3. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/db.py +14 -20
  6. gisserver/exceptions.py +23 -9
  7. gisserver/features.py +64 -100
  8. gisserver/geometries.py +2 -2
  9. gisserver/operations/base.py +31 -21
  10. gisserver/operations/wfs20.py +44 -38
  11. gisserver/output/__init__.py +2 -1
  12. gisserver/output/base.py +43 -27
  13. gisserver/output/csv.py +38 -33
  14. gisserver/output/geojson.py +43 -51
  15. gisserver/output/gml32.py +88 -67
  16. gisserver/output/results.py +23 -8
  17. gisserver/output/utils.py +18 -2
  18. gisserver/output/xmlschema.py +1 -1
  19. gisserver/parsers/base.py +2 -2
  20. gisserver/parsers/fes20/__init__.py +18 -0
  21. gisserver/parsers/fes20/expressions.py +7 -12
  22. gisserver/parsers/fes20/functions.py +1 -1
  23. gisserver/parsers/fes20/operators.py +7 -3
  24. gisserver/parsers/fes20/query.py +11 -1
  25. gisserver/parsers/fes20/sorting.py +3 -1
  26. gisserver/parsers/gml/base.py +1 -1
  27. gisserver/queries/__init__.py +3 -0
  28. gisserver/queries/adhoc.py +16 -12
  29. gisserver/queries/base.py +76 -36
  30. gisserver/queries/projection.py +240 -0
  31. gisserver/queries/stored.py +7 -6
  32. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +2 -2
  33. gisserver/types.py +78 -24
  34. gisserver/views.py +9 -20
  35. django_gisserver-1.4.0.dist-info/RECORD +0 -54
  36. gisserver/output/gml32_lxml.py +0 -612
  37. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/LICENSE +0 -0
  38. {django_gisserver-1.4.0.dist-info → django_gisserver-1.5.0.dist-info}/top_level.txt +0 -0
@@ -32,12 +32,12 @@ RE_SAFE_FILENAME = re.compile(r"\A[A-Za-z0-9]+[A-Za-z0-9.]*") # no dot at the s
32
32
 
33
33
  @dataclass(frozen=True)
34
34
  class Parameter:
35
- """The definition of an parameter for an WFS method.
35
+ """The definition of a parameter for an WFS method.
36
36
 
37
37
  These parameters should be defined in the
38
38
  WFSMethod parameters attribute / get_parameters().
39
39
 
40
- These fields is used to parse the incoming request.
40
+ These fields are used to parse the incoming request.
41
41
  The GetCapabilities method also reads the metadata of this value.
42
42
  """
43
43
 
@@ -81,9 +81,9 @@ class Parameter:
81
81
  if not self.required:
82
82
  return self.default
83
83
  elif value is None:
84
- raise MissingParameterValue(self.name, f"Missing {kvp_name} parameter")
84
+ raise MissingParameterValue(f"Missing {kvp_name} parameter", locator=self.name)
85
85
  else:
86
- raise InvalidParameterValue(self.name, f"Empty {kvp_name} parameter")
86
+ raise InvalidParameterValue(f"Empty {kvp_name} parameter", locator=self.name)
87
87
 
88
88
  # Allow conversion into a python object
89
89
  if self.parser is not None:
@@ -91,13 +91,15 @@ class Parameter:
91
91
  value = self.parser(value)
92
92
  except ExternalParsingError as e:
93
93
  raise OperationParsingFailed(
94
- self.name, f"Unable to parse {kvp_name} argument: {e}"
94
+ f"Unable to parse {kvp_name} argument: {e}",
95
+ locator=self.name,
95
96
  ) from None
96
97
  except (TypeError, ValueError, NotImplementedError) as e:
97
98
  # TypeError/ValueError are raised by most handlers for unexpected data
98
99
  # The NotImplementedError can be raised by fes parsing.
99
100
  raise InvalidParameterValue(
100
- self.name, f"Invalid {kvp_name} argument: {e}"
101
+ f"Invalid {kvp_name} argument: {e}",
102
+ locator=self.name,
101
103
  ) from None
102
104
 
103
105
  # Validate against value choices
@@ -111,14 +113,16 @@ class Parameter:
111
113
  """
112
114
  if self.allowed_values is not None and value not in self.allowed_values:
113
115
  msg = self.error_messages.get("invalid", "Invalid value for {name}: {value}")
114
- raise InvalidParameterValue(self.name, msg.format(name=self.name, value=value))
116
+ raise InvalidParameterValue(msg.format(name=self.name, value=value), locator=self.name)
115
117
 
116
118
 
117
119
  class UnsupportedParameter(Parameter):
118
120
  def value_from_query(self, KVP: dict):
119
121
  kvp_name = self.name.upper()
120
122
  if kvp_name in KVP:
121
- raise InvalidParameterValue(self.name, f"Support for {self.name} is not implemented!")
123
+ raise InvalidParameterValue(
124
+ f"Support for {self.name} is not implemented!", locator=self.name
125
+ )
122
126
  return None
123
127
 
124
128
 
@@ -134,6 +138,7 @@ class OutputFormat:
134
138
  renderer_class=None,
135
139
  max_page_size=None,
136
140
  title=None,
141
+ in_capabilities=True,
137
142
  **extra,
138
143
  ):
139
144
  """
@@ -141,7 +146,8 @@ class OutputFormat:
141
146
  :param renderer_class: The class that performs the output rendering.
142
147
  If it's not given, the operation renders it's output using an XML template.
143
148
  :param max_page_size: Used to override the ``max_page_size`` of the renderer_class.
144
- :param title: A human-friendly name for a HTML overview page.
149
+ :param title: A human-friendly name for an HTML overview page.
150
+ :param in_capabilities: Whether this format needs to be advertised in GetCapabilities.
145
151
  :param extra: Any additional key-value pairs for the definition.
146
152
  Could include ``subtype`` as a shorter alias for the MIME-type.
147
153
  """
@@ -150,6 +156,7 @@ class OutputFormat:
150
156
  self.subtype = self.extra.get("subtype")
151
157
  self.renderer_class = renderer_class
152
158
  self.title = title
159
+ self.in_capabilities = in_capabilities
153
160
  self._max_page_size = max_page_size
154
161
 
155
162
  def matches(self, value):
@@ -197,7 +204,7 @@ class WFSMethod:
197
204
 
198
205
  do_not_call_in_templates = True # avoid __call__ execution in Django templates
199
206
 
200
- #: List the suported parameters for this method, extended by get_parameters()
207
+ #: List the supported parameters for this method, extended by get_parameters()
201
208
  parameters: list[Parameter] = []
202
209
 
203
210
  #: List the supported output formats for this method.
@@ -258,20 +265,20 @@ class WFSMethod:
258
265
 
259
266
  def _parse_output_format(self, value) -> OutputFormat:
260
267
  """Select the proper OutputFormat object based on the input value"""
261
- # When using ?OUTPUTFORMAT=application/gml+xml", it is actually an URL-encoded space
262
- # character. Hence spaces are replaced back to a '+' character to allow such notation
263
- # instead of forcing it to to be ?OUTPUTFORMAT=application/gml%2bxml".
268
+ # When using ?OUTPUTFORMAT=application/gml+xml", it is actually a URL-encoded space
269
+ # character. Hence, spaces are replaced back to a '+' character to allow such notation
270
+ # instead of forcing it to be ?OUTPUTFORMAT=application/gml%2bxml".
264
271
  for v in {value, value.replace(" ", "+")}:
265
272
  for o in self.output_formats:
266
273
  if o.matches(v):
267
274
  return o
268
275
  raise InvalidParameterValue(
269
- "outputformat",
270
276
  f"'{value}' is not a permitted output format for this operation.",
277
+ locator="outputformat",
271
278
  ) from None
272
279
 
273
280
  def _parse_namespaces(self, value) -> dict[str, str]:
274
- """Parse the namespaces definition.
281
+ """Parse the 'namespaces' definition.
275
282
 
276
283
  The NAMESPACES parameter defines which namespaces are used in the KVP request.
277
284
  When this parameter is not given, the default namespaces are assumed.
@@ -287,7 +294,9 @@ class WFSMethod:
287
294
  tokens = iter(tokens)
288
295
  for prefix in tokens:
289
296
  if not prefix.startswith("xmlns("):
290
- raise InvalidParameterValue("namespaces", f"Expected xmlns(...) format: {value}")
297
+ raise InvalidParameterValue(
298
+ f"Expected xmlns(...) format: {value}", locator="namespaces"
299
+ )
291
300
  if prefix.endswith(")"):
292
301
  # xmlns(http://...)
293
302
  prefix = ""
@@ -296,7 +305,8 @@ class WFSMethod:
296
305
  uri = next(tokens, "")
297
306
  if not uri.endswith(")"):
298
307
  raise InvalidParameterValue(
299
- "namespaces", f"Expected xmlns(prefix,uri) format: {value}"
308
+ f"Expected xmlns(prefix,uri) format: {value}",
309
+ locator="namespaces",
300
310
  )
301
311
  prefix = prefix[6:]
302
312
  uri = uri[:-1]
@@ -361,12 +371,12 @@ class WFSMethod:
361
371
  )
362
372
 
363
373
  def _get_xml_template_name(self):
364
- """Generate the XML template name for this operation, and check it's file pattern"""
374
+ """Generate the XML template name for this operation, and check its file pattern"""
365
375
  service = self.view.KVP["SERVICE"].lower()
366
376
  template_name = f"gisserver/{service}/{self.view.version}/{self.xml_template_name}"
367
377
 
368
378
  # Since 'service' and 'version' are based on external input,
369
- # these values are double checked again to avoid remove file inclusion.
379
+ # these values are double-checked again to avoid remove file inclusion.
370
380
  if not RE_SAFE_FILENAME.match(service) or not RE_SAFE_FILENAME.match(self.view.version):
371
381
  raise SuspiciousOperation(f"Refusing to render template name {template_name}")
372
382
 
@@ -403,8 +413,8 @@ class WFSTypeNamesMethod(WFSMethod):
403
413
  # This allows to perform multiple queries in a single request:
404
414
  # TYPENAMES=(A)(B)&FILTER=(filter for A)(filter for B)
405
415
  raise OperationParsingFailed(
406
- "typenames",
407
416
  "Parameter lists to perform multiple queries are not supported yet.",
417
+ locator="typenames",
408
418
  )
409
419
 
410
420
  return [self._parse_type_name(name, locator="typenames") for name in type_names.split(",")]
@@ -419,9 +429,9 @@ class WFSTypeNamesMethod(WFSMethod):
419
429
  return self.all_feature_types_by_name[local_name]
420
430
  except KeyError:
421
431
  raise InvalidParameterValue(
422
- locator,
423
432
  f"Typename '{name}' doesn't exist in this server. "
424
433
  f"Please check the capabilities and reformulate your request.",
434
+ locator=locator,
425
435
  ) from None
426
436
 
427
437
 
@@ -44,11 +44,17 @@ RE_SAFE_FILENAME = re.compile(r"\A[A-Za-z0-9]+[A-Za-z0-9.]*") # no dot at the s
44
44
 
45
45
 
46
46
  class GetCapabilities(WFSMethod):
47
- """ "This operation returns map features, and available operations this WFS server supports."""
47
+ """This operation returns map features, and available operations this WFS server supports."""
48
48
 
49
49
  output_formats = [
50
- OutputFormat("application/gml+xml", version="3.2", title="GML"), # for FME
51
50
  OutputFormat("text/xml"),
51
+ OutputFormat(
52
+ # for FME (Feature Manipulation Engine)
53
+ "application/gml+xml",
54
+ version="3.2",
55
+ title="GML",
56
+ in_capabilities=False,
57
+ ),
52
58
  ]
53
59
  xml_template_name = "get_capabilities.xml"
54
60
 
@@ -87,16 +93,17 @@ class GetCapabilities(WFSMethod):
87
93
  if "VERSION" in self.view.KVP:
88
94
  # Even GetCapabilities can still receive a VERSION argument to fixate it.
89
95
  raise InvalidParameterValue(
90
- "AcceptVersions", "Can't provide both ACCEPTVERSIONS and VERSION"
96
+ "Can't provide both ACCEPTVERSIONS and VERSION",
97
+ locator="AcceptVersions",
91
98
  )
92
99
 
93
100
  matched_versions = set(accept_versions.split(",")).intersection(self.view.accept_versions)
94
101
  if not matched_versions:
95
102
  allowed = ", ".join(self.view.accept_versions)
96
103
  raise VersionNegotiationFailed(
97
- "acceptversions",
98
104
  f"'{accept_versions}' does not contain supported versions, "
99
105
  f"supported are: {allowed}.",
106
+ locator="acceptversions",
100
107
  )
101
108
 
102
109
  # Take the highest version (mapserver returns first matching)
@@ -115,7 +122,7 @@ class GetCapabilities(WFSMethod):
115
122
  def get_context_data(self, **params):
116
123
  view = self.view
117
124
 
118
- # The 'service' is not reaad from 'params' to avoid dependency on get_parameters()
125
+ # The 'service' is not read from 'params' to avoid dependency on get_parameters()
119
126
  service = view.KVP["SERVICE"] # is WFS
120
127
  service_operations = view.accept_operations[service]
121
128
  feature_output_formats = service_operations["GetFeature"].output_formats
@@ -138,18 +145,19 @@ class GetCapabilities(WFSMethod):
138
145
 
139
146
  class DescribeFeatureType(WFSTypeNamesMethod):
140
147
  """This returns an XML Schema for the provided objects.
141
- Each feature is exposed as an XSD definition with it's fields.
148
+ Each feature is exposed as an XSD definition with its fields.
142
149
  """
143
150
 
144
151
  output_formats = [
145
152
  OutputFormat("XMLSCHEMA", renderer_class=output.XMLSchemaRenderer),
146
- # At least one version of FME seems to sends a DescribeFeatureType
147
- # request with this output format. Do what mapserver does and just
148
- # send it XML Schema.
153
+ # At least one version of FME (Feature Manipulation Engine) seems to
154
+ # send a DescribeFeatureType request with this GML as output format.
155
+ # Do what mapserver does and just send it XML Schema.
149
156
  OutputFormat(
150
157
  "application/gml+xml",
151
158
  version="3.2",
152
159
  renderer_class=output.XMLSchemaRenderer,
160
+ in_capabilities=False,
153
161
  ),
154
162
  # OutputFormat("text/xml", subtype="gml/3.1.1"),
155
163
  ]
@@ -157,7 +165,7 @@ class DescribeFeatureType(WFSTypeNamesMethod):
157
165
  def get_context_data(self, typeNames, **params):
158
166
  if self.view.KVP.get("TYPENAMES") == "" or self.view.KVP.get("TYPENAME") == "":
159
167
  # Using TYPENAMES= does result in an error.
160
- raise MissingParameterValue("typeNames", "Empty TYPENAMES parameter")
168
+ raise MissingParameterValue("Empty TYPENAMES parameter", locator="typeNames")
161
169
  elif typeNames is None:
162
170
  # Not given, all types are returned
163
171
  typeNames = self.all_feature_types
@@ -218,7 +226,7 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
218
226
  # StandardInputParameters
219
227
  Parameter("srsName", parser=CRS.from_string),
220
228
  # Projection clause parameters
221
- UnsupportedParameter("propertyName"), # which fields to return
229
+ Parameter("propertyName", parser=fes20.parse_property_name),
222
230
  # AdHoc Query parameters
223
231
  Parameter("bbox", parser=BoundingBox.from_string),
224
232
  Parameter(
@@ -239,7 +247,7 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
239
247
 
240
248
  try:
241
249
  if resultType == "HITS":
242
- collection = self.get_hits(query)
250
+ collection = query.get_hits()
243
251
  elif resultType == "RESULTS":
244
252
  # Validate StandardPresentationParameters
245
253
  collection = self.get_paginated_results(query, **params)
@@ -248,16 +256,17 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
248
256
  except ExternalParsingError as e:
249
257
  # Bad input data
250
258
  self._log_filter_error(query, logging.ERROR, e)
251
- raise OperationParsingFailed(self._get_locator(**params), str(e)) from e
259
+ raise OperationParsingFailed(str(e), locator=self._get_locator(**params)) from e
252
260
  except ExternalValueError as e:
253
261
  # Bad input data
254
262
  self._log_filter_error(query, logging.ERROR, e)
255
- raise InvalidParameterValue(self._get_locator(**params), str(e)) from e
263
+ raise InvalidParameterValue(str(e), locator=self._get_locator(**params)) from e
256
264
  except ValidationError as e:
257
265
  # Bad input data
258
266
  self._log_filter_error(query, logging.ERROR, e)
259
267
  raise OperationParsingFailed(
260
- self._get_locator(**params), "\n".join(map(str, e.messages))
268
+ "\n".join(map(str, e.messages)),
269
+ locator=self._get_locator(**params),
261
270
  ) from e
262
271
  except FieldError as e:
263
272
  # e.g. doing a LIKE on a foreign key, or requesting an unknown field.
@@ -265,7 +274,8 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
265
274
  raise
266
275
  self._log_filter_error(query, logging.ERROR, e)
267
276
  raise InvalidParameterValue(
268
- self._get_locator(**params), "Internal error when processing filter"
277
+ "Internal error when processing filter",
278
+ locator=self._get_locator(**params),
269
279
  ) from e
270
280
  except (InternalError, ProgrammingError) as e:
271
281
  # e.g. comparing datetime against integer
@@ -274,14 +284,15 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
274
284
  logger.exception("WFS request failed: %s\nParams: %r", str(e), params)
275
285
  msg = str(e)
276
286
  locator = "srsName" if "Cannot find SRID" in msg else self._get_locator(**params)
277
- raise InvalidParameterValue(locator, f"Invalid request: {msg}") from e
287
+ raise InvalidParameterValue(f"Invalid request: {msg}", locator=locator) from e
278
288
  except (TypeError, ValueError) as e:
279
289
  # TypeError/ValueError could reference a datatype mismatch in an
280
290
  # ORM query, but it could also be an internal bug. In most cases,
281
291
  # this is already caught by XsdElement.validate_comparison().
282
292
  if self._is_orm_error(e):
283
293
  raise InvalidParameterValue(
284
- self._get_locator(**params), f"Invalid filter query: {e}"
294
+ f"Invalid filter query: {e}",
295
+ locator=self._get_locator(**params),
285
296
  ) from e
286
297
  raise
287
298
 
@@ -289,40 +300,35 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
289
300
  if not output_crs and collection.results:
290
301
  output_crs = collection.results[0].feature_type.crs
291
302
 
292
- # These become init kwargs for the selected OutputRenderer class:
293
303
  return {
304
+ # These become init kwargs for the selected OutputRenderer class:
294
305
  "source_query": query,
295
306
  "collection": collection,
296
307
  "output_crs": output_crs,
297
308
  }
298
309
 
299
310
  def get_query(self, **params) -> queries.QueryExpression:
300
- """Create the query object that will process this request"""
311
+ """Create or retrieve the query object that will process this request.
312
+
313
+ This can either retrieve the stored procedure (e.g. from a database),
314
+ or construct an ad-hoc query from request parameters.
315
+ """
301
316
  if params["STOREDQUERY_ID"]:
302
317
  # The 'StoredQueryParameter' already parses the input into a complete object.
303
318
  # When it's not provided, the regular Adhoc-query will be created from the KVP request.
304
319
  query = params["STOREDQUERY_ID"]
320
+ query.bind(
321
+ # TODO: pass this in a cleaner way?
322
+ all_feature_types=self.all_feature_types_by_name, # for GetFeatureById
323
+ value_reference=params.get("valueReference"), # for GetPropertyValue
324
+ property_names=params.get("propertyName"), # for GetFeature with PropertyName
325
+ )
305
326
  else:
306
- query = queries.AdhocQuery.from_kvp_request(**params)
307
-
308
- # TODO: pass this in a cleaner way?
309
- query.bind(
310
- all_feature_types=self.all_feature_types_by_name, # for GetFeatureById
311
- value_reference=params.get("valueReference"), # for GetPropertyValue
312
- )
327
+ query = queries.AdhocQuery.from_kvp_request(index=0, **params)
328
+ query.bind(all_feature_types=self.all_feature_types_by_name)
313
329
 
314
330
  return query
315
331
 
316
- def get_hits(self, query: queries.QueryExpression) -> output.FeatureCollection:
317
- """Return the number of hits"""
318
- return query.get_hits()
319
-
320
- def get_results(
321
- self, query: queries.QueryExpression, start, count
322
- ) -> output.FeatureCollection:
323
- """Return the actual results"""
324
- return query.get_results(start, count=count)
325
-
326
332
  def get_paginated_results(
327
333
  self, query: queries.QueryExpression, outputFormat, **params
328
334
  ) -> output.FeatureCollection:
@@ -336,7 +342,7 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
336
342
  stop = start + page_size
337
343
 
338
344
  # Perform query
339
- collection = self.get_results(query, start=start, count=page_size)
345
+ collection = query.get_results(start, count=page_size)
340
346
 
341
347
  # Allow presentation-layer to add extra logic.
342
348
  if outputFormat.renderer_class is not None:
@@ -1,5 +1,7 @@
1
1
  """All supported output formats"""
2
2
 
3
+ from .results import FeatureCollection, SimpleFeatureCollection # isort: skip (fixes import loops)
4
+
3
5
  from django.utils.functional import classproperty
4
6
 
5
7
  from gisserver import conf
@@ -13,7 +15,6 @@ from .gml32 import (
13
15
  GML32Renderer,
14
16
  GML32ValueRenderer,
15
17
  )
16
- from .results import FeatureCollection, SimpleFeatureCollection
17
18
  from .xmlschema import XMLSchemaRenderer
18
19
 
19
20
  __all__ = [
gisserver/output/base.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import math
4
+ import typing
4
5
 
5
6
  from django.conf import settings
6
7
  from django.db import models
@@ -9,11 +10,14 @@ from django.utils.html import escape
9
10
 
10
11
  from gisserver import conf
11
12
  from gisserver.exceptions import InvalidParameterValue
12
- from gisserver.features import FeatureRelation, FeatureType
13
- from gisserver.geometries import CRS
14
- from gisserver.operations.base import WFSMethod
15
13
 
16
- from .results import FeatureCollection
14
+ if typing.TYPE_CHECKING:
15
+ from gisserver.features import FeatureType
16
+ from gisserver.geometries import CRS
17
+ from gisserver.operations.base import WFSMethod
18
+ from gisserver.queries import FeatureProjection, FeatureRelation, QueryExpression
19
+
20
+ from .results import FeatureCollection, SimpleFeatureCollection
17
21
 
18
22
 
19
23
  class OutputRenderer:
@@ -35,17 +39,18 @@ class OutputRenderer:
35
39
  def __init__(
36
40
  self,
37
41
  method: WFSMethod,
38
- source_query,
42
+ source_query: QueryExpression,
39
43
  collection: FeatureCollection,
40
44
  output_crs: CRS,
41
45
  ):
42
46
  """
43
47
  Receive the collected data to render.
48
+ These parameters are received from the ``get_context_data()`` method of the view.
44
49
 
45
50
  :param method: The calling WFS Method (e.g. GetFeature class)
46
51
  :param source_query: The query that generated this output.
47
- :type source_query: gisserver.queries.QueryExpression
48
- :param collection: The collected data for rendering
52
+ :param collection: The collected data for rendering.
53
+ :param projection: Which fields to render.
49
54
  :param output_crs: The requested output projection.
50
55
  """
51
56
  self.method = method
@@ -66,7 +71,7 @@ class OutputRenderer:
66
71
  cls.validate(sub_collection.feature_type, **params)
67
72
 
68
73
  queryset = cls.decorate_queryset(
69
- sub_collection.feature_type,
74
+ sub_collection.projection,
70
75
  sub_collection.queryset,
71
76
  output_crs,
72
77
  **params,
@@ -84,54 +89,60 @@ class OutputRenderer:
84
89
  and crs not in feature_type.supported_crs
85
90
  ):
86
91
  raise InvalidParameterValue(
87
- "srsName",
88
92
  f"Feature '{feature_type.name}' does not support SRID {crs.srid}.",
93
+ locator="srsName",
89
94
  )
90
95
 
91
96
  @classmethod
92
97
  def decorate_queryset(
93
98
  cls,
94
- feature_type: FeatureType,
99
+ projection: FeatureProjection,
95
100
  queryset: models.QuerySet,
96
101
  output_crs: CRS,
97
102
  **params,
98
103
  ) -> models.QuerySet:
99
- """Apply presentation layer logic to the queryset."""
104
+ """Apply presentation layer logic to the queryset.
105
+
106
+ This allows fine-tuning the queryset for any special needs of the output rendering type.
107
+
108
+ :param feature_type: The feature that is being queried.
109
+ :param queryset: The constructed queryset so far.
110
+ :param output_crs: The projected output
111
+ :param params: All remaining request parameters (e.g. KVP parameters).
112
+ """
100
113
  # Avoid fetching relations, fetch these within the same query,
101
- related = cls._get_prefetch_related(feature_type, output_crs)
114
+ related = cls._get_prefetch_related(projection, output_crs)
102
115
  if related:
103
116
  queryset = queryset.prefetch_related(*related)
104
117
 
105
- # Also limit the queryset to the actual fields that are shown.
106
- # No need to request more data
107
- fields = [
108
- f.orm_field
109
- for f in feature_type.xsd_type.elements
110
- if not f.is_many or f.is_array # exclude M2M, but include ArrayField
111
- ]
112
- return queryset.only("pk", *fields)
118
+ return queryset.only("pk", *projection.only_fields)
113
119
 
114
120
  @classmethod
115
121
  def _get_prefetch_related(
116
- cls, feature_type: FeatureType, output_crs: CRS
122
+ cls,
123
+ projection: FeatureProjection,
124
+ output_crs: CRS,
117
125
  ) -> list[models.Prefetch]:
118
126
  """Summarize which fields read data from relations.
119
127
 
120
128
  This combines the input from flattened and complex fields,
121
129
  in the unlikely case both variations are used in the same feature.
122
130
  """
131
+ # When PROPERTYNAME is used, determine all ORM paths (and levels) that this query will touch.
132
+ # The prefetch will only be applied when its field coincide with this list.
133
+
123
134
  return [
124
135
  models.Prefetch(
125
136
  orm_relation.orm_path,
126
- queryset=cls.get_prefetch_queryset(feature_type, orm_relation, output_crs),
137
+ queryset=cls.get_prefetch_queryset(projection, orm_relation, output_crs),
127
138
  )
128
- for orm_relation in feature_type.orm_relations
139
+ for orm_relation in projection.orm_relations
129
140
  ]
130
141
 
131
142
  @classmethod
132
143
  def get_prefetch_queryset(
133
144
  cls,
134
- feature_type: FeatureType,
145
+ projection: FeatureProjection,
135
146
  feature_relation: FeatureRelation,
136
147
  output_crs: CRS,
137
148
  ) -> models.QuerySet | None:
@@ -140,7 +151,8 @@ class OutputRenderer:
140
151
  if feature_relation.related_model is None:
141
152
  return None
142
153
 
143
- return feature_type.get_related_queryset(feature_relation)
154
+ # This will also apply .only() based on the projection's feature_relation
155
+ return projection.feature_type.get_related_queryset(feature_relation)
144
156
 
145
157
  def get_response(self):
146
158
  """Render the output as streaming response."""
@@ -204,10 +216,14 @@ class OutputRenderer:
204
216
  The actual exception is still raised and logged server-side.
205
217
  """
206
218
  if settings.DEBUG:
207
- return f"<!-- {exception.__class__.__name__}: {exception} -->\n"
219
+ return f"{exception.__class__.__name__}: {exception}"
208
220
  else:
209
- return f"<!-- {exception.__class__.__name__} during rendering! -->\n"
221
+ return f"{exception.__class__.__name__} during rendering!"
210
222
 
211
223
  def render_stream(self):
212
224
  """Implement this in subclasses to implement a custom output format."""
213
225
  raise NotImplementedError()
226
+
227
+ def get_projection(self, sub_collection: SimpleFeatureCollection) -> FeatureProjection:
228
+ """Provide the projection clause for the given sub-collection."""
229
+ return self.source_query.get_projection(sub_collection.feature_type)