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.
- {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/METADATA +34 -8
- django_gisserver-2.1.dist-info/RECORD +68 -0
- {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/compat.py +23 -0
- gisserver/conf.py +7 -0
- gisserver/crs.py +401 -0
- gisserver/db.py +126 -51
- gisserver/exceptions.py +132 -4
- gisserver/extensions/__init__.py +4 -0
- gisserver/{parsers/fes20 → extensions}/functions.py +131 -31
- gisserver/extensions/queries.py +266 -0
- gisserver/features.py +253 -181
- gisserver/geometries.py +64 -311
- gisserver/management/__init__.py +0 -0
- gisserver/management/commands/__init__.py +0 -0
- gisserver/management/commands/loadgeojson.py +311 -0
- gisserver/operations/base.py +130 -312
- gisserver/operations/wfs20.py +399 -375
- gisserver/output/__init__.py +14 -49
- gisserver/output/base.py +198 -144
- gisserver/output/csv.py +78 -75
- gisserver/output/geojson.py +37 -37
- gisserver/output/gml32.py +287 -259
- gisserver/output/iters.py +207 -0
- gisserver/output/results.py +73 -61
- gisserver/output/stored.py +143 -0
- gisserver/output/utils.py +81 -169
- gisserver/output/xmlschema.py +85 -46
- gisserver/parsers/__init__.py +10 -10
- gisserver/parsers/ast.py +426 -0
- gisserver/parsers/fes20/__init__.py +89 -31
- gisserver/parsers/fes20/expressions.py +172 -58
- gisserver/parsers/fes20/filters.py +116 -45
- gisserver/parsers/fes20/identifiers.py +66 -28
- gisserver/parsers/fes20/lookups.py +146 -0
- gisserver/parsers/fes20/operators.py +417 -161
- gisserver/parsers/fes20/sorting.py +113 -34
- gisserver/parsers/gml/__init__.py +17 -25
- gisserver/parsers/gml/base.py +36 -15
- gisserver/parsers/gml/geometries.py +105 -44
- gisserver/parsers/ows/__init__.py +25 -0
- gisserver/parsers/ows/kvp.py +198 -0
- gisserver/parsers/ows/requests.py +160 -0
- gisserver/parsers/query.py +179 -0
- gisserver/parsers/values.py +87 -4
- gisserver/parsers/wfs20/__init__.py +39 -0
- gisserver/parsers/wfs20/adhoc.py +253 -0
- gisserver/parsers/wfs20/base.py +148 -0
- gisserver/parsers/wfs20/projection.py +103 -0
- gisserver/parsers/wfs20/requests.py +483 -0
- gisserver/parsers/wfs20/stored.py +193 -0
- gisserver/parsers/xml.py +261 -0
- gisserver/projection.py +367 -0
- gisserver/static/gisserver/index.css +20 -4
- 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/2.0.0/get_capabilities.xml +9 -9
- gisserver/templates/gisserver/wfs/feature_field.html +3 -3
- gisserver/templates/gisserver/wfs/feature_type.html +35 -13
- gisserver/templatetags/gisserver_tags.py +20 -0
- gisserver/types.py +445 -313
- gisserver/views.py +227 -62
- django_gisserver-1.5.0.dist-info/RECORD +0 -54
- gisserver/parsers/base.py +0 -149
- gisserver/parsers/fes20/query.py +0 -285
- gisserver/parsers/tags.py +0 -102
- gisserver/queries/__init__.py +0 -37
- gisserver/queries/adhoc.py +0 -185
- gisserver/queries/base.py +0 -186
- gisserver/queries/projection.py +0 -240
- gisserver/queries/stored.py +0 -206
- gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
- gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
- {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info/licenses}/LICENSE +0 -0
- {django_gisserver-1.5.0.dist-info → django_gisserver-2.1.dist-info}/top_level.txt +0 -0
gisserver/db.py
CHANGED
|
@@ -2,14 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from functools import lru_cache, reduce
|
|
6
7
|
|
|
7
|
-
from django.contrib.gis.db.models import functions
|
|
8
|
+
from django.contrib.gis.db.models import Extent, PolygonField, functions
|
|
9
|
+
from django.contrib.gis.db.models.fields import ExtentField
|
|
8
10
|
from django.db import connection, connections, models
|
|
9
11
|
|
|
10
12
|
from gisserver import conf
|
|
11
|
-
from gisserver.
|
|
12
|
-
from gisserver.
|
|
13
|
+
from gisserver.crs import CRS, WGS84
|
|
14
|
+
from gisserver.geometries import WGS84BoundingBox
|
|
15
|
+
from gisserver.types import GeometryXsdElement
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
13
18
|
|
|
14
19
|
|
|
15
20
|
class AsEWKT(functions.GeoFunc):
|
|
@@ -26,6 +31,8 @@ class AsEWKT(functions.GeoFunc):
|
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
class AsGML(functions.AsGML):
|
|
34
|
+
"""An overwritten ST_AsGML() function to handle PostGIS extensions."""
|
|
35
|
+
|
|
29
36
|
name = "AsGML"
|
|
30
37
|
|
|
31
38
|
def __init__(
|
|
@@ -34,20 +41,55 @@ class AsGML(functions.AsGML):
|
|
|
34
41
|
version=3,
|
|
35
42
|
precision=conf.GISSERVER_DB_PRECISION,
|
|
36
43
|
envelope=False,
|
|
44
|
+
is_latlon=False,
|
|
37
45
|
**extra,
|
|
38
46
|
):
|
|
39
|
-
# Note that Django's AsGml
|
|
40
|
-
# the options is postgres-only.
|
|
47
|
+
# Note that Django's AsGml the defaults are: version=2, precision=8
|
|
41
48
|
super().__init__(expression, version, precision, **extra)
|
|
42
49
|
self.envelope = envelope
|
|
50
|
+
self.is_latlon = is_latlon
|
|
43
51
|
|
|
44
52
|
def as_postgresql(self, compiler, connection, **extra_context):
|
|
45
53
|
# Fill options parameter (https://postgis.net/docs/ST_AsGML.html)
|
|
46
54
|
options = 33 if self.envelope else 1 # 32 = bbox, 1 = long CRS urn
|
|
55
|
+
if self.is_latlon:
|
|
56
|
+
# PostGIS provides the data in longitude/latitude format (east/north to look like x/y).
|
|
57
|
+
# However, WFS 2.0 fixed their axis by following the authority. The ST_AsGML() doesn't
|
|
58
|
+
# detect this on their own. It needs to be told to flip the coordinates.
|
|
59
|
+
# Passing option 16 flips the coordinates unconditionally, like using ST_FlipCoordinates()
|
|
60
|
+
# but more efficiently done during rendering. That happens in:
|
|
61
|
+
# https://github.com/postgis/postgis/blob/81e2bc783b77cc740291445e992658e1db7179e0/liblwgeom/lwout_gml.c#L121
|
|
62
|
+
options |= 16
|
|
63
|
+
|
|
47
64
|
template = f"%(function)s(%(expressions)s, {options})"
|
|
48
65
|
return self.as_sql(compiler, connection, template=template, **extra_context)
|
|
49
66
|
|
|
50
67
|
|
|
68
|
+
class ST_SetSRID(functions.Transform):
|
|
69
|
+
"""PostGIS function to assign an SRID to geometry.
|
|
70
|
+
When this is applied to the result from an ``Extent`` aggegrate,
|
|
71
|
+
it will convert that ``BBOX(...)`` value into a ``POLYGON(...)``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
name = "SetSRID"
|
|
75
|
+
geom_param_pos = ()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def geo_field(self):
|
|
79
|
+
return PolygonField(srid=self.source_expressions[1].value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Box2D(functions.GeomOutputGeoFunc):
|
|
83
|
+
"""PostGIS function (without ST_) that converts a ``POLYGON(...)`` back to a ``BOX(...)``."""
|
|
84
|
+
|
|
85
|
+
name = "Box2D"
|
|
86
|
+
function = "Box2D" # no ST_ prefix.
|
|
87
|
+
output_field = ExtentField()
|
|
88
|
+
|
|
89
|
+
def convert_value(self, value, expression, connection):
|
|
90
|
+
return connection.ops.convert_extent(value)
|
|
91
|
+
|
|
92
|
+
|
|
51
93
|
class ST_Union(functions.Union):
|
|
52
94
|
name = "Union"
|
|
53
95
|
arity = None
|
|
@@ -59,6 +101,31 @@ class ST_Union(functions.Union):
|
|
|
59
101
|
return self.as_sql(compiler, connection, **extra_context)
|
|
60
102
|
|
|
61
103
|
|
|
104
|
+
def get_wgs84_bounding_box(
|
|
105
|
+
queryset: models.QuerySet, geo_element: GeometryXsdElement
|
|
106
|
+
) -> WGS84BoundingBox:
|
|
107
|
+
"""Calculate the WGS84 bounding box for a feature.
|
|
108
|
+
|
|
109
|
+
Note that the ``<ows:WGS84BoundingBox>`` element
|
|
110
|
+
always uses longitude/latitude, and doesn't describe a CRS.
|
|
111
|
+
"""
|
|
112
|
+
if connections[queryset.db].vendor == "postgresql":
|
|
113
|
+
# Allow a more efficient way to combine geometry first, transform once later
|
|
114
|
+
box = queryset.aggregate(
|
|
115
|
+
box=Box2D(
|
|
116
|
+
functions.Transform(
|
|
117
|
+
ST_SetSRID(Extent(geo_element.orm_path), srid=geo_element.source_srid),
|
|
118
|
+
srid=WGS84.srid,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
)["box"]
|
|
122
|
+
else:
|
|
123
|
+
# Need to transform each element to srid 4326 before combining it in an extent.
|
|
124
|
+
box = queryset.aggregate(box=Extent(get_db_geometry_target(geo_element, WGS84)))["box"]
|
|
125
|
+
|
|
126
|
+
return WGS84BoundingBox(*box) if box else None
|
|
127
|
+
|
|
128
|
+
|
|
62
129
|
def get_geometries_union(
|
|
63
130
|
expressions: list[str | functions.GeoFunc], using="default"
|
|
64
131
|
) -> str | functions.Union:
|
|
@@ -78,63 +145,71 @@ def get_geometries_union(
|
|
|
78
145
|
return reduce(functions.Union, expressions)
|
|
79
146
|
|
|
80
147
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
selects: dict[str, str | functions.Func],
|
|
92
|
-
name_template: str,
|
|
93
|
-
wrapper_func: type[functions.Func],
|
|
94
|
-
) -> dict:
|
|
95
|
-
"""Utility to build annotations for all geometry fields for an XSD type.
|
|
96
|
-
This is used by various DB-optimized rendering methods.
|
|
148
|
+
def replace_queryset_geometries(
|
|
149
|
+
queryset: models.QuerySet,
|
|
150
|
+
geo_elements: list[GeometryXsdElement],
|
|
151
|
+
output_crs: CRS,
|
|
152
|
+
wrapper_func: type[functions.GeoFunc],
|
|
153
|
+
**wrapper_kwargs,
|
|
154
|
+
) -> models.QuerySet:
|
|
155
|
+
"""Replace the queryset geometry retrieval with a database-rendered version.
|
|
156
|
+
|
|
157
|
+
This uses absolute paths in the queryset, but can use relative paths for related querysets.
|
|
97
158
|
"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
159
|
+
defer_names = []
|
|
160
|
+
as_geo_map = {}
|
|
161
|
+
for geo_element in geo_elements:
|
|
162
|
+
if geo_element.source is not None: # excludes GmlBoundedByElement
|
|
163
|
+
defer_names.append(geo_element.local_orm_path)
|
|
164
|
+
annotation_name = _as_annotation_name(geo_element.local_orm_path, wrapper_func)
|
|
165
|
+
as_geo_map[annotation_name] = wrapper_func(
|
|
166
|
+
get_db_geometry_target(geo_element, output_crs, use_relative_path=True),
|
|
167
|
+
**wrapper_kwargs,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not defer_names:
|
|
171
|
+
return queryset
|
|
172
|
+
|
|
173
|
+
logger.debug(
|
|
174
|
+
"DB rendering: QuerySet for %s replacing %r with %r",
|
|
175
|
+
queryset.model._meta.label,
|
|
176
|
+
defer_names,
|
|
177
|
+
list(as_geo_map.keys()),
|
|
178
|
+
)
|
|
179
|
+
return queryset.defer(*defer_names).annotate(**as_geo_map)
|
|
102
180
|
|
|
103
181
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
182
|
+
def get_db_rendered_geometry(
|
|
183
|
+
instance: models.Model, geo_element: GeometryXsdElement, replacement: type[functions.GeoFunc]
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Retrieve the database-rendered geometry.
|
|
186
|
+
This includes formatted EWKT or GML output, rendered by the database.
|
|
187
|
+
"""
|
|
188
|
+
annotation_name = _as_annotation_name(geo_element.local_orm_path, replacement)
|
|
108
189
|
try:
|
|
109
|
-
return getattr(instance,
|
|
190
|
+
return getattr(instance, annotation_name)
|
|
110
191
|
except AttributeError as e:
|
|
192
|
+
prefix = _as_annotation_name("", replacement)
|
|
193
|
+
available = ", ".join(key for key in instance.__dict__ if key.startswith(prefix)) or "none"
|
|
111
194
|
raise AttributeError(
|
|
112
|
-
f" DB annotation {instance._meta.
|
|
113
|
-
f" not found (using {name_template})"
|
|
195
|
+
f" DB annotation {instance._meta.label}.{annotation_name} not found. Found {available}."
|
|
114
196
|
) from e
|
|
115
197
|
|
|
116
198
|
|
|
117
199
|
@lru_cache
|
|
118
|
-
def
|
|
200
|
+
def _as_annotation_name(name: str, func: type[functions.GeoFunc]) -> str:
|
|
119
201
|
"""Escape an XML name to be used as annotation name."""
|
|
120
|
-
return
|
|
202
|
+
return f"_{func.__name__}_{name.replace('.', '_')}"
|
|
121
203
|
|
|
122
204
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
) ->
|
|
126
|
-
"""
|
|
127
|
-
|
|
205
|
+
def get_db_geometry_target(
|
|
206
|
+
geo_element: GeometryXsdElement, output_crs: CRS, use_relative_path: bool = False
|
|
207
|
+
) -> str | functions.Transform:
|
|
208
|
+
"""Translate a GML geometry field into the proper expression for retrieving it from the database.
|
|
209
|
+
The path will be wrapped into a CRS Transform function if needed.
|
|
128
210
|
"""
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def get_db_geometry_target(gml_element: GmlElement, output_crs: CRS) -> str | functions.Transform:
|
|
137
|
-
"""Wrap the selection of a geometry field in a CRS Transform if needed."""
|
|
138
|
-
return conditional_transform(
|
|
139
|
-
gml_element.orm_path, gml_element.source.srid, output_srid=output_crs.srid
|
|
140
|
-
)
|
|
211
|
+
orm_path = geo_element.local_orm_path if use_relative_path else geo_element.orm_path
|
|
212
|
+
if geo_element.source_srid != output_crs.srid:
|
|
213
|
+
return functions.Transform(orm_path, srid=output_crs.srid)
|
|
214
|
+
else:
|
|
215
|
+
return orm_path
|
gisserver/exceptions.py
CHANGED
|
@@ -8,10 +8,126 @@ https://docs.opengeospatial.org/is/09-025r2/09-025r2.html#35
|
|
|
8
8
|
https://docs.opengeospatial.org/is/09-025r2/09-025r2.html#411
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import typing
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
|
|
11
17
|
from django.conf import settings
|
|
18
|
+
from django.core.exceptions import FieldError, ValidationError
|
|
19
|
+
from django.db import InternalError, ProgrammingError
|
|
12
20
|
from django.http import HttpResponse
|
|
13
21
|
from django.utils.html import format_html
|
|
14
22
|
|
|
23
|
+
from gisserver import conf
|
|
24
|
+
|
|
25
|
+
if typing.TYPE_CHECKING:
|
|
26
|
+
from gisserver.parsers import wfs20
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@contextmanager
|
|
32
|
+
def wrap_parser_errors(name: str, locator: str):
|
|
33
|
+
"""Convert the value into a Python format.
|
|
34
|
+
This catches any typical exceptions and transforms them into an OWSException.
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
except ExternalParsingError as e:
|
|
39
|
+
raise OperationParsingFailed(
|
|
40
|
+
f"Unable to parse {name} argument: {e}", locator=locator
|
|
41
|
+
) from None
|
|
42
|
+
except (TypeError, ValueError, NotImplementedError) as e:
|
|
43
|
+
# TypeError/ValueError are raised by most handlers for unexpected data
|
|
44
|
+
# The NotImplementedError can be raised by fes parsing.
|
|
45
|
+
raise InvalidParameterValue(f"Invalid {name} argument: {e}", locator=locator) from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def wrap_filter_errors(query: wfs20.QueryExpression): # noqa:C901
|
|
50
|
+
"""Perform a QuerySet/filter creation operation.
|
|
51
|
+
and trap many parser errors in the making of it."""
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
except ExternalParsingError as e:
|
|
55
|
+
# Bad input data
|
|
56
|
+
_log_filter_error(query, logging.ERROR, e)
|
|
57
|
+
raise OperationParsingFailed(str(e), locator=query.query_locator) from e
|
|
58
|
+
except ExternalValueError as e:
|
|
59
|
+
# Bad input data
|
|
60
|
+
_log_filter_error(query, logging.ERROR, e)
|
|
61
|
+
raise InvalidParameterValue(str(e), locator=query.query_locator) from e
|
|
62
|
+
except ValidationError as e:
|
|
63
|
+
# Bad input data
|
|
64
|
+
_log_filter_error(query, logging.ERROR, e)
|
|
65
|
+
raise OperationParsingFailed(
|
|
66
|
+
"\n".join(map(str, e.messages)),
|
|
67
|
+
locator=query.query_locator,
|
|
68
|
+
) from e
|
|
69
|
+
except FieldError as e:
|
|
70
|
+
# e.g. doing a LIKE on a foreign key, or requesting an unknown field.
|
|
71
|
+
if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
|
|
72
|
+
raise
|
|
73
|
+
_log_filter_error(query, logging.ERROR, e)
|
|
74
|
+
raise InvalidParameterValue(
|
|
75
|
+
"Internal error when processing filter",
|
|
76
|
+
locator=query.query_locator,
|
|
77
|
+
) from e
|
|
78
|
+
except (InternalError, ProgrammingError) as e:
|
|
79
|
+
# e.g. comparing datetime against integer
|
|
80
|
+
if not conf.GISSERVER_WRAP_FILTER_DB_ERRORS:
|
|
81
|
+
raise
|
|
82
|
+
logger.exception("WFS request failed: %s\nQuery: %r", str(e), query)
|
|
83
|
+
msg = str(e)
|
|
84
|
+
locator = "srsName" if "Cannot find SRID" in msg else query.query_locator
|
|
85
|
+
raise InvalidParameterValue(f"Invalid request: {msg}", locator=locator) from e
|
|
86
|
+
except (TypeError, ValueError) as e:
|
|
87
|
+
# TypeError/ValueError could reference a datatype mismatch in an
|
|
88
|
+
# ORM query, but it could also be an internal bug. In most cases,
|
|
89
|
+
# this is already caught by XsdElement.validate_comparison().
|
|
90
|
+
raise
|
|
91
|
+
if _is_orm_error(e):
|
|
92
|
+
if query.query_locator == "STOREDQUERY_ID":
|
|
93
|
+
# This is a fallback, ideally the stored query performs its own validation.
|
|
94
|
+
raise InvalidParameterValue(
|
|
95
|
+
f"Invalid stored query parameter: {e}", locator=query.query_locator
|
|
96
|
+
) from e
|
|
97
|
+
else:
|
|
98
|
+
raise InvalidParameterValue(
|
|
99
|
+
f"Invalid filter query: {e}", locator=query.query_locator
|
|
100
|
+
) from e
|
|
101
|
+
raise
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_orm_error(exception: Exception) -> bool:
|
|
105
|
+
"""Tell whether an exception is caused by the ORM."""
|
|
106
|
+
traceback = exception.__traceback__
|
|
107
|
+
while traceback.tb_next is not None:
|
|
108
|
+
traceback = traceback.tb_next
|
|
109
|
+
if "/django/db/models/query" in traceback.tb_frame.f_code.co_filename:
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _log_filter_error(query, level, exc):
|
|
115
|
+
"""Report a filtering parsing error in the logging"""
|
|
116
|
+
filter = getattr(query, "filter", None) # AdhocQuery only
|
|
117
|
+
fes_xml = filter.source if filter is not None else "(not provided)"
|
|
118
|
+
try:
|
|
119
|
+
sql = exc.__cause__.cursor.query.decode()
|
|
120
|
+
except AttributeError:
|
|
121
|
+
logger.log(level, "WFS query failed: %s\nFilter:\n%s", exc, fes_xml)
|
|
122
|
+
else:
|
|
123
|
+
logger.log(
|
|
124
|
+
level,
|
|
125
|
+
"WFS query failed: %s\nSQL Query: %s\n\nFilter:\n%s",
|
|
126
|
+
exc,
|
|
127
|
+
sql,
|
|
128
|
+
fes_xml,
|
|
129
|
+
)
|
|
130
|
+
|
|
15
131
|
|
|
16
132
|
class ExternalValueError(ValueError):
|
|
17
133
|
"""Raise a ValueError for external input.
|
|
@@ -24,6 +140,14 @@ class ExternalParsingError(ValueError):
|
|
|
24
140
|
"""Raise a ValueError for a parsing problem."""
|
|
25
141
|
|
|
26
142
|
|
|
143
|
+
class XmlElementNotSupported(ExternalParsingError):
|
|
144
|
+
"""Raise a ValueError when an XML tag is not known by the parser at all."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class InvalidXmlElement(ExternalParsingError):
|
|
148
|
+
"""Raise a ValueError when a particular XML tag wasn't expected."""
|
|
149
|
+
|
|
150
|
+
|
|
27
151
|
class OWSException(Exception):
|
|
28
152
|
"""Base class for XML based exceptions in this module."""
|
|
29
153
|
|
|
@@ -38,7 +162,7 @@ class OWSException(Exception):
|
|
|
38
162
|
def __init__(self, text=None, code=None, locator=None, status_code=None):
|
|
39
163
|
text = text or self.text_template.format(code=self.code, locator=locator)
|
|
40
164
|
if (code and len(text) < len(code)) or (locator and len(text) < len(locator)):
|
|
41
|
-
raise ValueError("text/locator arguments are switched")
|
|
165
|
+
raise ValueError(f"text/locator arguments are switched: {text!r}, locator={locator!r}")
|
|
42
166
|
|
|
43
167
|
super().__init__(text)
|
|
44
168
|
self.locator = locator
|
|
@@ -46,15 +170,19 @@ class OWSException(Exception):
|
|
|
46
170
|
self.code = code or self.code or self.__class__.__name__
|
|
47
171
|
self.status_code = status_code or self.status_code
|
|
48
172
|
|
|
49
|
-
def as_response(self):
|
|
173
|
+
def as_response(self) -> HttpResponse:
|
|
174
|
+
"""Return the excetion as HTTP response."""
|
|
175
|
+
logger.debug("Returning HTTP %d for %s: %s", self.status_code, self.code, self.text)
|
|
176
|
+
xml_body = self.as_xml()
|
|
50
177
|
return HttpResponse(
|
|
51
|
-
b'<?xml version="1.0" encoding="UTF-8"?>\n%b' %
|
|
178
|
+
b'<?xml version="1.0" encoding="UTF-8"?>\n%b' % xml_body.encode("utf-8"),
|
|
52
179
|
content_type="text/xml; charset=utf-8",
|
|
53
180
|
status=self.status_code,
|
|
54
181
|
reason=self.reason,
|
|
55
182
|
)
|
|
56
183
|
|
|
57
|
-
def as_xml(self):
|
|
184
|
+
def as_xml(self) -> str:
|
|
185
|
+
"""Serialize the exception to an XML string."""
|
|
58
186
|
return format_html(
|
|
59
187
|
"<ows:ExceptionReport"
|
|
60
188
|
' xmlns:ows="http://www.opengis.net/ows/1.1"'
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
"""Functions to be callable from
|
|
1
|
+
"""Functions to be callable from query filters.
|
|
2
|
+
|
|
3
|
+
By using the :attr:`function_registry`, custom stored functions can be registered in this server.
|
|
4
|
+
These are called by the filter queries using the :class:`~gisserver.parsers.fes20.expressions.Function` element.
|
|
5
|
+
Out of the box, various built-in functions are present.
|
|
6
|
+
|
|
7
|
+
Built-in options are documented in :ref:`functions`.
|
|
8
|
+
|
|
9
|
+
Most of the out-of-the box options are inspired
|
|
10
|
+
by `GeoServer <https://docs.geoserver.org/latest/en/user/filter/function_reference.html>`_.
|
|
11
|
+
Functions which already have a fes-syntax equivalent have been are omitted.
|
|
12
|
+
"""
|
|
2
13
|
|
|
3
14
|
from __future__ import annotations
|
|
4
15
|
|
|
@@ -6,15 +17,16 @@ from collections.abc import Callable
|
|
|
6
17
|
from dataclasses import dataclass
|
|
7
18
|
from typing import Union
|
|
8
19
|
|
|
20
|
+
import django
|
|
9
21
|
from django.contrib.gis.db.models import functions as gis
|
|
10
22
|
from django.db import models
|
|
11
23
|
from django.db.models import functions
|
|
12
|
-
from django.db.models.expressions import Combinable
|
|
24
|
+
from django.db.models.expressions import Combinable, Value
|
|
13
25
|
|
|
14
26
|
from gisserver.exceptions import InvalidParameterValue
|
|
15
27
|
from gisserver.types import XsdTypes
|
|
16
28
|
|
|
17
|
-
__all__ = ["function_registry"]
|
|
29
|
+
__all__ = ["function_registry", "FesFunctionRegistry", "FesFunction"]
|
|
18
30
|
|
|
19
31
|
FesArg = Union[Combinable, models.Q]
|
|
20
32
|
FesFunctionBody = Union[models.Func, Callable[..., models.Func]]
|
|
@@ -22,8 +34,14 @@ FesFunctionBody = Union[models.Func, Callable[..., models.Func]]
|
|
|
22
34
|
|
|
23
35
|
@dataclass(order=True)
|
|
24
36
|
class FesFunction:
|
|
25
|
-
"""
|
|
26
|
-
|
|
37
|
+
"""A registered database function that can be used by ``<fes:Function name="...">``.
|
|
38
|
+
|
|
39
|
+
The :class:`~gisserver.parsers.fes20.expressions.Function` class will resolve
|
|
40
|
+
these registered functions by name, and call :meth:`build_query` to include them
|
|
41
|
+
in the database query. This will actually insert a Django ORM function in the query!
|
|
42
|
+
|
|
43
|
+
This wrapper class also provides the metadata and type descriptions of the function,
|
|
44
|
+
which is exposed in the ``GetCapabilities`` call.
|
|
27
45
|
"""
|
|
28
46
|
|
|
29
47
|
#: Name of the function
|
|
@@ -50,7 +68,7 @@ class FesFunction:
|
|
|
50
68
|
|
|
51
69
|
|
|
52
70
|
class FesFunctionRegistry:
|
|
53
|
-
"""Registry of functions to be callable by
|
|
71
|
+
"""Registry of functions to be callable by ``<fes:Function>``.
|
|
54
72
|
|
|
55
73
|
The registered functions should be capable of running an SQL function.
|
|
56
74
|
"""
|
|
@@ -59,9 +77,11 @@ class FesFunctionRegistry:
|
|
|
59
77
|
self.functions = {}
|
|
60
78
|
|
|
61
79
|
def __bool__(self):
|
|
80
|
+
"""Tell whether there are functions"""
|
|
62
81
|
return bool(self.functions)
|
|
63
82
|
|
|
64
83
|
def __iter__(self):
|
|
84
|
+
"""Iterate over the functions"""
|
|
65
85
|
return iter(sorted(self.functions.values())) # for template rendering
|
|
66
86
|
|
|
67
87
|
def register(
|
|
@@ -102,19 +122,15 @@ class FesFunctionRegistry:
|
|
|
102
122
|
return self.functions[function_name]
|
|
103
123
|
except KeyError:
|
|
104
124
|
raise InvalidParameterValue(
|
|
105
|
-
|
|
125
|
+
f"Unsupported function: {function_name}", locator="filter"
|
|
106
126
|
) from None
|
|
107
127
|
|
|
108
128
|
|
|
129
|
+
#: The function registry
|
|
109
130
|
function_registry = FesFunctionRegistry()
|
|
110
131
|
|
|
111
132
|
|
|
112
133
|
# Register a set of default SQL functions.
|
|
113
|
-
# These are based on GeoServer:
|
|
114
|
-
# https://docs.geoserver.org/latest/en/user/filter/function_reference.html
|
|
115
|
-
# Not implemented:
|
|
116
|
-
# - Aggregates (like Collection_*)
|
|
117
|
-
# - Comparisons (which already has fes variants)
|
|
118
134
|
|
|
119
135
|
# -- strings
|
|
120
136
|
|
|
@@ -125,6 +141,27 @@ function_registry.register(
|
|
|
125
141
|
returns=XsdTypes.string,
|
|
126
142
|
)
|
|
127
143
|
|
|
144
|
+
function_registry.register(
|
|
145
|
+
"strIndexOf",
|
|
146
|
+
lambda string, substring: (functions.StrIndex(string, Value(substring)) - 1),
|
|
147
|
+
arguments={"string": XsdTypes.string, "substring": XsdTypes.string},
|
|
148
|
+
returns=XsdTypes.string,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
function_registry.register(
|
|
152
|
+
"strSubstring",
|
|
153
|
+
lambda string, begin, end: functions.Substr(string, begin + 1, end - begin),
|
|
154
|
+
arguments={"string": XsdTypes.string, "begin": XsdTypes.integer, "end": XsdTypes.integer},
|
|
155
|
+
returns=XsdTypes.string,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
function_registry.register(
|
|
159
|
+
"strSubstringStart",
|
|
160
|
+
lambda string, begin, end: functions.Substr(string, begin + 1),
|
|
161
|
+
arguments={"string": XsdTypes.string, "begin": XsdTypes.integer},
|
|
162
|
+
returns=XsdTypes.string,
|
|
163
|
+
)
|
|
164
|
+
|
|
128
165
|
function_registry.register(
|
|
129
166
|
"strToLowerCase",
|
|
130
167
|
functions.Lower,
|
|
@@ -306,25 +343,25 @@ function_registry.register(
|
|
|
306
343
|
# -- geometric
|
|
307
344
|
|
|
308
345
|
function_registry.register(
|
|
309
|
-
"
|
|
346
|
+
"area",
|
|
310
347
|
gis.Area,
|
|
311
|
-
arguments={"
|
|
348
|
+
arguments={"geom": XsdTypes.gmlAbstractGeometryType},
|
|
312
349
|
returns=XsdTypes.double,
|
|
313
350
|
)
|
|
314
351
|
|
|
315
352
|
function_registry.register(
|
|
316
|
-
"
|
|
353
|
+
"centroid",
|
|
317
354
|
gis.Centroid,
|
|
318
|
-
arguments={"
|
|
355
|
+
arguments={"geom": XsdTypes.gmlAbstractGeometryType},
|
|
319
356
|
returns=XsdTypes.string,
|
|
320
357
|
)
|
|
321
358
|
|
|
322
359
|
function_registry.register(
|
|
323
|
-
"
|
|
360
|
+
"difference",
|
|
324
361
|
gis.Difference,
|
|
325
362
|
arguments={
|
|
326
|
-
"
|
|
327
|
-
"
|
|
363
|
+
"a": XsdTypes.gmlAbstractGeometryType,
|
|
364
|
+
"b": XsdTypes.gmlAbstractGeometryType,
|
|
328
365
|
},
|
|
329
366
|
returns=XsdTypes.string,
|
|
330
367
|
)
|
|
@@ -333,35 +370,98 @@ function_registry.register(
|
|
|
333
370
|
"distance",
|
|
334
371
|
gis.Distance,
|
|
335
372
|
arguments={
|
|
336
|
-
"
|
|
337
|
-
"
|
|
373
|
+
"a": XsdTypes.gmlAbstractGeometryType,
|
|
374
|
+
"b": XsdTypes.gmlAbstractGeometryType,
|
|
338
375
|
},
|
|
339
376
|
returns=XsdTypes.double,
|
|
340
377
|
)
|
|
341
378
|
|
|
342
379
|
function_registry.register(
|
|
343
|
-
"
|
|
380
|
+
"envelope",
|
|
344
381
|
gis.Envelope,
|
|
382
|
+
arguments={"geom": XsdTypes.gmlAbstractGeometryType},
|
|
383
|
+
returns=XsdTypes.gmlAbstractGeometryType, # returns point or polygon
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
function_registry.register(
|
|
387
|
+
"geomLength",
|
|
388
|
+
gis.Length,
|
|
345
389
|
arguments={"geometry": XsdTypes.gmlAbstractGeometryType},
|
|
346
|
-
returns=XsdTypes.
|
|
390
|
+
returns=XsdTypes.float,
|
|
347
391
|
)
|
|
348
392
|
|
|
349
393
|
function_registry.register(
|
|
350
|
-
"
|
|
394
|
+
"intersection",
|
|
351
395
|
gis.Intersection,
|
|
352
396
|
arguments={
|
|
353
|
-
"
|
|
354
|
-
"
|
|
397
|
+
"a": XsdTypes.gmlAbstractGeometryType,
|
|
398
|
+
"b": XsdTypes.gmlAbstractGeometryType,
|
|
355
399
|
},
|
|
356
|
-
returns=XsdTypes.
|
|
400
|
+
returns=XsdTypes.gmlAbstractGeometryType,
|
|
357
401
|
)
|
|
358
402
|
|
|
403
|
+
if django.VERSION >= (4, 2):
|
|
404
|
+
function_registry.register(
|
|
405
|
+
"isEmpty",
|
|
406
|
+
gis.IsEmpty,
|
|
407
|
+
arguments={
|
|
408
|
+
"geom": XsdTypes.gmlAbstractGeometryType,
|
|
409
|
+
},
|
|
410
|
+
returns=XsdTypes.boolean,
|
|
411
|
+
)
|
|
412
|
+
|
|
359
413
|
function_registry.register(
|
|
360
|
-
"
|
|
414
|
+
"isValid",
|
|
415
|
+
gis.IsValid,
|
|
416
|
+
arguments={
|
|
417
|
+
"geom": XsdTypes.gmlAbstractGeometryType,
|
|
418
|
+
},
|
|
419
|
+
returns=XsdTypes.boolean,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
function_registry.register(
|
|
423
|
+
"numGeometries",
|
|
424
|
+
gis.NumGeometries,
|
|
425
|
+
arguments={
|
|
426
|
+
"collection": XsdTypes.gmlAbstractGeometryType,
|
|
427
|
+
},
|
|
428
|
+
returns=XsdTypes.integer,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
function_registry.register(
|
|
432
|
+
"numPoints",
|
|
433
|
+
gis.NumPoints,
|
|
434
|
+
arguments={
|
|
435
|
+
"collection": XsdTypes.gmlAbstractGeometryType,
|
|
436
|
+
},
|
|
437
|
+
returns=XsdTypes.integer,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
function_registry.register(
|
|
441
|
+
"perimeter",
|
|
442
|
+
gis.Perimeter,
|
|
443
|
+
arguments={
|
|
444
|
+
"geom": XsdTypes.gmlAbstractGeometryType,
|
|
445
|
+
},
|
|
446
|
+
returns=XsdTypes.integer,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
function_registry.register(
|
|
450
|
+
"symDifference",
|
|
451
|
+
gis.SymDifference,
|
|
452
|
+
arguments={
|
|
453
|
+
"a": XsdTypes.gmlAbstractGeometryType,
|
|
454
|
+
"b": XsdTypes.gmlAbstractGeometryType,
|
|
455
|
+
},
|
|
456
|
+
returns=XsdTypes.gmlAbstractGeometryType,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
function_registry.register(
|
|
460
|
+
"union",
|
|
361
461
|
gis.Union,
|
|
362
462
|
arguments={
|
|
363
|
-
"
|
|
364
|
-
"
|
|
463
|
+
"a": XsdTypes.gmlAbstractGeometryType,
|
|
464
|
+
"b": XsdTypes.gmlAbstractGeometryType,
|
|
365
465
|
},
|
|
366
|
-
returns=XsdTypes.
|
|
466
|
+
returns=XsdTypes.gmlAbstractGeometryType,
|
|
367
467
|
)
|