django-gisserver 2.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 (55) hide show
  1. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +26 -10
  2. django_gisserver-2.1.dist-info/RECORD +68 -0
  3. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/crs.py +401 -0
  6. gisserver/db.py +71 -5
  7. gisserver/exceptions.py +106 -2
  8. gisserver/extensions/functions.py +122 -28
  9. gisserver/extensions/queries.py +15 -10
  10. gisserver/features.py +44 -36
  11. gisserver/geometries.py +64 -306
  12. gisserver/management/commands/loadgeojson.py +41 -21
  13. gisserver/operations/base.py +11 -7
  14. gisserver/operations/wfs20.py +31 -93
  15. gisserver/output/__init__.py +6 -2
  16. gisserver/output/base.py +28 -13
  17. gisserver/output/csv.py +18 -6
  18. gisserver/output/geojson.py +7 -6
  19. gisserver/output/gml32.py +43 -23
  20. gisserver/output/results.py +25 -39
  21. gisserver/output/utils.py +9 -2
  22. gisserver/parsers/ast.py +171 -65
  23. gisserver/parsers/fes20/__init__.py +76 -4
  24. gisserver/parsers/fes20/expressions.py +97 -27
  25. gisserver/parsers/fes20/filters.py +9 -6
  26. gisserver/parsers/fes20/identifiers.py +27 -7
  27. gisserver/parsers/fes20/lookups.py +8 -6
  28. gisserver/parsers/fes20/operators.py +101 -49
  29. gisserver/parsers/fes20/sorting.py +14 -6
  30. gisserver/parsers/gml/__init__.py +10 -19
  31. gisserver/parsers/gml/base.py +32 -14
  32. gisserver/parsers/gml/geometries.py +48 -21
  33. gisserver/parsers/ows/kvp.py +10 -2
  34. gisserver/parsers/ows/requests.py +6 -4
  35. gisserver/parsers/query.py +6 -2
  36. gisserver/parsers/values.py +61 -4
  37. gisserver/parsers/wfs20/__init__.py +2 -0
  38. gisserver/parsers/wfs20/adhoc.py +25 -17
  39. gisserver/parsers/wfs20/base.py +12 -7
  40. gisserver/parsers/wfs20/projection.py +3 -3
  41. gisserver/parsers/wfs20/requests.py +1 -0
  42. gisserver/parsers/wfs20/stored.py +3 -2
  43. gisserver/parsers/xml.py +12 -0
  44. gisserver/projection.py +17 -7
  45. gisserver/static/gisserver/index.css +8 -3
  46. gisserver/templates/gisserver/base.html +12 -0
  47. gisserver/templates/gisserver/index.html +9 -15
  48. gisserver/templates/gisserver/service_description.html +12 -6
  49. gisserver/templates/gisserver/wfs/feature_field.html +1 -1
  50. gisserver/templates/gisserver/wfs/feature_type.html +35 -13
  51. gisserver/types.py +150 -81
  52. gisserver/views.py +47 -24
  53. django_gisserver-2.0.dist-info/RECORD +0 -66
  54. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
  55. {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/top_level.txt +0 -0
gisserver/output/gml32.py CHANGED
@@ -170,7 +170,7 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
170
170
  def render_exception(self, exception: Exception):
171
171
  """Render the exception in a format that fits with the output.
172
172
 
173
- The WSF XSD spec has a hidden gem: an <wfs:truncatedResponse> element
173
+ The WSF XSD spec has a hidden gem: an ``<wfs:truncatedResponse>`` element
174
174
  can be rendered at the end of a feature collection
175
175
  to inform the client an error happened during rendering.
176
176
  """
@@ -182,15 +182,14 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
182
182
  exception = WFSException(message, code=exception.__class__.__name__, status_code=500)
183
183
  exception.debug_hint = False
184
184
 
185
- # Only at the top-level, an exception report can be rendered
186
- closing_child = (
187
- f"</wfs:{self.xml_sub_collection_tag}></wfs:member>\n"
188
- if len(self.collection.results) > 1
189
- else ""
190
- )
185
+ # Only at the top-level, an exception report can be rendered, close any remaining tags.
186
+ if len(self.collection.results) > 1:
187
+ sub_closing = f"</wfs:{self.xml_sub_collection_tag}>\n</wfs:member>\n"
188
+ if not buffer.endswith(sub_closing):
189
+ buffer += sub_closing
190
+
191
191
  return (
192
192
  f"{buffer}"
193
- f"{closing_child}"
194
193
  " <wfs:truncatedResponse>"
195
194
  f"{exception.as_xml()}"
196
195
  " </wfs:truncatedResponse>\n"
@@ -204,9 +203,14 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
204
203
  collection = self.collection
205
204
  self.output = output = StringIO()
206
205
  self._write = self.output.write
206
+
207
+ # The base class peaks the generator and handles early exceptions.
208
+ # Any database exceptions during calculating the number of results
209
+ # are all handled by the main WFS view.
207
210
  number_matched = collection.number_matched
208
211
  number_matched = int(number_matched) if number_matched is not None else "unknown"
209
212
  number_returned = collection.number_returned
213
+
210
214
  next = previous = ""
211
215
  if collection.next:
212
216
  next = f' next="{attr_escape(collection.next)}"'
@@ -238,7 +242,7 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
238
242
  f' numberReturned="{int(sub_collection.number_returned)}">\n'
239
243
  )
240
244
 
241
- for instance in sub_collection:
245
+ for instance in self.read_features(sub_collection):
242
246
  self.gml_seq = 0 # need to increment this between write_xml_field calls
243
247
  self._write("<wfs:member>\n")
244
248
  self.write_feature(projection, instance)
@@ -368,11 +372,11 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
368
372
  if geo_element.type is XsdTypes.gmlBoundingShapeType:
369
373
  # Special case for <gml:boundedBy>, which doesn't need xsd:nil values, nor a gml:id.
370
374
  # The value is not a GEOSGeometry either, as that is not exposed by django.contrib.gis.
371
- value = cast(GmlBoundedByElement, geo_element).get_value(
375
+ envelope = cast(GmlBoundedByElement, geo_element).get_value(
372
376
  instance, crs=projection.output_crs
373
377
  )
374
- if value is not None:
375
- self._write(self.render_gml_bounds(value))
378
+ if envelope is not None:
379
+ self._write(self.render_gml_bounds(envelope))
376
380
  else:
377
381
  # Regular geometry elements.
378
382
  xml_qname = self.xml_qnames[geo_element]
@@ -398,12 +402,12 @@ class GML32Renderer(CollectionOutputRenderer, XmlOutputRenderer):
398
402
 
399
403
  def render_gml_bounds(self, envelope: BoundingBox) -> str:
400
404
  """Render the gml:boundedBy element that contains an Envelope.
401
- Note this uses an internal object type,
402
- as :mod:`django.contrib.gis.geos` only provides a Point/Polygon or 4-tuple envelope.
405
+ This uses an internal object type,
406
+ as :mod:`django.contrib.gis.geos` only provides a 4-tuple envelope.
403
407
  """
404
408
  return f"""<gml:boundedBy><gml:Envelope srsDimension="2" srsName="{attr_escape(envelope.crs.urn)}">
405
- <gml:lowerCorner>{envelope.south} {envelope.west}</gml:lowerCorner>
406
- <gml:upperCorner>{envelope.north} {envelope.east}</gml:upperCorner>
409
+ <gml:lowerCorner>{envelope.min_x} {envelope.min_y}</gml:lowerCorner>
410
+ <gml:upperCorner>{envelope.max_x} {envelope.max_y}</gml:upperCorner>
407
411
  </gml:Envelope></gml:boundedBy>\n"""
408
412
 
409
413
  def render_gml_value(
@@ -550,7 +554,11 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
550
554
  # Only take the geometries of the current level.
551
555
  # The annotations for relations will be handled by prefetches and get_prefetch_queryset()
552
556
  return replace_queryset_geometries(
553
- queryset, projection.geometry_elements, projection.output_crs, AsGML
557
+ queryset,
558
+ projection.geometry_elements,
559
+ projection.output_crs,
560
+ AsGML,
561
+ is_latlon=projection.output_crs.is_north_east_order,
554
562
  )
555
563
 
556
564
  def get_prefetch_queryset(
@@ -565,7 +573,11 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
565
573
 
566
574
  # Find which fields are GML elements
567
575
  return replace_queryset_geometries(
568
- queryset, feature_relation.geometry_elements, projection.output_crs, AsGML
576
+ queryset,
577
+ feature_relation.geometry_elements,
578
+ projection.output_crs,
579
+ AsGML,
580
+ is_latlon=projection.output_crs.is_north_east_order,
569
581
  )
570
582
 
571
583
  def get_db_envelope_as_gml(self, projection: FeatureProjection, queryset) -> AsGML:
@@ -574,7 +586,11 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
574
586
  This also avoids offloads the geometry union calculation to the DB.
575
587
  """
576
588
  geo_fields_union = self._get_geometries_union(projection, queryset)
577
- return AsGML(geo_fields_union, envelope=True)
589
+ return AsGML(
590
+ geo_fields_union,
591
+ envelope=True,
592
+ is_latlon=projection.output_crs.is_north_east_order,
593
+ )
578
594
 
579
595
  def _get_geometries_union(self, projection: FeatureProjection, queryset):
580
596
  """Combine all geometries of the model in a single SQL function."""
@@ -621,9 +637,9 @@ class DBGML32Renderer(DBGMLRenderingMixin, GML32Renderer):
621
637
  class GML32ValueRenderer(GML32Renderer):
622
638
  """Render the GetPropertyValue XML output in GML 3.2 format.
623
639
 
624
- Geoserver seems to generate the element tag inside each <wfs:member> element. We've applied this one.
625
- The GML standard demonstrates to render only their content inside a <wfs:member> element
626
- (either plain text or an <gml:...> tag). Not sure what is right here.
640
+ Geoserver seems to generate the element tag inside each ``<wfs:member>`` element. We've applied this one.
641
+ The GML standard demonstrates to render only their content inside a ``<wfs:member>`` element
642
+ (either plain text or an ``<gml:...>`` tag). Not sure what is right here.
627
643
  """
628
644
 
629
645
  content_type = "text/xml; charset=utf-8"
@@ -735,7 +751,11 @@ class DBGML32ValueRenderer(DBGMLRenderingMixin, GML32ValueRenderer):
735
751
  # Add 'gml_member' to point to the pre-rendered GML version.
736
752
  geo_element = cast(GeometryXsdElement, element)
737
753
  return queryset.values(
738
- "pk", gml_member=AsGML(get_db_geometry_target(geo_element, projection.output_crs))
754
+ "pk",
755
+ gml_member=AsGML(
756
+ get_db_geometry_target(geo_element, projection.output_crs),
757
+ is_latlon=projection.output_crs.is_north_east_order,
758
+ ),
739
759
  )
740
760
  else:
741
761
  return queryset
@@ -7,18 +7,17 @@ properties match the WFS 2.0 spec closely.
7
7
  from __future__ import annotations
8
8
 
9
9
  import math
10
- import operator
11
10
  import typing
12
11
  from collections.abc import Iterable
13
12
  from datetime import timezone
14
- from functools import cached_property, reduce
13
+ from functools import cached_property
15
14
 
16
15
  from django.db import models
17
16
  from django.utils.timezone import now
18
17
 
19
18
  from gisserver import conf
19
+ from gisserver.exceptions import wrap_filter_errors
20
20
  from gisserver.features import FeatureType
21
- from gisserver.geometries import BoundingBox
22
21
 
23
22
  from .iters import ChunkedQuerySetIterator, CountingIterator
24
23
 
@@ -33,7 +32,7 @@ class SimpleFeatureCollection:
33
32
  """Wrapper to read a result set.
34
33
 
35
34
  This object type is defined in the WFS spec.
36
- It holds a collection of "wfs:member" objects.
35
+ It holds a collection of ``<wfs:member>`` objects.
37
36
  """
38
37
 
39
38
  def __init__(
@@ -86,7 +85,8 @@ class SimpleFeatureCollection:
86
85
  """Explicitly request the results to be streamed.
87
86
 
88
87
  This can be used by output formats that stream results, and don't
89
- access `number_returned`. Note this is not compatible with prefetch_related().
88
+ access :attr:`number_returned`.
89
+ Note this is not compatible with ``prefetch_related()``.
90
90
  """
91
91
  if self._result_iterator is not None:
92
92
  raise RuntimeError("Results for feature collection are read twice.")
@@ -126,12 +126,13 @@ class SimpleFeatureCollection:
126
126
  return self.queryset[self.start : self.stop + (1 if add_sentinel else 0)]
127
127
 
128
128
  def first(self):
129
- try:
130
- # Don't query a full page, return only one instance (for GetFeatureById)
131
- # This also preserves the extra added annotations (like _as_gml_FIELD)
132
- return self.queryset[self.start]
133
- except IndexError:
134
- return None
129
+ with wrap_filter_errors(self.source_query):
130
+ try:
131
+ # Don't query a full page, return only one instance (for GetFeatureById)
132
+ # This also preserves the extra added annotations (like _as_gml_FIELD)
133
+ return self.queryset[self.start]
134
+ except IndexError:
135
+ return None
135
136
 
136
137
  def fetch_results(self):
137
138
  """Forcefully read the results early."""
@@ -148,11 +149,15 @@ class SimpleFeatureCollection:
148
149
  if self.stop == math.inf:
149
150
  # Infinite page requested, see if start is still requested
150
151
  qs = self.queryset[self.start :] if self.start else self.queryset.all()
151
- self._result_cache = list(qs)
152
+
153
+ with wrap_filter_errors(self.source_query):
154
+ self._result_cache = list(qs)
152
155
  elif self._use_sentinel_record:
153
156
  # No counting, but instead fetch an extra item as sentinel to see if there are more results.
154
157
  qs = self.queryset[self.start : self.stop + 1]
155
- page_results = list(qs)
158
+
159
+ with wrap_filter_errors(self.source_query):
160
+ page_results = list(qs)
156
161
 
157
162
  # The stop + 1 sentinel allows checking if there is a next page.
158
163
  # This means no COUNT() is needed to detect that.
@@ -167,7 +172,9 @@ class SimpleFeatureCollection:
167
172
  # Fetch exactly the page size, no more is needed.
168
173
  # Will use a COUNT on the total table, so it can be used to see if there are more pages.
169
174
  qs = self.queryset[self.start : self.stop]
170
- self._result_cache = list(qs)
175
+
176
+ with wrap_filter_errors(self.source_query):
177
+ self._result_cache = list(qs)
171
178
 
172
179
  @cached_property
173
180
  def _use_sentinel_record(self) -> bool:
@@ -224,7 +231,8 @@ class SimpleFeatureCollection:
224
231
  qs.query.annotations = clean_annotations
225
232
 
226
233
  # Calculate, cache and return
227
- self._number_matched = qs.count()
234
+ with wrap_filter_errors(self.source_query):
235
+ self._number_matched = qs.count()
228
236
  return self._number_matched
229
237
 
230
238
  @property
@@ -278,28 +286,11 @@ class SimpleFeatureCollection:
278
286
  # but since the projection needs to be calculated once, it's stored here for convenience.
279
287
  return self.source_query.get_projection()
280
288
 
281
- def get_bounding_box(self) -> BoundingBox:
282
- """Determine bounding box of all items."""
283
- self.fetch_results() # Avoid querying results twice
284
-
285
- # Start with an obviously invalid bbox,
286
- # which corrects at the first extend_to_geometry call.
287
- bbox = BoundingBox(math.inf, math.inf, -math.inf, -math.inf)
288
-
289
- # Allow the geometry to exist in a dotted relationship.
290
- for instance in self:
291
- geometry_value = self.projection.get_main_geometry_value(instance)
292
- if geometry_value is None:
293
- continue
294
-
295
- bbox.extend_to_geometry(geometry_value)
296
-
297
- return bbox
298
-
299
289
 
300
290
  class FeatureCollection:
301
- """WFS object that holds the result type for GetFeature.
291
+ """WFS object that holds the result type for ``GetFeature``.
302
292
  This object type is defined in the WFS spec.
293
+ It holds a collection of :class:`SimpleFeatureCollection` results.
303
294
  """
304
295
 
305
296
  def __init__(
@@ -323,11 +314,6 @@ class FeatureCollection:
323
314
  self.date = now()
324
315
  self.timestamp = self.date.astimezone(timezone.utc).isoformat()
325
316
 
326
- def get_bounding_box(self) -> BoundingBox:
327
- """Determine bounding box of all items."""
328
- # Combine the bounding box of all collections
329
- return reduce(operator.add, [c.get_bounding_box() for c in self.results])
330
-
331
317
  @cached_property
332
318
  def number_returned(self) -> int:
333
319
  """Return the total number of returned features"""
gisserver/output/utils.py CHANGED
@@ -23,16 +23,20 @@ __all__ = (
23
23
 
24
24
 
25
25
  def tag_escape(s: str):
26
+ """Escape a value for usage in XML text."""
26
27
  return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
27
28
 
28
29
 
29
30
  def attr_escape(s: str):
30
- # Slightly faster then html.escape() as it doesn't replace single quotes.
31
+ """Escape a value for usage in an XML attribute.
32
+ This is slightly faster than ``html.escape()`` as it doesn't replace single quotes.
33
+ """
31
34
  # Having tried all possible variants, this code still outperforms other forms of escaping.
32
35
  return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
33
36
 
34
37
 
35
38
  def value_to_xml_string(value):
39
+ """Format a Python value for usage in XML text."""
36
40
  # Simple scalar value
37
41
  if isinstance(value, str): # most cases
38
42
  return tag_escape(value)
@@ -47,6 +51,9 @@ def value_to_xml_string(value):
47
51
 
48
52
 
49
53
  def value_to_text(value):
54
+ """Format a Python value for usage in plain text output.
55
+ This doesn't do any XML escaping.
56
+ """
50
57
  # Simple scalar value, no XML escapes
51
58
  if isinstance(value, str): # most cases
52
59
  return value
@@ -59,7 +66,7 @@ def value_to_text(value):
59
66
 
60
67
 
61
68
  def render_xmlns_attributes(xml_namespaces: dict[str, str]):
62
- """Render XML Namespace declaration attributes"""
69
+ """Render XML Namespace declaration attributes, i.e. ``xmlns:prefix="uri"`` for each dict item."""
63
70
  return " ".join(
64
71
  f'xmlns:{prefix}="{xml_namespace}"' if prefix else f'xmlns="{xml_namespace}"'
65
72
  for xml_namespace, prefix in xml_namespaces.items()