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

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