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,12 +1,11 @@
1
- """All supported output formats"""
1
+ """The output rendering classes for all response types.
2
2
 
3
- from .results import FeatureCollection, SimpleFeatureCollection # isort: skip (fixes import loops)
4
-
5
- from django.utils.functional import classproperty
3
+ This also includes collection classes for iterating over the Django QuerySet results.
4
+ """
6
5
 
7
- from gisserver import conf
6
+ from .results import FeatureCollection, SimpleFeatureCollection # isort: skip (fixes import loops)
8
7
 
9
- from .base import OutputRenderer
8
+ from .base import CollectionOutputRenderer, OutputRenderer, XmlOutputRenderer
10
9
  from .csv import CSVRenderer, DBCSVRenderer
11
10
  from .geojson import DBGeoJsonRenderer, GeoJsonRenderer
12
11
  from .gml32 import (
@@ -15,10 +14,13 @@ from .gml32 import (
15
14
  GML32Renderer,
16
15
  GML32ValueRenderer,
17
16
  )
18
- from .xmlschema import XMLSchemaRenderer
17
+ from .stored import DescribeStoredQueriesRenderer, ListStoredQueriesRenderer
18
+ from .utils import to_qname
19
+ from .xmlschema import XmlSchemaRenderer
19
20
 
20
21
  __all__ = [
21
22
  "OutputRenderer",
23
+ "CollectionOutputRenderer",
22
24
  "FeatureCollection",
23
25
  "SimpleFeatureCollection",
24
26
  "DBCSVRenderer",
@@ -29,46 +31,9 @@ __all__ = [
29
31
  "GeoJsonRenderer",
30
32
  "GML32Renderer",
31
33
  "GML32ValueRenderer",
32
- "XMLSchemaRenderer",
33
- "geojson_renderer",
34
- "gml32_renderer",
35
- "gml32_value_renderer",
34
+ "XmlOutputRenderer",
35
+ "XmlSchemaRenderer",
36
+ "ListStoredQueriesRenderer",
37
+ "DescribeStoredQueriesRenderer",
38
+ "to_qname",
36
39
  ]
37
-
38
-
39
- def select_renderer(native_renderer_class, db_renderer_class):
40
- """Dynamically select the preferred renderer.
41
- This allows changing the settings within the app.
42
- """
43
-
44
- class SelectRenderer:
45
- """A proxy to the actual rendering class.
46
- Its structure still allows accessing class-properties of the actual class.
47
- """
48
-
49
- @classproperty
50
- def real_class(self):
51
- if conf.GISSERVER_USE_DB_RENDERING:
52
- return db_renderer_class
53
- else:
54
- return native_renderer_class
55
-
56
- def __new__(cls, *args, **kwargs):
57
- # Return the actual class instead
58
- return cls.real_class(*args, **kwargs)
59
-
60
- @classmethod
61
- def decorate_collection(cls, *args, **kwargs):
62
- return cls.real_class.decorate_collection(*args, **kwargs)
63
-
64
- @classproperty
65
- def max_page_size(cls):
66
- return cls.real_class.max_page_size
67
-
68
- return SelectRenderer
69
-
70
-
71
- csv_renderer = select_renderer(CSVRenderer, DBCSVRenderer)
72
- gml32_renderer = select_renderer(GML32Renderer, DBGML32Renderer)
73
- gml32_value_renderer = select_renderer(GML32ValueRenderer, DBGML32ValueRenderer)
74
- geojson_renderer = select_renderer(GeoJsonRenderer, DBGeoJsonRenderer)
gisserver/output/base.py CHANGED
@@ -1,128 +1,222 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import math
4
5
  import typing
6
+ from collections.abc import Iterator
7
+ from io import BytesIO, StringIO
8
+ from itertools import chain
5
9
 
6
10
  from django.conf import settings
7
11
  from django.db import models
8
12
  from django.http import HttpResponse, StreamingHttpResponse
9
- from django.utils.html import escape
13
+ from django.http.response import HttpResponseBase # Django 3.2 import location
10
14
 
11
- from gisserver import conf
12
- from gisserver.exceptions import InvalidParameterValue
15
+ from gisserver.exceptions import wrap_filter_errors
16
+ from gisserver.features import FeatureType
17
+ from gisserver.parsers.values import fix_type_name
18
+ from gisserver.parsers.xml import split_ns
19
+ from gisserver.types import XsdAnyType, XsdNode
20
+
21
+ from .utils import render_xmlns_attributes, to_qname
22
+
23
+ logger = logging.getLogger(__name__)
13
24
 
14
25
  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
26
+ from gisserver.operations.base import WFSOperation
27
+ from gisserver.projection import FeatureProjection, FeatureRelation
19
28
 
20
29
  from .results import FeatureCollection, SimpleFeatureCollection
21
30
 
22
31
 
23
32
  class OutputRenderer:
24
- """Base class to create streaming responses.
33
+ """Base class for rendering content.
34
+
35
+ Note most rendering logic will generally
36
+ need to use :class:`~gisserver.output.XmlOutputRenderer`
37
+ or :class:`~gisserver.output.CollectionOutputRenderer` as their base class
38
+ """
39
+
40
+ #: Default content type for the HTTP response
41
+ content_type = "application/octet-stream"
42
+
43
+ def __init__(self, operation: WFSOperation):
44
+ """Base method for all output rendering."""
45
+ self.operation = operation
46
+
47
+ def get_response(self) -> HttpResponseBase:
48
+ """Render the output as regular or streaming response."""
49
+ stream = self.render_stream()
50
+ if isinstance(stream, (str, bytes, StringIO, BytesIO)):
51
+ # Not a real stream, output anyway as regular HTTP response.
52
+ return HttpResponse(
53
+ content=stream,
54
+ content_type=self.content_type,
55
+ headers=self.get_headers(),
56
+ )
57
+ else:
58
+ # An actual generator.
59
+ # Peek the generator so initial exceptions can still be handled,
60
+ # and get rendered as normal HTTP responses with the proper status.
61
+ try:
62
+ start = next(stream) # peek, so any raised OWSException here is handled by OWSView
63
+ stream = chain([start], self._trap_exceptions(stream))
64
+ except StopIteration:
65
+ pass
66
+
67
+ # Handover to WSGI server (starts streaming when reading the contents)
68
+ return StreamingHttpResponse(
69
+ streaming_content=stream,
70
+ content_type=self.content_type,
71
+ headers=self.get_headers(),
72
+ )
73
+
74
+ def get_headers(self) -> dict[str, str]:
75
+ """Override to define HTTP headers to add."""
76
+ return {}
77
+
78
+ def _trap_exceptions(self, stream):
79
+ """Decorate the generator to show exceptions"""
80
+ try:
81
+ yield from stream
82
+ except Exception as e:
83
+ # Can't return 500 at this point,
84
+ # but can still tell the client what happened.
85
+ yield self.render_exception(e)
86
+ raise
87
+
88
+ def render_exception(self, exception: Exception):
89
+ """Inform the client that the stream processing was interrupted with an exception.
90
+ The exception can be rendered in the format fits with the output.
25
91
 
26
- It receives the collected 'context' data of the WFSMethod.
92
+ Purposefully, not much information is given, so avoid informing clients.
93
+ The actual exception is still raised and logged server-side.
94
+ """
95
+ if settings.DEBUG:
96
+ return f"{exception.__class__.__name__}: {exception}"
97
+ else:
98
+ return f"{exception.__class__.__name__} during rendering!"
99
+
100
+ def render_stream(self):
101
+ """Implement this in subclasses to implement a custom output format.
102
+
103
+ The implementation may return a ``str``/``bytes`` object, which becomes
104
+ a normal ``HttpResponse`` object **OR** return a generator
105
+ that emits chunks. Such generator is wrapped in a ``StreamingHttpResponse``.
106
+ """
107
+ raise NotImplementedError()
108
+
109
+
110
+ class XmlOutputRenderer(OutputRenderer):
111
+ """Base class/mixin for XML-based rendering.
112
+
113
+ This provides the logic to translate XML elements into QName aliases.
27
114
  """
28
115
 
116
+ #: Default content type for the HTTP response
117
+ content_type = "text/xml; charset=utf-8"
118
+
119
+ #: Default extra namespaces to include in the xmlns="..." attributes, and use for to_qname().
120
+ xml_namespaces = {}
121
+
122
+ def __init__(self, operation: WFSOperation):
123
+ """Base method for all output rendering."""
124
+ super().__init__(operation)
125
+ self.app_namespaces = {
126
+ **self.xml_namespaces,
127
+ **operation.view.get_xml_namespaces_to_prefixes(),
128
+ }
129
+
130
+ def render_xmlns_attributes(self):
131
+ """Render XML Namespace declaration attributes"""
132
+ return render_xmlns_attributes(self.app_namespaces)
133
+
134
+ def to_qname(self, xsd_type: XsdNode | XsdAnyType, namespaces=None) -> str:
135
+ """Generate the aliased name for the element or type."""
136
+ return to_qname(xsd_type.namespace, xsd_type.name, namespaces or self.app_namespaces)
137
+
138
+ def feature_to_qname(self, feature_type: FeatureType | str) -> str:
139
+ """Convert the FeatureType name to a QName"""
140
+ if isinstance(feature_type, FeatureType):
141
+ return to_qname(feature_type.xml_namespace, feature_type.name, self.app_namespaces)
142
+ else:
143
+ # e.g. "return_type" for StoredQueryDescription/QueryExpressionText
144
+ type_name = fix_type_name(feature_type, self.operation.view.xml_namespace)
145
+ ns, localname = split_ns(type_name)
146
+ return to_qname(ns, localname, self.app_namespaces)
147
+
148
+
149
+ class CollectionOutputRenderer(OutputRenderer):
150
+ """Base class to create streaming responses."""
151
+
29
152
  #: Allow to override the maximum page size.
30
153
  #: This value can be 'math.inf' to support endless pages by default.
31
154
  max_page_size = None
32
155
 
33
- #: Define the content type for rendering the output
34
- content_type = "application/octet-stream"
35
-
36
156
  #: An optional content-disposition header to output
37
157
  content_disposition = None
38
158
 
39
- def __init__(
40
- self,
41
- method: WFSMethod,
42
- source_query: QueryExpression,
43
- collection: FeatureCollection,
44
- output_crs: CRS,
45
- ):
159
+ def __init__(self, operation: WFSOperation, collection: FeatureCollection):
46
160
  """
47
161
  Receive the collected data to render.
48
- These parameters are received from the ``get_context_data()`` method of the view.
49
162
 
50
- :param method: The calling WFS Method (e.g. GetFeature class)
51
- :param source_query: The query that generated this output.
163
+ :param operation: The calling WFS Operation (e.g. GetFeature class)
52
164
  :param collection: The collected data for rendering.
53
- :param projection: Which fields to render.
54
- :param output_crs: The requested output projection.
55
165
  """
56
- self.method = method
57
- self.source_query = source_query
166
+ super().__init__(operation)
58
167
  self.collection = collection
59
- self.output_crs = output_crs
60
- self.xml_srs_name = escape(str(self.output_crs))
61
-
62
- # Common elements for output rendering:
63
- self.server_url = method.view.server_url
64
- self.app_xml_namespace = method.view.xml_namespace
65
-
66
- @classmethod
67
- def decorate_collection(cls, collection: FeatureCollection, output_crs: CRS, **params):
68
- """Perform presentation-layer logic enhancements on the queryset."""
69
- for sub_collection in collection.results:
70
- # Validate the presentation-level parameters for this feature:
71
- cls.validate(sub_collection.feature_type, **params)
72
-
73
- queryset = cls.decorate_queryset(
74
- sub_collection.projection,
75
- sub_collection.queryset,
76
- output_crs,
77
- **params,
168
+ self.apply_projection()
169
+
170
+ def apply_projection(self):
171
+ """Perform presentation-layer logic enhancements on all results.
172
+ This calls :meth:`decorate_queryset` for
173
+ each :class:`~gisserver.output.SimpleFeatureCollection`.
174
+ """
175
+ for sub_collection in self.collection.results:
176
+ queryset = self.decorate_queryset(
177
+ projection=sub_collection.projection,
178
+ queryset=sub_collection.queryset,
78
179
  )
180
+ if sub_collection._result_cache is not None:
181
+ raise RuntimeError(
182
+ "SimpleFeatureCollection QuerySet was already processed in output rendering."
183
+ )
79
184
  if queryset is not None:
80
185
  sub_collection.queryset = queryset
81
186
 
82
- @classmethod
83
- def validate(cls, feature_type: FeatureType, **params):
84
- """Validate the presentation parameters"""
85
- crs = params["srsName"]
86
- if (
87
- conf.GISSERVER_SUPPORTED_CRS_ONLY
88
- and crs is not None
89
- and crs not in feature_type.supported_crs
90
- ):
91
- raise InvalidParameterValue(
92
- f"Feature '{feature_type.name}' does not support SRID {crs.srid}.",
93
- locator="srsName",
94
- )
95
-
96
- @classmethod
97
187
  def decorate_queryset(
98
- cls,
99
- projection: FeatureProjection,
100
- queryset: models.QuerySet,
101
- output_crs: CRS,
102
- **params,
188
+ self, projection: FeatureProjection, queryset: models.QuerySet
103
189
  ) -> models.QuerySet:
104
190
  """Apply presentation layer logic to the queryset.
105
191
 
106
192
  This allows fine-tuning the queryset for any special needs of the output rendering type.
107
193
 
108
- :param feature_type: The feature that is being queried.
194
+ :param projection: The projection information, including feature that is being rendered.
109
195
  :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
196
  """
197
+ if queryset._result_cache is not None:
198
+ raise RuntimeError(
199
+ "QuerySet was already processed before passing to the output rendering."
200
+ )
201
+
113
202
  # Avoid fetching relations, fetch these within the same query,
114
- related = cls._get_prefetch_related(projection, output_crs)
115
- if related:
116
- queryset = queryset.prefetch_related(*related)
203
+ prefetches = self._get_prefetch_related(projection)
204
+ if prefetches:
205
+ logger.debug(
206
+ "QuerySet for %s prefetches: %r",
207
+ queryset.model._meta.label,
208
+ [p.prefetch_through for p in prefetches],
209
+ )
210
+ queryset = queryset.prefetch_related(*prefetches)
117
211
 
212
+ logger.debug(
213
+ "QuerySet for %s only retrieves: %r",
214
+ queryset.model._meta.label,
215
+ projection.only_fields,
216
+ )
118
217
  return queryset.only("pk", *projection.only_fields)
119
218
 
120
- @classmethod
121
- def _get_prefetch_related(
122
- cls,
123
- projection: FeatureProjection,
124
- output_crs: CRS,
125
- ) -> list[models.Prefetch]:
219
+ def _get_prefetch_related(self, projection: FeatureProjection) -> list[models.Prefetch]:
126
220
  """Summarize which fields read data from relations.
127
221
 
128
222
  This combines the input from flattened and complex fields,
@@ -134,17 +228,13 @@ class OutputRenderer:
134
228
  return [
135
229
  models.Prefetch(
136
230
  orm_relation.orm_path,
137
- queryset=cls.get_prefetch_queryset(projection, orm_relation, output_crs),
231
+ queryset=self.get_prefetch_queryset(projection, orm_relation),
138
232
  )
139
233
  for orm_relation in projection.orm_relations
140
234
  ]
141
235
 
142
- @classmethod
143
236
  def get_prefetch_queryset(
144
- cls,
145
- projection: FeatureProjection,
146
- feature_relation: FeatureRelation,
147
- output_crs: CRS,
237
+ self, projection: FeatureProjection, feature_relation: FeatureRelation
148
238
  ) -> models.QuerySet | None:
149
239
  """Generate a custom queryset that's used to prefetch a relation."""
150
240
  # Multiple elements could be referencing the same model, just take first that is filled in.
@@ -154,76 +244,40 @@ class OutputRenderer:
154
244
  # This will also apply .only() based on the projection's feature_relation
155
245
  return projection.feature_type.get_related_queryset(feature_relation)
156
246
 
157
- def get_response(self):
158
- """Render the output as streaming response."""
159
- stream = self.render_stream()
160
- if isinstance(stream, (str, bytes)):
161
- # Not a real stream, output anyway as regular HTTP response.
162
- response = HttpResponse(content=stream, content_type=self.content_type)
163
- else:
164
- # A actual generator.
165
- stream = self._trap_exceptions(stream)
166
- response = StreamingHttpResponse(
167
- streaming_content=stream,
168
- content_type=self.content_type,
169
- )
170
-
171
- # Add HTTP headers
172
- for name, value in self.get_headers().items():
173
- response[name] = value
174
-
175
- # Handover to WSGI server (starts streaming when reading the contents)
176
- return response
177
-
178
247
  def get_headers(self):
179
248
  """Return the response headers"""
180
249
  if self.content_disposition:
181
250
  # Offer a common quick content-disposition logic that works for all possible queries.
182
- sub_collection = self.collection.results[0]
183
- if sub_collection.stop == math.inf:
184
- page = f"{sub_collection.start}-end" if sub_collection.start else "all"
185
- elif sub_collection.stop:
186
- page = f"{sub_collection.start}-{sub_collection.stop - 1}"
187
- else:
188
- page = "results"
189
-
190
251
  return {
191
252
  "Content-Disposition": self.content_disposition.format(
192
- typenames="+".join(sub.feature_type.name for sub in self.collection.results),
193
- page=page,
194
- date=self.collection.date.strftime("%Y-%m-%d %H.%M.%S%z"),
195
- timestamp=self.collection.timestamp,
253
+ **self.get_content_disposition_kwargs()
196
254
  )
197
255
  }
198
256
 
199
257
  return {}
200
258
 
201
- def _trap_exceptions(self, stream):
202
- """Decorate the generator to show exceptions"""
203
- try:
204
- yield from stream
205
- except Exception as e:
206
- # Can't return 500 at this point,
207
- # but can still tell the client what happened.
208
- yield self.render_exception(e)
209
- raise
210
-
211
- def render_exception(self, exception: Exception):
212
- """Inform the client that the stream processing was interrupted with an exception.
213
- The exception can be rendered in the format fits with the output.
214
-
215
- Purposefully, not much information is given, so avoid informing clients.
216
- The actual exception is still raised and logged server-side.
217
- """
218
- if settings.DEBUG:
219
- return f"{exception.__class__.__name__}: {exception}"
259
+ def get_content_disposition_kwargs(self) -> dict:
260
+ """Offer a common quick content-disposition logic that works for all possible queries."""
261
+ sub_collection = self.collection.results[0]
262
+ if sub_collection.stop == math.inf:
263
+ page = f"{sub_collection.start}-end" if sub_collection.start else "all"
264
+ elif sub_collection.stop:
265
+ page = f"{sub_collection.start}-{sub_collection.stop - 1}"
220
266
  else:
221
- return f"{exception.__class__.__name__} during rendering!"
222
-
223
- def render_stream(self):
224
- """Implement this in subclasses to implement a custom output format."""
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)
267
+ page = "results"
268
+
269
+ return {
270
+ "typenames": "+".join(
271
+ feature_type.name
272
+ for sub in self.collection.results
273
+ for feature_type in sub.feature_types
274
+ ),
275
+ "page": page,
276
+ "date": self.collection.date.strftime("%Y-%m-%d %H.%M.%S%z"),
277
+ "timestamp": self.collection.timestamp,
278
+ }
279
+
280
+ def read_features(self, sub_collection: SimpleFeatureCollection) -> Iterator[models.Model]:
281
+ """A wrapper to read features from a collection, while raising WFS exceptions on query errors."""
282
+ with wrap_filter_errors(sub_collection.source_query):
283
+ yield from sub_collection