django-gisserver 1.5.0__py3-none-any.whl → 2.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 (77) hide show
  1. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +34 -8
  2. django_gisserver-2.1.dist-info/RECORD +68 -0
  3. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.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/crs.py +401 -0
  8. gisserver/db.py +126 -51
  9. gisserver/exceptions.py +132 -4
  10. gisserver/extensions/__init__.py +4 -0
  11. gisserver/{parsers/fes20 → extensions}/functions.py +131 -31
  12. gisserver/extensions/queries.py +266 -0
  13. gisserver/features.py +253 -181
  14. gisserver/geometries.py +64 -311
  15. gisserver/management/__init__.py +0 -0
  16. gisserver/management/commands/__init__.py +0 -0
  17. gisserver/management/commands/loadgeojson.py +311 -0
  18. gisserver/operations/base.py +130 -312
  19. gisserver/operations/wfs20.py +399 -375
  20. gisserver/output/__init__.py +14 -49
  21. gisserver/output/base.py +198 -144
  22. gisserver/output/csv.py +78 -75
  23. gisserver/output/geojson.py +37 -37
  24. gisserver/output/gml32.py +287 -259
  25. gisserver/output/iters.py +207 -0
  26. gisserver/output/results.py +73 -61
  27. gisserver/output/stored.py +143 -0
  28. gisserver/output/utils.py +81 -169
  29. gisserver/output/xmlschema.py +85 -46
  30. gisserver/parsers/__init__.py +10 -10
  31. gisserver/parsers/ast.py +426 -0
  32. gisserver/parsers/fes20/__init__.py +89 -31
  33. gisserver/parsers/fes20/expressions.py +172 -58
  34. gisserver/parsers/fes20/filters.py +116 -45
  35. gisserver/parsers/fes20/identifiers.py +66 -28
  36. gisserver/parsers/fes20/lookups.py +146 -0
  37. gisserver/parsers/fes20/operators.py +417 -161
  38. gisserver/parsers/fes20/sorting.py +113 -34
  39. gisserver/parsers/gml/__init__.py +17 -25
  40. gisserver/parsers/gml/base.py +36 -15
  41. gisserver/parsers/gml/geometries.py +105 -44
  42. gisserver/parsers/ows/__init__.py +25 -0
  43. gisserver/parsers/ows/kvp.py +198 -0
  44. gisserver/parsers/ows/requests.py +160 -0
  45. gisserver/parsers/query.py +179 -0
  46. gisserver/parsers/values.py +87 -4
  47. gisserver/parsers/wfs20/__init__.py +39 -0
  48. gisserver/parsers/wfs20/adhoc.py +253 -0
  49. gisserver/parsers/wfs20/base.py +148 -0
  50. gisserver/parsers/wfs20/projection.py +103 -0
  51. gisserver/parsers/wfs20/requests.py +483 -0
  52. gisserver/parsers/wfs20/stored.py +193 -0
  53. gisserver/parsers/xml.py +261 -0
  54. gisserver/projection.py +367 -0
  55. gisserver/static/gisserver/index.css +20 -4
  56. gisserver/templates/gisserver/base.html +12 -0
  57. gisserver/templates/gisserver/index.html +9 -15
  58. gisserver/templates/gisserver/service_description.html +12 -6
  59. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
  60. gisserver/templates/gisserver/wfs/feature_field.html +3 -3
  61. gisserver/templates/gisserver/wfs/feature_type.html +35 -13
  62. gisserver/templatetags/gisserver_tags.py +20 -0
  63. gisserver/types.py +445 -313
  64. gisserver/views.py +227 -62
  65. django_gisserver-1.5.0.dist-info/RECORD +0 -54
  66. gisserver/parsers/base.py +0 -149
  67. gisserver/parsers/fes20/query.py +0 -285
  68. gisserver/parsers/tags.py +0 -102
  69. gisserver/queries/__init__.py +0 -37
  70. gisserver/queries/adhoc.py +0 -185
  71. gisserver/queries/base.py +0 -186
  72. gisserver/queries/projection.py +0 -240
  73. gisserver/queries/stored.py +0 -206
  74. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  75. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  76. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
  77. {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/top_level.txt +0 -0
@@ -1,107 +1,104 @@
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
 
14
+ from __future__ import annotations
15
+
11
16
  import logging
12
17
  import math
13
18
  import re
19
+ import typing
14
20
  from urllib.parse import urlencode
15
21
 
16
- from django.core.exceptions import FieldError, ValidationError
17
- from django.db import InternalError, ProgrammingError
22
+ from django.core.exceptions import ImproperlyConfigured
23
+ from django.utils.module_loading import import_string
18
24
 
19
- from gisserver import conf, output, queries
25
+ from gisserver import conf, output
20
26
  from gisserver.exceptions import (
21
- ExternalParsingError,
22
- ExternalValueError,
23
27
  InvalidParameterValue,
24
- MissingParameterValue,
25
- OperationParsingFailed,
26
28
  VersionNegotiationFailed,
29
+ wrap_filter_errors,
27
30
  )
28
- from gisserver.geometries import CRS, BoundingBox
29
- from gisserver.parsers import fes20
30
- from gisserver.queries import stored_query_registry
31
-
32
- from .base import (
33
- OutputFormat,
34
- Parameter,
35
- UnsupportedParameter,
36
- WFSMethod,
37
- WFSTypeNamesMethod,
38
- )
31
+ from gisserver.extensions.functions import function_registry
32
+ from gisserver.extensions.queries import stored_query_registry
33
+ from gisserver.features import FeatureType
34
+ from gisserver.output import CollectionOutputRenderer
35
+ from gisserver.parsers import ows, wfs20
36
+
37
+ from .base import OutputFormat, OutputFormatMixin, Parameter, WFSOperation, XmlTemplateMixin
39
38
 
40
39
  logger = logging.getLogger(__name__)
41
40
 
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
+ ]
45
53
 
46
- class GetCapabilities(WFSMethod):
54
+
55
+ class GetCapabilities(XmlTemplateMixin, OutputFormatMixin, WFSOperation):
47
56
  """This operation returns map features, and available operations this WFS server supports."""
48
57
 
49
- output_formats = [
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
- ),
58
- ]
58
+ ows_request: wfs20.GetCapabilities
59
+
59
60
  xml_template_name = "get_capabilities.xml"
60
61
 
61
- def get_parameters(self):
62
- # Not calling super, as this differs slightly (e.g. no output formats)
62
+ def get_output_formats(self):
63
63
  return [
64
- Parameter(
65
- "service",
66
- required=True,
67
- in_capabilities=True,
68
- allowed_values=list(self.view.accept_operations.keys()),
69
- ),
70
- Parameter(
71
- # Version parameter can still be sent,
72
- # but not mandatory for GetCapabilities
73
- "version",
74
- allowed_values=self.view.accept_versions,
75
- ),
76
- Parameter(
77
- "AcceptVersions",
78
- in_capabilities=True,
79
- allowed_values=self.view.accept_versions,
80
- parser=self._parse_accept_versions,
81
- ),
82
- Parameter(
83
- "AcceptFormats",
84
- in_capabilities=True,
85
- allowed_values=self.output_formats,
86
- parser=self._parse_output_format,
87
- default=self.output_formats[0],
64
+ OutputFormat("text/xml", renderer_class=None),
65
+ OutputFormat(
66
+ # for FME (Feature Manipulation Engine)
67
+ "application/gml+xml",
68
+ renderer_class=None,
69
+ version="3.2",
70
+ title="GML",
71
+ in_capabilities=False,
88
72
  ),
89
- ] + self.parameters
73
+ ]
90
74
 
91
- def _parse_accept_versions(self, accept_versions) -> str:
75
+ def get_parameters(self):
76
+ # Not calling super, as this differs slightly (not requiring VERSION)
77
+ return [
78
+ Parameter("service", allowed_values=list(self.view.accept_operations.keys())),
79
+ Parameter("AcceptVersions", allowed_values=self.view.accept_versions),
80
+ Parameter("AcceptFormats", allowed_values=[str(o) for o in self.get_output_formats()]),
81
+ ]
82
+
83
+ def validate_request(self, ows_request: wfs20.GetCapabilities):
84
+ # Validate AcceptVersions
85
+ self._parse_accept_versions(ows_request)
86
+
87
+ # Validate AcceptFormats
88
+ if ows_request.acceptFormats:
89
+ for output_format in ows_request.acceptFormats:
90
+ self.resolve_output_format(output_format, locator="AcceptFormats")
91
+
92
+ def _parse_accept_versions(self, ows_request: wfs20.GetCapabilities) -> str | None:
92
93
  """Special parsing for the ACCEPTVERSIONS parameter."""
93
- if "VERSION" in self.view.KVP:
94
- # Even GetCapabilities can still receive a VERSION argument to fixate it.
95
- raise InvalidParameterValue(
96
- "Can't provide both ACCEPTVERSIONS and VERSION",
97
- locator="AcceptVersions",
98
- )
94
+ if not ows_request.acceptVersions:
95
+ return None
99
96
 
100
- matched_versions = set(accept_versions.split(",")).intersection(self.view.accept_versions)
97
+ matched_versions = set(ows_request.acceptVersions).intersection(self.view.accept_versions)
101
98
  if not matched_versions:
102
99
  allowed = ", ".join(self.view.accept_versions)
103
100
  raise VersionNegotiationFailed(
104
- f"'{accept_versions}' does not contain supported versions, "
101
+ f"'{','.join(ows_request.acceptVersions)}' does not contain supported versions, "
105
102
  f"supported are: {allowed}.",
106
103
  locator="acceptversions",
107
104
  )
@@ -110,269 +107,281 @@ class GetCapabilities(WFSMethod):
110
107
  requested_version = sorted(matched_versions, reverse=True)[0]
111
108
 
112
109
  # Make sure the views+exceptions continue to operate in this version
113
- self.view.set_version(requested_version)
114
-
110
+ self.view.set_version(ows_request.service, requested_version)
115
111
  return requested_version
116
112
 
117
- def __call__(self, **params):
118
- """GetCapabilities only supports XML output"""
119
- context = self.get_context_data(**params)
120
- return self.render_xml(context, **params)
121
-
122
- def get_context_data(self, **params):
123
- view = self.view
124
-
113
+ def get_context_data(self) -> dict:
125
114
  # The 'service' is not read from 'params' to avoid dependency on get_parameters()
126
- service = view.KVP["SERVICE"] # is WFS
127
- service_operations = view.accept_operations[service]
128
- feature_output_formats = service_operations["GetFeature"].output_formats
115
+ service_operations = self.view.accept_operations[self.ows_request.service]
116
+ app_namespaces = self.view.get_xml_namespaces_to_prefixes()
117
+
118
+ get_feature_op_cls = service_operations["GetFeature"]
119
+ feature_output_formats = get_feature_op_cls(self.view, None).get_output_formats()
129
120
 
130
121
  return {
131
- "service_description": view.get_service_description(service),
122
+ "xml_namespaces": app_namespaces,
123
+ "service_description": self.view.get_service_description(self.ows_request.service),
132
124
  "accept_operations": {
133
- name: operation(view) for name, operation in service_operations.items()
125
+ name: operation_class(self.view, None).get_parameters()
126
+ for name, operation_class in service_operations.items()
134
127
  },
135
128
  "service_constraints": self.view.wfs_service_constraints,
136
129
  "filter_capabilities": self.view.wfs_filter_capabilities,
137
- "function_registry": fes20.function_registry,
130
+ "function_registry": function_registry,
138
131
  "accept_versions": self.view.accept_versions,
139
- "feature_types": self.view.get_feature_types(),
132
+ "feature_types": self.view.get_bound_feature_types(),
140
133
  "feature_output_formats": feature_output_formats,
141
134
  "default_max_features": self.view.max_page_size,
142
135
  "BOUNDING_BOX": conf.GISSERVER_CAPABILITIES_BOUNDING_BOX,
143
136
  }
144
137
 
145
138
 
146
- class DescribeFeatureType(WFSTypeNamesMethod):
139
+ class DescribeFeatureType(OutputFormatMixin, WFSOperation):
147
140
  """This returns an XML Schema for the provided objects.
148
141
  Each feature is exposed as an XSD definition with its fields.
149
142
  """
150
143
 
151
- output_formats = [
152
- OutputFormat("XMLSCHEMA", renderer_class=output.XMLSchemaRenderer),
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.
156
- OutputFormat(
157
- "application/gml+xml",
158
- version="3.2",
159
- renderer_class=output.XMLSchemaRenderer,
160
- in_capabilities=False,
161
- ),
162
- # OutputFormat("text/xml", subtype="gml/3.1.1"),
163
- ]
164
-
165
- def get_context_data(self, typeNames, **params):
166
- if self.view.KVP.get("TYPENAMES") == "" or self.view.KVP.get("TYPENAME") == "":
167
- # Using TYPENAMES= does result in an error.
168
- raise MissingParameterValue("Empty TYPENAMES parameter", locator="typeNames")
169
- elif typeNames is None:
170
- # Not given, all types are returned
171
- typeNames = self.all_feature_types
172
-
173
- return {"feature_types": typeNames}
174
-
175
-
176
- class ListStoredQueries(WFSMethod):
177
- """This describes the available queries"""
144
+ ows_request: wfs20.DescribeFeatureType
145
+
146
+ def get_output_formats(self):
147
+ return [
148
+ OutputFormat("XMLSCHEMA", renderer_class=output.XmlSchemaRenderer),
149
+ # At least one version of FME (Feature Manipulation Engine) seems to
150
+ # send a DescribeFeatureType request with this GML as output format.
151
+ # Do what mapserver does and just send it XML Schema.
152
+ # This output format also seems to be used in the WFS 2.0.2 spec!
153
+ OutputFormat(
154
+ "application/gml+xml",
155
+ version="3.2",
156
+ renderer_class=output.XmlSchemaRenderer,
157
+ in_capabilities=False,
158
+ ),
159
+ # OutputFormat("text/xml", subtype="gml/3.1.1"),
160
+ ]
178
161
 
179
- xml_template_name = "list_stored_queries.xml"
162
+ def validate_request(self, ows_request: wfs20.DescribeFeatureType):
163
+ if not ows_request.typeNames:
164
+ self.feature_types = self.view.get_bound_feature_types()
165
+ else:
166
+ self.feature_types = [
167
+ self.resolve_feature_type(type_name) for type_name in ows_request.typeNames
168
+ ]
169
+ self.output_format = self.resolve_output_format(ows_request.outputFormat)
180
170
 
181
- def get_context_data(self, **params):
182
- return {"feature_types": self.view.get_feature_types()}
171
+ def process_request(self, ows_request: wfs20.DescribeFeatureType):
172
+ renderer_class = self.output_format.renderer_class or output.XmlSchemaRenderer
173
+ renderer = renderer_class(self, self.feature_types)
174
+ return renderer.get_response()
183
175
 
184
176
 
185
- class DescribeStoredQueries(WFSMethod):
177
+ class ListStoredQueries(WFSOperation):
186
178
  """This describes the available queries"""
187
179
 
188
- xml_template_name = "describe_stored_queries.xml"
180
+ ows_request: wfs20.ListStoredQueries
189
181
 
190
- parameters = [
191
- Parameter(
192
- "STOREDQUERY_ID",
193
- parser=lambda value: [
194
- stored_query_registry.resolve_query(name) for name in value.split(",")
195
- ],
182
+ def process_request(self, ows_request: ows.BaseOwsRequest):
183
+ renderer = output.ListStoredQueriesRenderer(
184
+ self,
185
+ query_descriptions=stored_query_registry.get_queries(),
196
186
  )
197
- ]
187
+ return renderer.get_response()
198
188
 
199
- def get_context_data(self, **params):
200
- queries = params["STOREDQUERY_ID"] or list(stored_query_registry)
201
- return {
202
- "feature_types": self.view.get_feature_types(),
203
- "stored_queries": [q.meta for q in queries],
204
- }
205
189
 
190
+ class DescribeStoredQueries(WFSOperation):
191
+ """This describes the available queries"""
192
+
193
+ ows_request: wfs20.DescribeStoredQueries
194
+
195
+ def validate_request(self, ows_request: wfs20.DescribeStoredQueries):
196
+ if ows_request.storedQueryId is None:
197
+ self.query_descriptions = stored_query_registry.get_queries()
198
+ else:
199
+ self.query_descriptions = [
200
+ stored_query_registry.resolve_query(query_id)
201
+ for query_id in ows_request.storedQueryId
202
+ ]
203
+
204
+ def process_request(self, ows_request: ows.BaseOwsRequest):
205
+ renderer = output.DescribeStoredQueriesRenderer(self, self.query_descriptions)
206
+ return renderer.get_response()
206
207
 
207
- class BaseWFSGetDataMethod(WFSTypeNamesMethod):
208
+
209
+ class BaseWFSGetDataOperation(OutputFormatMixin, WFSOperation):
208
210
  """Base class for GetFeature / GetPropertyValue"""
209
211
 
210
- parameters = [
211
- # StandardPresentationParameters
212
- Parameter(
213
- "resultType",
214
- in_capabilities=True,
215
- parser=lambda x: x.upper(),
216
- allowed_values=["RESULTS", "HITS"],
217
- default="RESULTS",
218
- ),
219
- Parameter("startIndex", parser=int, default=0),
220
- Parameter("count", alias="maxFeatures", parser=int), # maxFeatures is WFS 1.x
221
- # outputFormat will be added by the base class.
222
- # StandardResolveParameters
223
- Parameter("resolve", allowed_values=["local"], in_capabilities=True),
224
- UnsupportedParameter("resolveDepth"), # subresource settings
225
- UnsupportedParameter("resolveTimeout"),
226
- # StandardInputParameters
227
- Parameter("srsName", parser=CRS.from_string),
228
- # Projection clause parameters
229
- Parameter("propertyName", parser=fes20.parse_property_name),
230
- # AdHoc Query parameters
231
- Parameter("bbox", parser=BoundingBox.from_string),
232
- Parameter(
233
- "filter_language",
234
- default=fes20.Filter.query_language,
235
- allowed_values=[fes20.Filter.query_language],
236
- ),
237
- Parameter("filter", parser=fes20.Filter.from_string),
238
- Parameter("sortBy", parser=fes20.SortBy.from_string),
239
- Parameter("resourceID", parser=fes20.parse_resource_id_kvp),
240
- UnsupportedParameter("aliases"),
241
- queries.StoredQueryParameter(),
242
- ]
243
-
244
- def get_context_data(self, resultType, **params): # noqa: C901
245
- query = self.get_query(**params)
246
- query.check_permissions(self.view.request)
247
-
248
- try:
249
- if resultType == "HITS":
250
- collection = query.get_hits()
251
- elif resultType == "RESULTS":
252
- # Validate StandardPresentationParameters
253
- collection = self.get_paginated_results(query, **params)
254
- else:
255
- raise NotImplementedError()
256
- except ExternalParsingError as e:
257
- # Bad input data
258
- self._log_filter_error(query, logging.ERROR, e)
259
- raise OperationParsingFailed(str(e), locator=self._get_locator(**params)) from e
260
- except ExternalValueError as e:
261
- # Bad input data
262
- self._log_filter_error(query, logging.ERROR, e)
263
- raise InvalidParameterValue(str(e), locator=self._get_locator(**params)) from e
264
- except ValidationError as e:
265
- # Bad input data
266
- self._log_filter_error(query, logging.ERROR, e)
267
- raise OperationParsingFailed(
268
- "\n".join(map(str, e.messages)),
269
- locator=self._get_locator(**params),
270
- ) from e
271
- except FieldError as e:
272
- # e.g. doing a LIKE on a foreign key, or requesting an unknown field.
273
- if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
274
- raise
275
- self._log_filter_error(query, logging.ERROR, e)
276
- raise InvalidParameterValue(
277
- "Internal error when processing filter",
278
- locator=self._get_locator(**params),
279
- ) from e
280
- except (InternalError, ProgrammingError) as e:
281
- # e.g. comparing datetime against integer
282
- if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
283
- raise
284
- logger.exception("WFS request failed: %s\nParams: %r", str(e), params)
285
- msg = str(e)
286
- locator = "srsName" if "Cannot find SRID" in msg else self._get_locator(**params)
287
- raise InvalidParameterValue(f"Invalid request: {msg}", locator=locator) from e
288
- except (TypeError, ValueError) as e:
289
- # TypeError/ValueError could reference a datatype mismatch in an
290
- # ORM query, but it could also be an internal bug. In most cases,
291
- # this is already caught by XsdElement.validate_comparison().
292
- if self._is_orm_error(e):
293
- raise InvalidParameterValue(
294
- f"Invalid filter query: {e}",
295
- locator=self._get_locator(**params),
296
- ) from e
297
- raise
298
-
299
- output_crs = params["srsName"]
300
- if not output_crs and collection.results:
301
- output_crs = collection.results[0].feature_type.crs
212
+ ows_request: wfs20.GetFeature | wfs20.GetPropertyValue
302
213
 
303
- return {
304
- # These become init kwargs for the selected OutputRenderer class:
305
- "source_query": query,
306
- "collection": collection,
307
- "output_crs": output_crs,
308
- }
214
+ if typing.TYPE_CHECKING:
215
+ # Tell the type checker subclasses will only work with CollectionOutputRenderer rendering.
216
+
217
+ def resolve_output_format(
218
+ self, value, locator="outputFormat"
219
+ ) -> OutputFormat[CollectionOutputRenderer]:
220
+ return super().resolve_output_format(value, locator=locator)
221
+
222
+ def get_parameters(self) -> list[Parameter]:
223
+ """Parameters to advertise in the capabilities for this method."""
224
+ return [
225
+ # Advertise parameters in GetCapabilities
226
+ Parameter("resultType", allowed_values=["RESULTS", "HITS"]),
227
+ Parameter("resolve", allowed_values=["local"]),
228
+ ]
229
+
230
+ def validate_request(self, ows_request: wfs20.GetFeature | wfs20.GetPropertyValue):
231
+ """Validate the incoming data before execution."""
232
+ self.output_format = self.resolve_output_format(ows_request.outputFormat)
233
+
234
+ # Resolve the feature types
235
+ # Allow these to skip, e.g. when the query needs to return 0 results (GetFeatureById).
236
+ for query in ows_request.queries:
237
+ type_names = query.get_type_names()
238
+ feature_types = [
239
+ self.resolve_feature_type(
240
+ type_name,
241
+ locator=("resourceId" if query.query_locator == "resourceId" else "typeNames"),
242
+ )
243
+ for type_name in type_names
244
+ ]
245
+ self.bind_query(query, feature_types)
246
+
247
+ # Allow both the view and feature-type to check for access.
248
+ # Before 2.0 only FeatureType classes offered this, which required subclassing
249
+ # FeatureType to access view.request.user. The direct view check avoids that need.
250
+ for feature_type in feature_types:
251
+ self.view.check_permissions(feature_type)
252
+ feature_type.check_permissions(self.view.request)
253
+
254
+ def bind_query(self, query: wfs20.QueryExpression, feature_types: list[FeatureType]):
255
+ """Allow to be overwritten in GetFeatureValue"""
256
+ query.bind(feature_types)
257
+
258
+ def process_request(self, ows_request: wfs20.GetFeature | wfs20.GetPropertyValue):
259
+ """Process the query, and generate the output."""
260
+ # Initialize the collection, which constructs the ORM querysets.
261
+ if ows_request.resultType == wfs20.ResultType.hits:
262
+ collection = self.get_hits()
263
+ elif self.ows_request.resultType == wfs20.ResultType.results:
264
+ collection = self.get_results()
265
+ else:
266
+ raise NotImplementedError()
267
+
268
+ # assert False, str(collection.results[0].queryset.query)
269
+
270
+ # Initialize the renderer.
271
+ # This can also decorate the querysets with projection information,
272
+ # such as converting geometries to the correct CRS or add prefetch_related logic.
273
+ renderer = self.output_format.renderer_class(operation=self, collection=collection)
309
274
 
310
- def get_query(self, **params) -> queries.QueryExpression:
311
- """Create or retrieve the query object that will process this request.
275
+ # Fixing pagination will invoke the query,
276
+ # hence this is done at the end
277
+ self.set_pagination_links(collection)
312
278
 
313
- This can either retrieve the stored procedure (e.g. from a database),
314
- or construct an ad-hoc query from request parameters.
279
+ # Render it!
280
+ return renderer.get_response()
281
+
282
+ def get_hits(self) -> output.FeatureCollection:
283
+ """Handle the resultType=hits query.
284
+ This creates the QuerySet and counts the number of results.
315
285
  """
316
- if params["STOREDQUERY_ID"]:
317
- # The 'StoredQueryParameter' already parses the input into a complete object.
318
- # When it's not provided, the regular Adhoc-query will be created from the KVP request.
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
286
+ start, count = self.get_pagination()
287
+ results = []
288
+ for query in self.ows_request.queries:
289
+ with wrap_filter_errors(query):
290
+ queryset = query.get_queryset()
291
+
292
+ results.append(
293
+ output.SimpleFeatureCollection(
294
+ source_query=query,
295
+ feature_types=query.feature_types,
296
+ queryset=queryset.none(),
297
+ start=start,
298
+ stop=start + count, # yes, count can be passed for hits
299
+ number_matched=queryset.count(),
300
+ )
301
+ )
302
+
303
+ return output.FeatureCollection(
304
+ results=results,
305
+ number_matched=sum(r.number_matched for r in results),
306
+ )
307
+
308
+ def get_results(self) -> output.FeatureCollection:
309
+ """Handle the resultType=results query.
310
+ This creates the queryset, allowing to read over all results.
311
+ """
312
+ start, count = self.get_pagination()
313
+ results = []
314
+ for query in self.ows_request.queries:
315
+ # The querysets are not executed yet, until the output is reading them.
316
+ with wrap_filter_errors(query):
317
+ queryset = query.get_queryset()
318
+
319
+ results.append(
320
+ output.SimpleFeatureCollection(
321
+ source_query=query,
322
+ feature_types=query.feature_types,
323
+ queryset=queryset,
324
+ start=start,
325
+ stop=start + count,
326
+ )
325
327
  )
326
- else:
327
- query = queries.AdhocQuery.from_kvp_request(index=0, **params)
328
- query.bind(all_feature_types=self.all_feature_types_by_name)
329
328
 
330
- return query
329
+ # number_matched is not given here, so some rendering formats can count it instead.
330
+ # For GML it need to be printed at the start, but for GeoJSON it can be rendered
331
+ # as the last bit of the response. That avoids performing an expensive COUNT query.
332
+ return output.FeatureCollection(results=results)
331
333
 
332
- def get_paginated_results(
333
- self, query: queries.QueryExpression, outputFormat, **params
334
- ) -> output.FeatureCollection:
335
- """Handle pagination settings."""
336
- start = max(0, params["startIndex"])
334
+ def get_pagination(self) -> tuple[int, int]:
335
+ """Tell what the requested page size is."""
336
+ start = max(0, self.ows_request.startIndex)
337
337
 
338
338
  # outputFormat.max_page_size can be math.inf to enable endless scrolling.
339
339
  # this only works when the COUNT parameter is not given.
340
- max_page_size = outputFormat.max_page_size or self.view.max_page_size
341
- page_size = min(max_page_size, params["count"] or max_page_size)
342
- stop = start + page_size
343
-
344
- # Perform query
345
- collection = query.get_results(start, count=page_size)
346
-
347
- # Allow presentation-layer to add extra logic.
348
- if outputFormat.renderer_class is not None:
349
- output_crs = params["srsName"]
350
- if not output_crs and collection.results:
351
- output_crs = collection.results[0].feature_type.crs
340
+ max_page_size = self.output_format.max_page_size or self.view.max_page_size
341
+ default_page_size = (
342
+ 0 if self.ows_request.resultType == wfs20.ResultType.hits else max_page_size
343
+ )
344
+ count = min(max_page_size, self.ows_request.count or default_page_size)
345
+ return start, count
352
346
 
353
- outputFormat.renderer_class.decorate_collection(collection, output_crs, **params)
347
+ def set_pagination_links(self, collection: output.FeatureCollection):
348
+ """Assign the pagination links to the collection.
349
+ This happens within the operation logic, as it can access the original GET request.
350
+ """
351
+ if self.ows_request.resultType == wfs20.ResultType.hits and not self.ows_request.count:
352
+ return
354
353
 
354
+ start, count = self.get_pagination()
355
+ stop = start + count
355
356
  if stop != math.inf:
356
357
  if start > 0:
357
358
  collection.previous = self._replace_url_params(
358
- STARTINDEX=max(0, start - page_size),
359
- COUNT=page_size,
359
+ STARTINDEX=max(0, start - count),
360
+ COUNT=count,
360
361
  )
362
+
363
+ # Note that reading collection.has_next will invoke the query!
361
364
  if collection.has_next:
362
365
  # TODO: fix this when returning multiple typeNames:
363
366
  collection.next = self._replace_url_params(
364
- STARTINDEX=start + page_size,
365
- COUNT=page_size,
367
+ STARTINDEX=start + count,
368
+ COUNT=count,
366
369
  )
367
370
 
368
- return collection
369
-
370
- def _replace_url_params(self, **updates) -> str:
371
+ def _replace_url_params(self, **updates) -> str | None:
371
372
  """Replace a query parameter in the URL"""
372
373
  new_params = self.view.request.GET.copy() # preserve lowercase fields too
374
+ if self.view.request.method != "GET":
375
+ # CITE compliance testing wants to see a 'next' link for POST requests too.
376
+ try:
377
+ new_params.update(self.ows_request.as_kvp())
378
+ except NotImplementedError:
379
+ # Various POST requests can't be translated back to KVP
380
+ # mapserver omits the 'next' link in these cases too.
381
+ return None
373
382
 
374
383
  # Replace any lower/mixed case variants of the previous names:
375
- for name in self.view.request.GET:
384
+ for name in new_params:
376
385
  upper = name.upper()
377
386
  if upper in updates:
378
387
  new_params[name] = updates.pop(upper)
@@ -381,112 +390,127 @@ class BaseWFSGetDataMethod(WFSTypeNamesMethod):
381
390
  new_params.update(updates)
382
391
  return f"{self.view.server_url}?{urlencode(new_params)}"
383
392
 
384
- def _is_orm_error(self, exception: Exception):
385
- traceback = exception.__traceback__
386
- while traceback.tb_next is not None:
387
- traceback = traceback.tb_next
388
- if "/django/db/models/query" in traceback.tb_frame.f_code.co_filename:
389
- return True
390
- return False
391
-
392
- def _log_filter_error(self, query, level, exc):
393
- """Report a filtering parsing error in the logging"""
394
- filter = getattr(query, "filter", None) # AdhocQuery only
395
- fes_xml = filter.source if filter is not None else "(not provided)"
396
- try:
397
- sql = exc.__cause__.cursor.query.decode()
398
- except AttributeError:
399
- logger.log(level, "WFS query failed: %s\nFilter:\n%s", exc, fes_xml)
400
- else:
401
- logger.log(
402
- level,
403
- "WFS query failed: %s\nSQL Query: %s\n\nFilter:\n%s",
404
- exc,
405
- sql,
406
- fes_xml,
393
+
394
+ class GetFeature(BaseWFSGetDataOperation):
395
+ """This returns all properties of the feature.
396
+
397
+ Various query parameters allow limiting the data.
398
+ """
399
+
400
+ def get_output_formats(self) -> list[OutputFormat]:
401
+ """Return the default output formats.
402
+ This selects a different rendering depending on the ``GISSERVER_USE_DB_RENDERING`` setting.
403
+ """
404
+ if conf.GISSERVER_GET_FEATURE_OUTPUT_FORMATS:
405
+ return self.get_custom_output_formats(
406
+ conf.GISSERVER_GET_FEATURE_OUTPUT_FORMATS | conf.GISSERVER_EXTRA_OUTPUT_FORMATS
407
407
  )
408
408
 
409
- def _get_locator(self, **params):
410
- """Tell which field is likely causing the query error"""
411
- if params["resourceID"]:
412
- return "resourceId"
413
- elif params["STOREDQUERY_ID"]:
414
- return "STOREDQUERY_ID"
409
+ if conf.GISSERVER_USE_DB_RENDERING:
410
+ csv_renderer = output.DBCSVRenderer
411
+ gml32_renderer = output.DBGML32Renderer
412
+ geojson_renderer = output.DBGeoJsonRenderer
415
413
  else:
416
- return "filter"
414
+ csv_renderer = output.CSVRenderer
415
+ gml32_renderer = output.GML32Renderer
416
+ geojson_renderer = output.GeoJsonRenderer
417
417
 
418
+ return [
419
+ OutputFormat(
420
+ # Needed for cite compliance tests
421
+ "application/gml+xml",
422
+ version="3.2",
423
+ renderer_class=gml32_renderer,
424
+ title="GML",
425
+ ),
426
+ OutputFormat(
427
+ "text/xml",
428
+ subtype="gml/3.2.1",
429
+ renderer_class=gml32_renderer,
430
+ title="GML 3.2.1",
431
+ ),
432
+ # OutputFormat("gml"),
433
+ OutputFormat(
434
+ # identical to mapserver:
435
+ "application/json",
436
+ subtype="geojson",
437
+ charset="utf-8",
438
+ renderer_class=geojson_renderer,
439
+ title="GeoJSON",
440
+ ),
441
+ OutputFormat(
442
+ # Alias needed to make ESRI ArcGIS online accept the WFS.
443
+ # It does not recognize the "subtype" as an alias.
444
+ "geojson",
445
+ renderer_class=geojson_renderer,
446
+ ),
447
+ OutputFormat(
448
+ "text/csv",
449
+ subtype="csv",
450
+ charset="utf-8",
451
+ renderer_class=csv_renderer,
452
+ title="CSV",
453
+ ),
454
+ # OutputFormat("shapezip"),
455
+ # OutputFormat("application/zip"),
456
+ ] + self.get_custom_output_formats(conf.GISSERVER_EXTRA_OUTPUT_FORMATS)
457
+
458
+ def get_custom_output_formats(self, output_formats_setting: dict) -> list[OutputFormat]:
459
+ """Add custom output formats defined in the settings."""
460
+ result = []
461
+ for content_type, format_kwargs in output_formats_setting.items():
462
+ renderer_class = format_kwargs["renderer_class"]
463
+ if isinstance(renderer_class, str):
464
+ format_kwargs["renderer_class"] = import_string(renderer_class)
465
+
466
+ if not issubclass(renderer_class, CollectionOutputRenderer):
467
+ raise ImproperlyConfigured(
468
+ f"The 'renderer_class' of output format {content_type!r},"
469
+ f" should be a subclass of CollectionOutputRenderer."
470
+ )
418
471
 
419
- class GetFeature(BaseWFSGetDataMethod):
420
- """This returns all properties of the feature.
472
+ result.append(OutputFormat(content_type, **format_kwargs))
473
+
474
+ return result
421
475
 
422
- Various query parameters allow limiting the data.
423
- """
424
476
 
425
- output_formats = [
426
- OutputFormat(
427
- # Needed for cite compliance tests
428
- "application/gml+xml",
429
- version="3.2",
430
- renderer_class=output.gml32_renderer,
431
- title="GML",
432
- ),
433
- OutputFormat(
434
- "text/xml",
435
- subtype="gml/3.2.1",
436
- renderer_class=output.gml32_renderer,
437
- title="GML 3.2.1",
438
- ),
439
- # OutputFormat("gml"),
440
- OutputFormat(
441
- # identical to mapserver:
442
- "application/json",
443
- subtype="geojson",
444
- charset="utf-8",
445
- renderer_class=output.geojson_renderer,
446
- title="GeoJSON",
447
- ),
448
- OutputFormat(
449
- # Alias needed to make ESRI ArcGIS online accept the WFS.
450
- # It does not recognize the "subtype" as an alias.
451
- "geojson",
452
- renderer_class=output.geojson_renderer,
453
- ),
454
- OutputFormat(
455
- "text/csv",
456
- subtype="csv",
457
- charset="utf-8",
458
- renderer_class=output.csv_renderer,
459
- title="CSV",
460
- ),
461
- # OutputFormat("shapezip"),
462
- # OutputFormat("application/zip"),
463
- ]
464
-
465
-
466
- class GetPropertyValue(BaseWFSGetDataMethod):
477
+ class GetPropertyValue(BaseWFSGetDataOperation):
467
478
  """This returns a limited set of properties of the feature.
468
479
  It works almost identical to GetFeature, except that it returns a single field.
469
480
  """
470
481
 
471
- output_formats = [
472
- OutputFormat(
473
- "application/gml+xml",
474
- version="3.2",
475
- renderer_class=output.gml32_value_renderer,
476
- title="GML",
477
- ),
478
- OutputFormat("text/xml", subtype="gml/3.2", renderer_class=output.gml32_value_renderer),
479
- ]
480
-
481
- parameters = BaseWFSGetDataMethod.parameters + [
482
- Parameter("valueReference", required=True, parser=fes20.ValueReference)
483
- ]
484
-
485
- def get_context_data(self, **params):
486
- # Pass valueReference to output format.
487
- # NOTE: The AdhocQuery object also performs internal processing,
488
- # so the query performs a "SELECT id, <fieldname>" as well.
482
+ ows_request: wfs20.GetPropertyValue
483
+
484
+ def get_output_formats(self) -> list[OutputFormat]:
485
+ """Define the output format for GetPropertyValue."""
486
+ gml32_value_renderer = (
487
+ output.DBGML32ValueRenderer
488
+ if conf.GISSERVER_USE_DB_RENDERING
489
+ else output.GML32ValueRenderer
490
+ )
491
+
492
+ return [
493
+ OutputFormat(
494
+ "application/gml+xml",
495
+ version="3.2",
496
+ renderer_class=gml32_value_renderer,
497
+ title="GML",
498
+ ),
499
+ OutputFormat("text/xml", subtype="gml/3.2", renderer_class=gml32_value_renderer),
500
+ ]
501
+
502
+ def validate_request(self, ows_request: wfs20.GetPropertyValue):
503
+ super().validate_request(ows_request)
504
+
505
+ if ows_request.resolvePath:
506
+ raise InvalidParameterValue(
507
+ "Support for resolvePath is not implemented!", locator="resolvePath"
508
+ )
509
+
510
+ def bind_query(self, query: wfs20.QueryExpression, feature_types: list[FeatureType]):
511
+ """Allow to be overwritten in GetFeatureValue"""
512
+ # Pass valueReference to the query, which will include it in the FeatureProjection.
489
513
  # In the WFS-spec, the valueReference is only a presentation layer change.
490
- context = super().get_context_data(**params)
491
- context["value_reference"] = params["valueReference"]
492
- return context
514
+ # However, in our case AdhocQuery object also performs internal processing,
515
+ # so the query performs a "SELECT id, <fieldname>" as well.
516
+ query.bind(feature_types, value_reference=self.ows_request.valueReference)