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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Parsing the Key-Value-Pair (KVP) request format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import copy
|
|
6
|
+
|
|
7
|
+
from gisserver.exceptions import (
|
|
8
|
+
InvalidParameterValue,
|
|
9
|
+
MissingParameterValue,
|
|
10
|
+
OperationParsingFailed,
|
|
11
|
+
wrap_parser_errors,
|
|
12
|
+
)
|
|
13
|
+
from gisserver.parsers.xml import parse_qname, xmlns
|
|
14
|
+
|
|
15
|
+
REQUIRED = ... # sentinel value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KVPRequest:
|
|
19
|
+
"""The Key-Value-Pair (KVP) request format.
|
|
20
|
+
|
|
21
|
+
This handles parameters from the HTTP GET request.
|
|
22
|
+
It includes notation format support for certain parameters,
|
|
23
|
+
such as comma-separated lists, and parenthesis-grouping notations.
|
|
24
|
+
|
|
25
|
+
Some basic validation is performed, allowing to convert the data into Python types.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, query_string: dict[str, str], ns_aliases: dict[str, str] | None = None):
|
|
29
|
+
# The parameters are case-insensitive.
|
|
30
|
+
self.params = {name.upper(): value for name, value in query_string.items()}
|
|
31
|
+
|
|
32
|
+
# Make sure most common namespaces are known for resolving them.
|
|
33
|
+
self.ns_aliases = {
|
|
34
|
+
**xmlns.as_ns_aliases(), # some defaults in case these are missing in the request
|
|
35
|
+
**(ns_aliases or {}), # our local application namespaces
|
|
36
|
+
**parse_kvp_namespaces(self.params.get("NAMESPACES")), # extra in the request
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
def __contains__(self, name: str) -> bool:
|
|
40
|
+
"""Tell whether a parameter is present."""
|
|
41
|
+
return name.upper() in self.params
|
|
42
|
+
|
|
43
|
+
def get_custom(self, name: str, *, alias: str | None = None, default=REQUIRED, parser=None):
|
|
44
|
+
"""Retrieve a value by name.
|
|
45
|
+
This performs basic validation, similar to what the XML parsing does.
|
|
46
|
+
|
|
47
|
+
Any parsing errors or validation checks are raised as WFS Exceptions,
|
|
48
|
+
meaning the client will get the appropriate response.
|
|
49
|
+
|
|
50
|
+
:param name: The name of the parameter, typically given in its XML notation format (camelCase).
|
|
51
|
+
:param alias: An older WFS 1 to try for compatibility (e.g. TYPENAMES/TYPENAME, COUNT/MAXFEATURES)
|
|
52
|
+
:param default: The default value to return. If not provided, the parameter is required.
|
|
53
|
+
:param parser: A custom Python function or type to convert the value with.
|
|
54
|
+
"""
|
|
55
|
+
kvp_name = name.upper()
|
|
56
|
+
value = self.params.get(kvp_name)
|
|
57
|
+
if not value and alias:
|
|
58
|
+
value = self.params.get(alias.upper())
|
|
59
|
+
|
|
60
|
+
# Check required field settings, both empty and missing value are treated the same.
|
|
61
|
+
if not value:
|
|
62
|
+
if default is REQUIRED:
|
|
63
|
+
if value is None:
|
|
64
|
+
raise MissingParameterValue(
|
|
65
|
+
f"Missing required '{name}' parameter.", locator=name
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
raise InvalidParameterValue(f"Empty '{kvp_name}' parameter", locator=name)
|
|
69
|
+
return default
|
|
70
|
+
|
|
71
|
+
# Allow conversion into a python object
|
|
72
|
+
if parser is not None:
|
|
73
|
+
with wrap_parser_errors(kvp_name, locator=name):
|
|
74
|
+
return parser(value)
|
|
75
|
+
else:
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
def get_str(
|
|
79
|
+
self, name: str, *, alias: str | None = None, default: str | None = REQUIRED
|
|
80
|
+
) -> str | None:
|
|
81
|
+
"""Retrieve a string value from the request."""
|
|
82
|
+
return self.get_custom(name, alias=alias, default=default)
|
|
83
|
+
|
|
84
|
+
def get_int(
|
|
85
|
+
self, name: str, *, alias: str | None = None, default: int | None = REQUIRED
|
|
86
|
+
) -> int | None:
|
|
87
|
+
"""Retrieve an integer value from the request."""
|
|
88
|
+
return self.get_custom(name, alias=alias, default=default, parser=int)
|
|
89
|
+
|
|
90
|
+
def get_list(
|
|
91
|
+
self, name, *, alias: str | None = None, default: list | None = REQUIRED
|
|
92
|
+
) -> list[str] | None:
|
|
93
|
+
"""Retrieve a comma-separated list value from the request."""
|
|
94
|
+
return self.get_custom(
|
|
95
|
+
name,
|
|
96
|
+
alias=alias,
|
|
97
|
+
default=default,
|
|
98
|
+
parser=lambda x: x.split(","),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def parse_qname(self, value) -> str:
|
|
102
|
+
"""Convert the value to an XML fully qualified name."""
|
|
103
|
+
return parse_qname(value, self.ns_aliases)
|
|
104
|
+
|
|
105
|
+
def split_parameter_lists(self) -> list[KVPRequest]:
|
|
106
|
+
"""Split the parameter lists into individual requests.
|
|
107
|
+
|
|
108
|
+
This translates a request such as:
|
|
109
|
+
|
|
110
|
+
.. code-block:: urlencoded
|
|
111
|
+
|
|
112
|
+
TYPENAMES=(ns1:F1,ns2:F2)(ns1:F1,ns1:F1)
|
|
113
|
+
&ALIASES=(A,B)(C,D)
|
|
114
|
+
&FILTER=(<Filter>… for A,B …</Filter>)(<Filter>…for C,D…</Filter>)
|
|
115
|
+
|
|
116
|
+
into separate pairs:
|
|
117
|
+
|
|
118
|
+
.. code-block:: urlencoded
|
|
119
|
+
|
|
120
|
+
TYPENAMES=ns1:F1,ns2:F2&ALIASES=A,B&FILTER=<Filter>…for A,B…</Filter>
|
|
121
|
+
TYPENAMES=ns1:F1,ns1:F1&ALIASES=C,D&FILTER=<Filter>…for C,D…</Filter>
|
|
122
|
+
|
|
123
|
+
It's both possible have some query parameters split and some shared.
|
|
124
|
+
For example to have two different bounding boxes:
|
|
125
|
+
|
|
126
|
+
.. code-block:: urlencoded
|
|
127
|
+
|
|
128
|
+
TYPENAMES=(INWATER_1M)(BuiltUpA_1M)&BBOX=(40.9821,...)(40.5874,...)
|
|
129
|
+
|
|
130
|
+
or have a single bounding box for both queries:
|
|
131
|
+
|
|
132
|
+
.. code-block:: urlencoded
|
|
133
|
+
|
|
134
|
+
TYPENAMES=(INWATER_1M)(BuiltUpA_1M)&BBOX=40.9821,23.4948,41.0257,23.5525
|
|
135
|
+
"""
|
|
136
|
+
pairs = {
|
|
137
|
+
name: value[1:-1].split(")(")
|
|
138
|
+
for name, value in self.params.items()
|
|
139
|
+
if value.startswith("(") and value.endswith(")")
|
|
140
|
+
}
|
|
141
|
+
if not pairs:
|
|
142
|
+
return [self]
|
|
143
|
+
|
|
144
|
+
pair_sizes = {len(value) for value in pairs.values()}
|
|
145
|
+
if len(pair_sizes) > 1:
|
|
146
|
+
keys = sorted(pairs)
|
|
147
|
+
raise OperationParsingFailed(
|
|
148
|
+
f"Inconsistent pairs between: {', '.join(keys)}", locator=keys[0]
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Produce variations of the same request object
|
|
152
|
+
pair_size = next(iter(pair_sizes))
|
|
153
|
+
variants = []
|
|
154
|
+
for i in range(pair_size):
|
|
155
|
+
updates = {key: value[i] for key, value in pairs.items()}
|
|
156
|
+
variant = copy(self)
|
|
157
|
+
variant.params = {**self.params, **updates}
|
|
158
|
+
variants.append(variant)
|
|
159
|
+
return variants
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_kvp_namespaces(value) -> dict[str, str]:
|
|
163
|
+
"""Parse the 'NAMESPACES' parameter format to lookups.
|
|
164
|
+
|
|
165
|
+
The NAMESPACES parameter defines which namespaces are used in the KVP request.
|
|
166
|
+
When this parameter is not given, the default namespaces are assumed.
|
|
167
|
+
"""
|
|
168
|
+
if not value:
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
# example single value: xmlns(http://example.org)
|
|
172
|
+
# or: NAMESPACES=xmlns(xml,http://www.w3.org/...),xmlns(wfs,http://www.opengis.net/...)
|
|
173
|
+
tokens = value.split(",")
|
|
174
|
+
|
|
175
|
+
namespaces = {}
|
|
176
|
+
tokens = iter(tokens)
|
|
177
|
+
for prefix in tokens:
|
|
178
|
+
if not prefix.startswith("xmlns("):
|
|
179
|
+
raise InvalidParameterValue(
|
|
180
|
+
f"Expected xmlns(...) format: {value}", locator="namespaces"
|
|
181
|
+
)
|
|
182
|
+
if prefix.endswith(")"):
|
|
183
|
+
# xmlns(http://...)
|
|
184
|
+
uri = prefix[6:-1]
|
|
185
|
+
prefix = ""
|
|
186
|
+
else:
|
|
187
|
+
uri = next(tokens, "")
|
|
188
|
+
if not uri.endswith(")"):
|
|
189
|
+
raise InvalidParameterValue(
|
|
190
|
+
f"Expected xmlns(prefix,uri) format: {value}",
|
|
191
|
+
locator="namespaces",
|
|
192
|
+
)
|
|
193
|
+
prefix = prefix[6:]
|
|
194
|
+
uri = uri[:-1]
|
|
195
|
+
|
|
196
|
+
namespaces[prefix] = uri
|
|
197
|
+
|
|
198
|
+
return namespaces
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Request parsing for the common Generic Open Web Services (OWS) protocol bits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
from django.http import QueryDict
|
|
9
|
+
|
|
10
|
+
from gisserver.exceptions import (
|
|
11
|
+
ExternalParsingError,
|
|
12
|
+
InvalidParameterValue,
|
|
13
|
+
OperationNotSupported,
|
|
14
|
+
OperationParsingFailed,
|
|
15
|
+
)
|
|
16
|
+
from gisserver.parsers.ast import AstNode, tag_registry
|
|
17
|
+
from gisserver.parsers.xml import NSElement, parse_xml_from_string, split_ns, xmlns
|
|
18
|
+
|
|
19
|
+
from .kvp import KVPRequest
|
|
20
|
+
|
|
21
|
+
__all__ = (
|
|
22
|
+
"BaseOwsRequest",
|
|
23
|
+
"resolve_kvp_parser_class",
|
|
24
|
+
"resolve_xml_parser_class",
|
|
25
|
+
"parse_get_request",
|
|
26
|
+
"parse_post_request",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class BaseOwsRequest(AstNode):
|
|
32
|
+
"""Base request data for all request types of the OWS standards.
|
|
33
|
+
This mirrors the ``<wfs:BaseRequestType>`` element from the WFS spec.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# dataclass limitation: when defaults are added, subclasses can't have required parameters anymore.
|
|
37
|
+
service: str
|
|
38
|
+
version: str | None # none for GetCapabilities only
|
|
39
|
+
handle: str | None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_xml(cls, element: NSElement):
|
|
43
|
+
"""Initialize from an XML POST request."""
|
|
44
|
+
return cls(**cls.base_xml_init_parameters(element))
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_kvp_request(cls, kvp: KVPRequest):
|
|
48
|
+
"""Initialize from an KVP GET request."""
|
|
49
|
+
return cls(**cls.base_kvp_init_parameters(kvp))
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def base_xml_init_parameters(cls, element: NSElement) -> dict:
|
|
53
|
+
"""Parse the base attributes.
|
|
54
|
+
This parses the syntax such as::
|
|
55
|
+
|
|
56
|
+
<wfs:BaseRequest service="WFS" version="2.0.0" handle="...">
|
|
57
|
+
|
|
58
|
+
"""
|
|
59
|
+
return dict(
|
|
60
|
+
service=element.get_str_attribute("service"),
|
|
61
|
+
version=element.get_str_attribute("version"),
|
|
62
|
+
handle=element.attrib.get("handle"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def base_kvp_init_parameters(cls, kvp: KVPRequest) -> dict:
|
|
67
|
+
"""Parse the common Key-Value-Pair format (GET request parameters).
|
|
68
|
+
This parses the syntax::
|
|
69
|
+
|
|
70
|
+
?SERVICE=WFS&VERSION=2.0.0
|
|
71
|
+
"""
|
|
72
|
+
return dict(
|
|
73
|
+
service=kvp.get_str("SERVICE"),
|
|
74
|
+
version=kvp.get_str("VERSION"),
|
|
75
|
+
handle=None,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def as_kvp(self) -> dict:
|
|
79
|
+
"""Translate the POST request into KVP GET parameters. This is needed for pagination."""
|
|
80
|
+
return {
|
|
81
|
+
"SERVICE": self.service,
|
|
82
|
+
"VERSION": str(self.version),
|
|
83
|
+
"REQUEST": split_ns(self.xml_name)[1],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@lru_cache
|
|
88
|
+
def _get_kvp_parsers() -> dict[str, dict[str, type[BaseOwsRequest]]]:
|
|
89
|
+
"""Find which classes are registered."""
|
|
90
|
+
# This late initialization keeps the ability open in the future
|
|
91
|
+
# to handle request types that are registered in third party code.
|
|
92
|
+
ows_parsers = {}
|
|
93
|
+
|
|
94
|
+
for xml_name, parser_class in tag_registry.find_subclasses(BaseOwsRequest).items():
|
|
95
|
+
namespace, local_name = split_ns(xml_name)
|
|
96
|
+
ows_parsers.setdefault(namespace, {})[local_name.upper()] = parser_class
|
|
97
|
+
|
|
98
|
+
return ows_parsers
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def resolve_kvp_parser_class(kvp: KVPRequest) -> type[BaseOwsRequest]:
|
|
102
|
+
"""Find the appropriate class to parse the KVP GET request data."""
|
|
103
|
+
service = kvp.get_str("service").upper()
|
|
104
|
+
request = kvp.get_str("request")
|
|
105
|
+
|
|
106
|
+
# Find the appropriate request object
|
|
107
|
+
service_types = _get_kvp_parsers()
|
|
108
|
+
try:
|
|
109
|
+
# Translating the GET parameter to a namespace makes connecting to the parser registration.
|
|
110
|
+
# This doesn't take the VERSION/ACCEPTVERSIONS into account yet.
|
|
111
|
+
namespace = xmlns[service.lower()].value
|
|
112
|
+
request_classes = service_types[namespace]
|
|
113
|
+
except KeyError:
|
|
114
|
+
raise InvalidParameterValue(
|
|
115
|
+
f"Unsupported service type: {service}.", locator="service"
|
|
116
|
+
) from None
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
request_cls = request_classes[request.upper()]
|
|
120
|
+
except KeyError:
|
|
121
|
+
allowed = ", ".join(
|
|
122
|
+
xml_tag for node in request_classes.values() for xml_tag in node.get_tag_names()
|
|
123
|
+
)
|
|
124
|
+
raise OperationNotSupported(
|
|
125
|
+
f"'{request}' is not implemented, supported are: {allowed}.",
|
|
126
|
+
locator="request",
|
|
127
|
+
) from None
|
|
128
|
+
|
|
129
|
+
return request_cls
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def resolve_xml_parser_class(root: NSElement) -> type[BaseOwsRequest]:
|
|
133
|
+
"""Find the correct class to parse the XML POST data with."""
|
|
134
|
+
return tag_registry.resolve_class(root, allowed_types=(BaseOwsRequest,))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def parse_get_request(
|
|
138
|
+
query_string: str | dict[str, str], ns_aliases: dict | None = None
|
|
139
|
+
) -> BaseOwsRequest:
|
|
140
|
+
"""Parse the WFS KVP GET request format into the internal request objects.
|
|
141
|
+
Most code calls the resolver internally, but this variation is easier for unit testing.
|
|
142
|
+
"""
|
|
143
|
+
if isinstance(query_string, str):
|
|
144
|
+
query_string = QueryDict(query_string.lstrip("?"))
|
|
145
|
+
|
|
146
|
+
kvp = KVPRequest(query_string, ns_aliases=ns_aliases)
|
|
147
|
+
request_cls = resolve_kvp_parser_class(kvp)
|
|
148
|
+
return request_cls.from_kvp_request(kvp)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_post_request(xml_string: str | bytes, ns_aliases: dict | None = None) -> BaseOwsRequest:
|
|
152
|
+
"""Parse the XML POST request format into the internal request objects.
|
|
153
|
+
Most code calls the resolver internally, but this variation is easier for unit testing.
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
root = parse_xml_from_string(xml_string, extra_ns_aliases=ns_aliases)
|
|
157
|
+
except ExternalParsingError as e:
|
|
158
|
+
raise OperationParsingFailed(f"Unable to parse XML: {e}") from e
|
|
159
|
+
|
|
160
|
+
return tag_registry.node_from_xml(root, allowed_types=(BaseOwsRequest,))
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The intermediate query result used by the various ``build_...()`` methods."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import operator
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from decimal import Decimal as D
|
|
9
|
+
from functools import reduce
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
from django.contrib.gis.geos import GEOSGeometry
|
|
13
|
+
from django.core.exceptions import FieldError
|
|
14
|
+
from django.db.models import Q, QuerySet
|
|
15
|
+
from django.db.models.expressions import Combinable, Func
|
|
16
|
+
|
|
17
|
+
from gisserver.features import FeatureType
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
ScalarTypes = (bool, int, str, D, date, datetime) # Not Union for Python 3.9
|
|
21
|
+
RhsTypes = Union[Combinable, Func, Q, GEOSGeometry, bool, int, D, str, date, datetime, tuple]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CompiledQuery:
|
|
25
|
+
"""Intermediate data for translating FES queries to Django.
|
|
26
|
+
|
|
27
|
+
This class effectively contains all data from the ``<fes:Filter>`` object,
|
|
28
|
+
but using a format that can be translated to a Django QuerySet.
|
|
29
|
+
|
|
30
|
+
As the Abstract Syntax Tree of a FES-filter creates the ORM query,
|
|
31
|
+
it fills this object with all intermediate bits. This allows building
|
|
32
|
+
the final QuerySet object in a single round. Each ``build_...()`` method
|
|
33
|
+
in the tree may add extra lookups and annotations.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
feature_types: list[FeatureType],
|
|
39
|
+
lookups: list[Q] | None = None,
|
|
40
|
+
typed_lookups: dict[str, list[Q]] | None = None,
|
|
41
|
+
annotations: dict[str, Combinable | Q] | None = None,
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
:param feature_types: The feature types this query uses.
|
|
45
|
+
Typically, this is one feature unless a JOIN syntax is used.
|
|
46
|
+
|
|
47
|
+
The extra parameters of the init method ar typically used only in unit tests.
|
|
48
|
+
"""
|
|
49
|
+
self.feature_types = feature_types
|
|
50
|
+
self.lookups = lookups or []
|
|
51
|
+
self.typed_lookups = typed_lookups or {}
|
|
52
|
+
self.annotations = annotations or {}
|
|
53
|
+
self.aliases = 0
|
|
54
|
+
self.extra_lookups: list[Q] = []
|
|
55
|
+
self.ordering: list[str] = []
|
|
56
|
+
self.is_empty = False
|
|
57
|
+
self.distinct = False
|
|
58
|
+
|
|
59
|
+
def add_annotation(self, value: Combinable | Q) -> str:
|
|
60
|
+
"""Create a named-alias for a function/Q object.
|
|
61
|
+
This alias can be used in a comparison, where expressions are used as left-hand-side.
|
|
62
|
+
"""
|
|
63
|
+
self.aliases += 1
|
|
64
|
+
name = f"a{self.aliases}"
|
|
65
|
+
self.annotations[name] = value
|
|
66
|
+
return name
|
|
67
|
+
|
|
68
|
+
def add_distinct(self):
|
|
69
|
+
"""Enforce "SELECT DISTINCT" on the query, used when joining 1-N or N-M relationships."""
|
|
70
|
+
self.distinct = True
|
|
71
|
+
|
|
72
|
+
def add_lookups(self, q_object: Q, type_name: str | None = None):
|
|
73
|
+
"""Register an extra 'WHERE' clause of the query.
|
|
74
|
+
This is used for comparisons, ID selectors and other query types.
|
|
75
|
+
"""
|
|
76
|
+
if not isinstance(q_object, Q):
|
|
77
|
+
raise TypeError()
|
|
78
|
+
|
|
79
|
+
if type_name is not None:
|
|
80
|
+
if type_name not in self.typed_lookups:
|
|
81
|
+
self.typed_lookups[type_name] = []
|
|
82
|
+
self.typed_lookups[type_name].append(q_object)
|
|
83
|
+
else:
|
|
84
|
+
self.lookups.append(q_object)
|
|
85
|
+
|
|
86
|
+
def add_extra_lookup(self, q_object: Q):
|
|
87
|
+
"""Temporary stash an extra lookup that the expression can't return yet.
|
|
88
|
+
This is used for XPath selectors that also filter on attributes,
|
|
89
|
+
e.g. "element[@attr=..]/child". The attribute lookup is processed as another filter.
|
|
90
|
+
"""
|
|
91
|
+
if not isinstance(q_object, Q):
|
|
92
|
+
raise TypeError()
|
|
93
|
+
# Note the ORM also provides a FilteredRelation() option, that is not explored yet here.
|
|
94
|
+
self.extra_lookups.append(q_object)
|
|
95
|
+
|
|
96
|
+
def add_ordering(self, ordering: list[str]):
|
|
97
|
+
"""Read the desired result ordering from a ``<fes:SortBy>`` element."""
|
|
98
|
+
self.ordering.extend(ordering)
|
|
99
|
+
|
|
100
|
+
def apply_extra_lookups(self, comparison: Q) -> Q:
|
|
101
|
+
"""Combine stashed lookups with the provided Q object.
|
|
102
|
+
|
|
103
|
+
This is called for functions that compile a "Q" object.
|
|
104
|
+
In case a node added extra lookups (for attributes), these are combined here
|
|
105
|
+
with the actual comparison.
|
|
106
|
+
"""
|
|
107
|
+
if not self.extra_lookups:
|
|
108
|
+
return comparison
|
|
109
|
+
|
|
110
|
+
# The extra lookups are used for XPath queries such as "/node[@attr=..]/foo".
|
|
111
|
+
# A <ValueReference> with such lookup also requires to limit the filtered results,
|
|
112
|
+
# in addition to the comparison operator code that is wrapped up here.
|
|
113
|
+
result = reduce(operator.and_, [comparison] + self.extra_lookups)
|
|
114
|
+
self.extra_lookups.clear()
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
def mark_empty(self):
|
|
118
|
+
"""Mark as returning no results."""
|
|
119
|
+
self.is_empty = True
|
|
120
|
+
|
|
121
|
+
def get_queryset(self) -> QuerySet:
|
|
122
|
+
"""Apply the filters and lookups to the queryset."""
|
|
123
|
+
queryset = self.feature_types[0].get_queryset()
|
|
124
|
+
if self.is_empty:
|
|
125
|
+
return queryset.none()
|
|
126
|
+
|
|
127
|
+
if self.extra_lookups:
|
|
128
|
+
# Each time an expression node calls add_extra_lookup(),
|
|
129
|
+
# the parent should have used apply_extra_lookups()
|
|
130
|
+
raise RuntimeError("apply_extra_lookups() was not called")
|
|
131
|
+
|
|
132
|
+
# All are applied at once.
|
|
133
|
+
if self.annotations:
|
|
134
|
+
queryset = queryset.annotate(**self.annotations)
|
|
135
|
+
|
|
136
|
+
lookups = self.lookups
|
|
137
|
+
try:
|
|
138
|
+
lookups += self.typed_lookups.pop(self.feature_types[0].xml_name)
|
|
139
|
+
except KeyError:
|
|
140
|
+
pass
|
|
141
|
+
if self.typed_lookups:
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
"Types lookups defined for unknown feature types: %r", list(self.typed_lookups)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if lookups:
|
|
147
|
+
try:
|
|
148
|
+
queryset = queryset.filter(*lookups)
|
|
149
|
+
except FieldError as e:
|
|
150
|
+
logger.debug("Query failed: %s, constructed query: %r", e.args[0], lookups)
|
|
151
|
+
e.args = (f"{e.args[0]} Constructed query: {lookups!r}",) + e.args[1:]
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
if self.ordering:
|
|
155
|
+
queryset = queryset.order_by(*self.ordering)
|
|
156
|
+
|
|
157
|
+
if self.distinct:
|
|
158
|
+
queryset = queryset.distinct()
|
|
159
|
+
|
|
160
|
+
return queryset
|
|
161
|
+
|
|
162
|
+
def __repr__(self):
|
|
163
|
+
return (
|
|
164
|
+
"<CompiledQuery"
|
|
165
|
+
f" annotations={self.annotations!r},"
|
|
166
|
+
f" lookups={self.lookups!r},"
|
|
167
|
+
f" typed_lookups={self.typed_lookups!r}>"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def __eq__(self, other):
|
|
171
|
+
"""For pytest comparisons."""
|
|
172
|
+
if isinstance(other, CompiledQuery):
|
|
173
|
+
return (
|
|
174
|
+
other.lookups == self.lookups
|
|
175
|
+
and other.typed_lookups == self.typed_lookups
|
|
176
|
+
and other.annotations == self.annotations
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
return NotImplemented
|
gisserver/parsers/values.py
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
|
+
"""Parsing of scalar values in the request."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
1
4
|
import re
|
|
2
|
-
from datetime import datetime
|
|
5
|
+
from datetime import date, datetime, time
|
|
3
6
|
from decimal import Decimal as D
|
|
4
7
|
|
|
5
|
-
from django.utils.dateparse import parse_datetime
|
|
8
|
+
from django.utils.dateparse import parse_date, parse_datetime, parse_duration, parse_time
|
|
6
9
|
|
|
7
10
|
from gisserver.exceptions import ExternalParsingError
|
|
8
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
9
13
|
RE_FLOAT = re.compile(r"\A[0-9]+(\.[0-9]+)\Z")
|
|
10
14
|
|
|
11
15
|
|
|
12
16
|
def auto_cast(value: str):
|
|
13
|
-
"""Automatically cast a value to a scalar.
|
|
17
|
+
"""Automatically cast a value to a scalar.
|
|
18
|
+
|
|
19
|
+
This recognizes integers, floats and ISO datetimes.
|
|
20
|
+
Booleans are not handled, as that leads to unpredictable behavior
|
|
21
|
+
(as seen in Yaml for example).
|
|
22
|
+
"""
|
|
23
|
+
if not isinstance(value, str):
|
|
24
|
+
return value
|
|
25
|
+
|
|
14
26
|
if value.isdigit():
|
|
15
27
|
return int(value)
|
|
16
28
|
elif RE_FLOAT.match(value):
|
|
@@ -24,17 +36,88 @@ def auto_cast(value: str):
|
|
|
24
36
|
return value
|
|
25
37
|
|
|
26
38
|
|
|
39
|
+
def parse_iso_date(raw_value: str) -> date:
|
|
40
|
+
"""Translate ISO date into a Python date value."""
|
|
41
|
+
try:
|
|
42
|
+
value = parse_date(raw_value)
|
|
43
|
+
except ValueError as e:
|
|
44
|
+
raise ExternalParsingError(str(e)) from e
|
|
45
|
+
|
|
46
|
+
if value is None:
|
|
47
|
+
raise ExternalParsingError("Date must be in YYYY-MM-DD format.")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
27
51
|
def parse_iso_datetime(raw_value: str) -> datetime:
|
|
28
|
-
|
|
52
|
+
"""Translate ISO datetimes into a Python datetime value."""
|
|
53
|
+
try:
|
|
54
|
+
value = parse_datetime(raw_value)
|
|
55
|
+
except ValueError as e:
|
|
56
|
+
raise ExternalParsingError(str(e)) from e
|
|
57
|
+
|
|
29
58
|
if value is None:
|
|
30
59
|
raise ExternalParsingError("Date must be in YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format.")
|
|
31
60
|
return value
|
|
32
61
|
|
|
33
62
|
|
|
63
|
+
def parse_iso_time(raw_value: str) -> time:
|
|
64
|
+
"""Translate ISO times into a Python time value."""
|
|
65
|
+
try:
|
|
66
|
+
value = parse_time(raw_value)
|
|
67
|
+
except ValueError as e:
|
|
68
|
+
raise ExternalParsingError(str(e)) from e
|
|
69
|
+
|
|
70
|
+
if value is None:
|
|
71
|
+
raise ExternalParsingError("Time must be in HH:MM[:ss[.uuuuuu]][TZ] format.")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_iso_duration(raw_value: str) -> time:
|
|
76
|
+
"""Translate ISO times into a Python time value."""
|
|
77
|
+
# The parse_duration() supports multiple formats, including ISO8601.
|
|
78
|
+
# Limit it to only one format.
|
|
79
|
+
if not raw_value.startswith(("P", "-P")):
|
|
80
|
+
raise ExternalParsingError("Duration must be in ISO8601 format (e.g. PT1H).")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
value = parse_duration(raw_value)
|
|
84
|
+
except ValueError as e:
|
|
85
|
+
raise ExternalParsingError(str(e)) from e
|
|
86
|
+
|
|
87
|
+
if value is None:
|
|
88
|
+
raise ExternalParsingError("Duration must be in ISO8601 format (e.g. PT1H).")
|
|
89
|
+
return value
|
|
90
|
+
|
|
91
|
+
|
|
34
92
|
def parse_bool(raw_value: str):
|
|
93
|
+
"""Translate XML notations of true/1 and false/0 into a boolean."""
|
|
35
94
|
if raw_value in ("true", "1"):
|
|
36
95
|
return True
|
|
37
96
|
elif raw_value in ("false", "0"):
|
|
38
97
|
return False
|
|
39
98
|
else:
|
|
40
99
|
raise ExternalParsingError(f"Can't cast '{raw_value}' to boolean")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fix_type_name(type_name: str, feature_namespace: str):
|
|
103
|
+
"""Fix the XML namespace for a typename value.
|
|
104
|
+
|
|
105
|
+
When the default namespace points to the "wfs" or "gml" namespaces,
|
|
106
|
+
parsing the QName of the type value will resolve that element as existing there.
|
|
107
|
+
This will correct such error, and restore the feature-type namespace.
|
|
108
|
+
"""
|
|
109
|
+
if feature_namespace[0] == "{":
|
|
110
|
+
raise ValueError("Incorrect namespace argument")
|
|
111
|
+
|
|
112
|
+
alt_type_name = type_name
|
|
113
|
+
if type_name.startswith("{http://www.opengis.net/"):
|
|
114
|
+
# When the XML POST request used xmlns="http://www.opengis.net/wfs/2.0",
|
|
115
|
+
# this will define a default namespace that all QName values resolve to.
|
|
116
|
+
# As this is not detectable at the moment of parsing, correct it here.
|
|
117
|
+
alt_type_name = f"{{{feature_namespace}}}{type_name[type_name.index('}')+1:]}"
|
|
118
|
+
logger.debug("Corrected namespaced '%s' to '%s'", type_name, alt_type_name)
|
|
119
|
+
elif type_name[0] != "{":
|
|
120
|
+
# Typically happens in GET requests, or when no namespace prefix is used in XML POST
|
|
121
|
+
alt_type_name = f"{{{feature_namespace}}}{type_name}"
|
|
122
|
+
logger.debug("Corrected unnamespaced '%s' to namespaced '%s'", type_name, alt_type_name)
|
|
123
|
+
return alt_type_name
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""WFS 2.0 element parsing.
|
|
2
|
+
|
|
3
|
+
These classes parse the XML request body.
|
|
4
|
+
|
|
5
|
+
The full spec can be found at: https://www.ogc.org/publications/standard/wfs/.
|
|
6
|
+
Secondly, using https://www.mediamaps.ch/ogc/schemas-xsdoc/sld/1.2/wfs_xsd.html can be very
|
|
7
|
+
helpful to see which options each object type should support.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .adhoc import AdhocQuery
|
|
11
|
+
from .base import QueryExpression
|
|
12
|
+
from .projection import PropertyName
|
|
13
|
+
from .requests import (
|
|
14
|
+
DescribeFeatureType,
|
|
15
|
+
DescribeStoredQueries,
|
|
16
|
+
GetCapabilities,
|
|
17
|
+
GetFeature,
|
|
18
|
+
GetPropertyValue,
|
|
19
|
+
ListStoredQueries,
|
|
20
|
+
ResultType,
|
|
21
|
+
)
|
|
22
|
+
from .stored import StoredQuery
|
|
23
|
+
|
|
24
|
+
__all__ = (
|
|
25
|
+
# Requests
|
|
26
|
+
"GetCapabilities",
|
|
27
|
+
"DescribeFeatureType",
|
|
28
|
+
"GetFeature",
|
|
29
|
+
"GetPropertyValue",
|
|
30
|
+
"ListStoredQueries",
|
|
31
|
+
"DescribeStoredQueries",
|
|
32
|
+
"ResultType",
|
|
33
|
+
# Queries
|
|
34
|
+
"QueryExpression",
|
|
35
|
+
"AdhocQuery",
|
|
36
|
+
"StoredQuery",
|
|
37
|
+
# Projection
|
|
38
|
+
"PropertyName",
|
|
39
|
+
)
|