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.
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +26 -10
- django_gisserver-2.1.dist-info/RECORD +68 -0
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/crs.py +401 -0
- gisserver/db.py +71 -5
- gisserver/exceptions.py +106 -2
- gisserver/extensions/functions.py +122 -28
- gisserver/extensions/queries.py +15 -10
- gisserver/features.py +44 -36
- gisserver/geometries.py +64 -306
- gisserver/management/commands/loadgeojson.py +41 -21
- gisserver/operations/base.py +11 -7
- gisserver/operations/wfs20.py +31 -93
- gisserver/output/__init__.py +6 -2
- gisserver/output/base.py +28 -13
- gisserver/output/csv.py +18 -6
- gisserver/output/geojson.py +7 -6
- gisserver/output/gml32.py +43 -23
- gisserver/output/results.py +25 -39
- gisserver/output/utils.py +9 -2
- gisserver/parsers/ast.py +171 -65
- gisserver/parsers/fes20/__init__.py +76 -4
- gisserver/parsers/fes20/expressions.py +97 -27
- gisserver/parsers/fes20/filters.py +9 -6
- gisserver/parsers/fes20/identifiers.py +27 -7
- gisserver/parsers/fes20/lookups.py +8 -6
- gisserver/parsers/fes20/operators.py +101 -49
- gisserver/parsers/fes20/sorting.py +14 -6
- gisserver/parsers/gml/__init__.py +10 -19
- gisserver/parsers/gml/base.py +32 -14
- gisserver/parsers/gml/geometries.py +48 -21
- gisserver/parsers/ows/kvp.py +10 -2
- gisserver/parsers/ows/requests.py +6 -4
- gisserver/parsers/query.py +6 -2
- gisserver/parsers/values.py +61 -4
- gisserver/parsers/wfs20/__init__.py +2 -0
- gisserver/parsers/wfs20/adhoc.py +25 -17
- gisserver/parsers/wfs20/base.py +12 -7
- gisserver/parsers/wfs20/projection.py +3 -3
- gisserver/parsers/wfs20/requests.py +1 -0
- gisserver/parsers/wfs20/stored.py +3 -2
- gisserver/parsers/xml.py +12 -0
- gisserver/projection.py +17 -7
- gisserver/static/gisserver/index.css +8 -3
- gisserver/templates/gisserver/base.html +12 -0
- gisserver/templates/gisserver/index.html +9 -15
- gisserver/templates/gisserver/service_description.html +12 -6
- gisserver/templates/gisserver/wfs/feature_field.html +1 -1
- gisserver/templates/gisserver/wfs/feature_type.html +35 -13
- gisserver/types.py +150 -81
- gisserver/views.py +47 -24
- django_gisserver-2.0.dist-info/RECORD +0 -66
- {django_gisserver-2.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
187
|
-
f"</wfs:{self.xml_sub_collection_tag}
|
|
188
|
-
if
|
|
189
|
-
|
|
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
|
-
|
|
375
|
+
envelope = cast(GmlBoundedByElement, geo_element).get_value(
|
|
372
376
|
instance, crs=projection.output_crs
|
|
373
377
|
)
|
|
374
|
-
if
|
|
375
|
-
self._write(self.render_gml_bounds(
|
|
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
|
-
|
|
402
|
-
as :mod:`django.contrib.gis.geos` only provides a
|
|
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.
|
|
406
|
-
<gml:upperCorner>{envelope.
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
|
625
|
-
The GML standard demonstrates to render only their content inside a
|
|
626
|
-
(either plain text or an
|
|
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",
|
|
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
|
gisserver/output/results.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
def attr_escape(s: str):
|
|
30
|
-
|
|
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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
|
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()
|