django-gisserver 1.5.0__py3-none-any.whl → 2.0__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.0.dist-info}/METADATA +14 -4
- django_gisserver-2.0.dist-info/RECORD +66 -0
- {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/WHEEL +1 -1
- gisserver/__init__.py +1 -1
- gisserver/compat.py +23 -0
- gisserver/conf.py +7 -0
- gisserver/db.py +56 -47
- gisserver/exceptions.py +26 -2
- gisserver/extensions/__init__.py +4 -0
- gisserver/{parsers/fes20 → extensions}/functions.py +10 -4
- gisserver/extensions/queries.py +261 -0
- gisserver/features.py +220 -156
- gisserver/geometries.py +32 -37
- gisserver/management/__init__.py +0 -0
- gisserver/management/commands/__init__.py +0 -0
- gisserver/management/commands/loadgeojson.py +291 -0
- gisserver/operations/base.py +122 -308
- gisserver/operations/wfs20.py +423 -337
- gisserver/output/__init__.py +9 -48
- gisserver/output/base.py +178 -139
- gisserver/output/csv.py +65 -74
- gisserver/output/geojson.py +34 -35
- gisserver/output/gml32.py +254 -246
- gisserver/output/iters.py +207 -0
- gisserver/output/results.py +52 -26
- gisserver/output/stored.py +143 -0
- gisserver/output/utils.py +75 -170
- gisserver/output/xmlschema.py +85 -46
- gisserver/parsers/__init__.py +10 -10
- gisserver/parsers/ast.py +320 -0
- gisserver/parsers/fes20/__init__.py +13 -27
- gisserver/parsers/fes20/expressions.py +82 -38
- gisserver/parsers/fes20/filters.py +111 -43
- gisserver/parsers/fes20/identifiers.py +44 -26
- gisserver/parsers/fes20/lookups.py +144 -0
- gisserver/parsers/fes20/operators.py +331 -127
- gisserver/parsers/fes20/sorting.py +104 -33
- gisserver/parsers/gml/__init__.py +12 -11
- gisserver/parsers/gml/base.py +5 -2
- gisserver/parsers/gml/geometries.py +69 -35
- gisserver/parsers/ows/__init__.py +25 -0
- gisserver/parsers/ows/kvp.py +190 -0
- gisserver/parsers/ows/requests.py +158 -0
- gisserver/parsers/query.py +175 -0
- gisserver/parsers/values.py +26 -0
- gisserver/parsers/wfs20/__init__.py +37 -0
- gisserver/parsers/wfs20/adhoc.py +245 -0
- gisserver/parsers/wfs20/base.py +143 -0
- gisserver/parsers/wfs20/projection.py +103 -0
- gisserver/parsers/wfs20/requests.py +482 -0
- gisserver/parsers/wfs20/stored.py +192 -0
- gisserver/parsers/xml.py +249 -0
- gisserver/projection.py +357 -0
- gisserver/static/gisserver/index.css +12 -1
- gisserver/templates/gisserver/index.html +1 -1
- gisserver/templates/gisserver/service_description.html +2 -2
- gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
- gisserver/templates/gisserver/wfs/feature_field.html +2 -2
- gisserver/templatetags/gisserver_tags.py +20 -0
- gisserver/types.py +322 -259
- gisserver/views.py +198 -56
- 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.0.dist-info}/LICENSE +0 -0
- {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
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 BaseNode, 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(BaseNode):
|
|
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_tags[0])[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(node.xml_tags[0] for node in request_classes.values())
|
|
122
|
+
raise OperationNotSupported(
|
|
123
|
+
f"'{request}' is not implemented, supported are: {allowed}.",
|
|
124
|
+
locator="request",
|
|
125
|
+
) from None
|
|
126
|
+
|
|
127
|
+
return request_cls
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_xml_parser_class(root: NSElement) -> type[BaseOwsRequest]:
|
|
131
|
+
"""Find the correct class to parse the XML POST data with."""
|
|
132
|
+
return tag_registry.resolve_class(root, allowed_types=(BaseOwsRequest,))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_get_request(
|
|
136
|
+
query_string: str | dict[str, str], ns_aliases: dict | None = None
|
|
137
|
+
) -> BaseOwsRequest:
|
|
138
|
+
"""Parse the WFS KVP GET request format into the internal request objects.
|
|
139
|
+
Most code calls the resolver internally, but this variation is easier for unit testing.
|
|
140
|
+
"""
|
|
141
|
+
if isinstance(query_string, str):
|
|
142
|
+
query_string = QueryDict(query_string.lstrip("?"))
|
|
143
|
+
|
|
144
|
+
kvp = KVPRequest(query_string, ns_aliases=ns_aliases)
|
|
145
|
+
request_cls = resolve_kvp_parser_class(kvp)
|
|
146
|
+
return request_cls.from_kvp_request(kvp)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_post_request(xml_string: str | bytes, ns_aliases: dict | None = None) -> BaseOwsRequest:
|
|
150
|
+
"""Parse the XML POST request format into the internal request objects.
|
|
151
|
+
Most code calls the resolver internally, but this variation is easier for unit testing.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
root = parse_xml_from_string(xml_string, extra_ns_aliases=ns_aliases)
|
|
155
|
+
except ExternalParsingError as e:
|
|
156
|
+
raise OperationParsingFailed(f"Unable to parse XML: {e}") from e
|
|
157
|
+
|
|
158
|
+
return tag_registry.node_from_xml(root, allowed_types=(BaseOwsRequest,))
|
|
@@ -0,0 +1,175 @@
|
|
|
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 functools import reduce
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
from django.contrib.gis.geos import GEOSGeometry
|
|
12
|
+
from django.core.exceptions import FieldError
|
|
13
|
+
from django.db.models import Q, QuerySet
|
|
14
|
+
from django.db.models.expressions import Combinable, Func
|
|
15
|
+
|
|
16
|
+
from gisserver.features import FeatureType
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
RhsTypes = Union[Combinable, Func, Q, GEOSGeometry, bool, int, str, date, datetime, tuple]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CompiledQuery:
|
|
23
|
+
"""Intermediate data for translating FES queries to Django.
|
|
24
|
+
|
|
25
|
+
This class effectively contains all data from the ``<fes:Filter>`` object,
|
|
26
|
+
but using a format that can be translated to a django QuerySet.
|
|
27
|
+
|
|
28
|
+
As the Abstract Syntax Tree of a FES-filter creates the ORM query,
|
|
29
|
+
it fills this object with all intermediate bits. This allows building
|
|
30
|
+
the final QuerySet object in a single round. Each ``build_...()`` method
|
|
31
|
+
in the tree may add extra lookups and annotations.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
feature_types: list[FeatureType],
|
|
37
|
+
lookups: list[Q] | None = None,
|
|
38
|
+
typed_lookups: dict[str, list[Q]] | None = None,
|
|
39
|
+
annotations: dict[str, Combinable | Q] | None = None,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
:param feature_types: The feature types this query uses.
|
|
43
|
+
Typically, this is one feature unless a JOIN syntax is used.
|
|
44
|
+
|
|
45
|
+
The extra parameters of the init method ar typically used only in unit tests.
|
|
46
|
+
"""
|
|
47
|
+
self.feature_types = feature_types
|
|
48
|
+
self.lookups = lookups or []
|
|
49
|
+
self.typed_lookups = typed_lookups or {}
|
|
50
|
+
self.annotations = annotations or {}
|
|
51
|
+
self.aliases = 0
|
|
52
|
+
self.extra_lookups: list[Q] = []
|
|
53
|
+
self.ordering: list[str] = []
|
|
54
|
+
self.is_empty = False
|
|
55
|
+
self.distinct = False
|
|
56
|
+
|
|
57
|
+
def add_annotation(self, value: Combinable | Q) -> str:
|
|
58
|
+
"""Create a named-alias for a function/Q object.
|
|
59
|
+
This alias can be used in a comparison, where expressions are used as left-hand-side.
|
|
60
|
+
"""
|
|
61
|
+
self.aliases += 1
|
|
62
|
+
name = f"a{self.aliases}"
|
|
63
|
+
self.annotations[name] = value
|
|
64
|
+
return name
|
|
65
|
+
|
|
66
|
+
def add_distinct(self):
|
|
67
|
+
self.distinct = True
|
|
68
|
+
|
|
69
|
+
def add_lookups(self, q_object: Q, type_name: str | None = None):
|
|
70
|
+
"""Register an extra 'WHERE' clause of the query.
|
|
71
|
+
This is used for comparisons, ID selectors and other query types.
|
|
72
|
+
"""
|
|
73
|
+
if not isinstance(q_object, Q):
|
|
74
|
+
raise TypeError()
|
|
75
|
+
|
|
76
|
+
if type_name is not None:
|
|
77
|
+
if type_name not in self.typed_lookups:
|
|
78
|
+
self.typed_lookups[type_name] = []
|
|
79
|
+
self.typed_lookups[type_name].append(q_object)
|
|
80
|
+
else:
|
|
81
|
+
self.lookups.append(q_object)
|
|
82
|
+
|
|
83
|
+
def add_extra_lookup(self, q_object: Q):
|
|
84
|
+
"""Temporary stash an extra lookup that the expression can't return yet.
|
|
85
|
+
This is used for XPath selectors that also filter on attributes,
|
|
86
|
+
e.g. "element[@attr=..]/child". The attribute lookup is processed as another filter.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(q_object, Q):
|
|
89
|
+
raise TypeError()
|
|
90
|
+
self.extra_lookups.append(q_object)
|
|
91
|
+
|
|
92
|
+
def add_ordering(self, ordering: list[str]):
|
|
93
|
+
"""Read the desired result ordering from a ``<fes:SortBy>`` element."""
|
|
94
|
+
self.ordering.extend(ordering)
|
|
95
|
+
|
|
96
|
+
def apply_extra_lookups(self, comparison: Q) -> Q:
|
|
97
|
+
"""Combine stashed lookups with the provided Q object.
|
|
98
|
+
|
|
99
|
+
This is called for functions that compile a "Q" object.
|
|
100
|
+
In case a node added extra lookups (for attributes), these are combined here
|
|
101
|
+
with the actual comparison.
|
|
102
|
+
"""
|
|
103
|
+
if not self.extra_lookups:
|
|
104
|
+
return comparison
|
|
105
|
+
|
|
106
|
+
# The extra lookups are used for XPath queries such as "/node[@attr=..]/foo".
|
|
107
|
+
# A <ValueReference> with such lookup also requires to limit the filtered results,
|
|
108
|
+
# in addition to the comparison operator code that is wrapped up here.
|
|
109
|
+
result = reduce(operator.and_, [comparison] + self.extra_lookups)
|
|
110
|
+
self.extra_lookups.clear()
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
def mark_empty(self):
|
|
114
|
+
"""Mark as returning no results."""
|
|
115
|
+
self.is_empty = True
|
|
116
|
+
|
|
117
|
+
def get_queryset(self) -> QuerySet:
|
|
118
|
+
"""Apply the filters and lookups to the queryset."""
|
|
119
|
+
queryset = self.feature_types[0].get_queryset()
|
|
120
|
+
if self.is_empty:
|
|
121
|
+
return queryset.none()
|
|
122
|
+
|
|
123
|
+
if self.extra_lookups:
|
|
124
|
+
# Each time an expression node calls add_extra_lookup(),
|
|
125
|
+
# the parent should have used apply_extra_lookups()
|
|
126
|
+
raise RuntimeError("apply_extra_lookups() was not called")
|
|
127
|
+
|
|
128
|
+
# All are applied at once.
|
|
129
|
+
if self.annotations:
|
|
130
|
+
queryset = queryset.annotate(**self.annotations)
|
|
131
|
+
|
|
132
|
+
lookups = self.lookups
|
|
133
|
+
try:
|
|
134
|
+
lookups += self.typed_lookups.pop(self.feature_types[0].xml_name)
|
|
135
|
+
except KeyError:
|
|
136
|
+
pass
|
|
137
|
+
if self.typed_lookups:
|
|
138
|
+
raise RuntimeError(
|
|
139
|
+
"Types lookups defined for unknown feature types: %r", list(self.typed_lookups)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
if lookups:
|
|
143
|
+
try:
|
|
144
|
+
queryset = queryset.filter(*lookups)
|
|
145
|
+
except FieldError as e:
|
|
146
|
+
logger.debug("Query failed: %s, constructed query: %r", e.args[0], lookups)
|
|
147
|
+
e.args = (f"{e.args[0]} Constructed query: {lookups!r}",) + e.args[1:]
|
|
148
|
+
raise
|
|
149
|
+
|
|
150
|
+
if self.ordering:
|
|
151
|
+
queryset = queryset.order_by(*self.ordering)
|
|
152
|
+
|
|
153
|
+
if self.distinct:
|
|
154
|
+
queryset = queryset.distinct()
|
|
155
|
+
|
|
156
|
+
return queryset
|
|
157
|
+
|
|
158
|
+
def __repr__(self):
|
|
159
|
+
return (
|
|
160
|
+
"<CompiledQuery"
|
|
161
|
+
f" annotations={self.annotations!r},"
|
|
162
|
+
f" lookups={self.lookups!r},"
|
|
163
|
+
f" typed_lookups={self.typed_lookups!r}>"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def __eq__(self, other):
|
|
167
|
+
"""For pytest comparisons."""
|
|
168
|
+
if isinstance(other, CompiledQuery):
|
|
169
|
+
return (
|
|
170
|
+
other.lookups == self.lookups
|
|
171
|
+
and other.typed_lookups == self.typed_lookups
|
|
172
|
+
and other.annotations == self.annotations
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
return NotImplemented
|
gisserver/parsers/values.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import re
|
|
2
3
|
from datetime import datetime
|
|
3
4
|
from decimal import Decimal as D
|
|
@@ -6,6 +7,7 @@ from django.utils.dateparse import parse_datetime
|
|
|
6
7
|
|
|
7
8
|
from gisserver.exceptions import ExternalParsingError
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
9
11
|
RE_FLOAT = re.compile(r"\A[0-9]+(\.[0-9]+)\Z")
|
|
10
12
|
|
|
11
13
|
|
|
@@ -38,3 +40,27 @@ def parse_bool(raw_value: str):
|
|
|
38
40
|
return False
|
|
39
41
|
else:
|
|
40
42
|
raise ExternalParsingError(f"Can't cast '{raw_value}' to boolean")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fix_type_name(type_name: str, feature_namespace: str):
|
|
46
|
+
"""Fix the XML namespace for a typename value.
|
|
47
|
+
|
|
48
|
+
When the default namespace points to the "wfs" or "gml" namespaces,
|
|
49
|
+
parsing the QName of the type value will resolve that element as existing there.
|
|
50
|
+
This will correct such error, and restore the feature-type namespace.
|
|
51
|
+
"""
|
|
52
|
+
if feature_namespace[0] == "{":
|
|
53
|
+
raise ValueError("Incorrect namespace argument")
|
|
54
|
+
|
|
55
|
+
alt_type_name = type_name
|
|
56
|
+
if type_name.startswith("{http://www.opengis.net/"):
|
|
57
|
+
# When the XML POST request used xmlns="http://www.opengis.net/wfs/2.0",
|
|
58
|
+
# this will define a default namespace that all QName values resolve to.
|
|
59
|
+
# As this is not detectable at the moment of parsing, correct it here.
|
|
60
|
+
alt_type_name = f"{{{feature_namespace}}}{type_name[type_name.index('}')+1:]}"
|
|
61
|
+
logger.debug("Corrected namespaced '%s' to '%s'", type_name, alt_type_name)
|
|
62
|
+
elif type_name[0] != "{":
|
|
63
|
+
# Typically happens in GET requests, or when no namespace prefix is used in XML POST
|
|
64
|
+
alt_type_name = f"{{{feature_namespace}}}{type_name}"
|
|
65
|
+
logger.debug("Corrected unnamespaced '%s' to namespaced '%s'", type_name, alt_type_name)
|
|
66
|
+
return alt_type_name
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""WFS 2.0 element parsing.
|
|
2
|
+
|
|
3
|
+
The full spec can be found at: https://www.ogc.org/publications/standard/wfs/.
|
|
4
|
+
Secondly, using https://www.mediamaps.ch/ogc/schemas-xsdoc/sld/1.2/wfs_xsd.html can be very
|
|
5
|
+
helpful to see which options each object type should support.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .adhoc import AdhocQuery
|
|
9
|
+
from .base import QueryExpression
|
|
10
|
+
from .projection import PropertyName
|
|
11
|
+
from .requests import (
|
|
12
|
+
DescribeFeatureType,
|
|
13
|
+
DescribeStoredQueries,
|
|
14
|
+
GetCapabilities,
|
|
15
|
+
GetFeature,
|
|
16
|
+
GetPropertyValue,
|
|
17
|
+
ListStoredQueries,
|
|
18
|
+
ResultType,
|
|
19
|
+
)
|
|
20
|
+
from .stored import StoredQuery
|
|
21
|
+
|
|
22
|
+
__all__ = (
|
|
23
|
+
# Requests
|
|
24
|
+
"GetCapabilities",
|
|
25
|
+
"DescribeFeatureType",
|
|
26
|
+
"GetFeature",
|
|
27
|
+
"GetPropertyValue",
|
|
28
|
+
"ListStoredQueries",
|
|
29
|
+
"DescribeStoredQueries",
|
|
30
|
+
"ResultType",
|
|
31
|
+
# Queries
|
|
32
|
+
"QueryExpression",
|
|
33
|
+
"AdhocQuery",
|
|
34
|
+
"StoredQuery",
|
|
35
|
+
# Projection
|
|
36
|
+
"PropertyName",
|
|
37
|
+
)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Handle adhoc-query objects.
|
|
2
|
+
|
|
3
|
+
The adhoc query is based on incoming request parameters,
|
|
4
|
+
such as the "FILTER", "BBOX" and "RESOURCEID" parameters.
|
|
5
|
+
|
|
6
|
+
These definitions follow the WFS spec.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
|
|
15
|
+
from django.db.models import Q
|
|
16
|
+
|
|
17
|
+
from gisserver.exceptions import (
|
|
18
|
+
InvalidParameterValue,
|
|
19
|
+
MissingParameterValue,
|
|
20
|
+
OperationNotSupported,
|
|
21
|
+
)
|
|
22
|
+
from gisserver.geometries import CRS
|
|
23
|
+
from gisserver.parsers import fes20
|
|
24
|
+
from gisserver.parsers.ast import tag_registry
|
|
25
|
+
from gisserver.parsers.ows import KVPRequest
|
|
26
|
+
from gisserver.parsers.query import CompiledQuery
|
|
27
|
+
from gisserver.parsers.xml import NSElement, xmlns
|
|
28
|
+
from gisserver.projection import FeatureProjection
|
|
29
|
+
|
|
30
|
+
from .base import QueryExpression
|
|
31
|
+
from .projection import PropertyName
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
ADHOC_QUERY_ELEMENTS = (PropertyName, fes20.Filter, fes20.SortBy)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
@tag_registry.register("Query", xmlns.wfs)
|
|
40
|
+
class AdhocQuery(QueryExpression):
|
|
41
|
+
"""The Ad hoc query expression parameters.
|
|
42
|
+
|
|
43
|
+
This parses and handles the syntax::
|
|
44
|
+
|
|
45
|
+
<wfs:Query typeNames="...">
|
|
46
|
+
<wfs:PropertyName>...</wfs:PropertyName>
|
|
47
|
+
<fes:Filter>...</fes:Filter>
|
|
48
|
+
<fes:SortBy>...</fes:SortBy>
|
|
49
|
+
</wfs:Query>
|
|
50
|
+
|
|
51
|
+
And supports the KVP syntax::
|
|
52
|
+
|
|
53
|
+
?SERVICE=WFS&...&TYPENAMES=ns:myType&FILTER=...&SORTBY=...&SRSNAME=...&PROPERTYNAME=...
|
|
54
|
+
?SERVICE=WFS&...&TYPENAMES=ns:myType&BBOX=...&SORTBY=...&SRSNAME=...&PROPERTYNAME=...
|
|
55
|
+
?SERVICE=WFS&...&TYPENAMES=ns:myType&RESOURCEID=...
|
|
56
|
+
|
|
57
|
+
This represents all dynamic queries received as request (hence "adhoc"),
|
|
58
|
+
such as the "FILTER" and "BBOX" arguments from an HTTP GET.
|
|
59
|
+
|
|
60
|
+
The WFS Spec has 3 class levels for this:
|
|
61
|
+
|
|
62
|
+
- AdhocQueryExpression (types, projection, selection, sorting)
|
|
63
|
+
- Query (adds srsName, featureVersion)
|
|
64
|
+
- StoredQuery (adds storedQueryID)
|
|
65
|
+
|
|
66
|
+
For KVP requests, this class seems almost identifical to the provided parameters.
|
|
67
|
+
However, the KVP format allows to provide parameter lists,
|
|
68
|
+
to perform support multiple queries in a single request!
|
|
69
|
+
|
|
70
|
+
.. seealso::
|
|
71
|
+
https://www.mediamaps.ch/ogc/schemas-xsdoc/sld/1.2/query_xsd.html#AbstractAdhocQueryExpressionType
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# Tag attributes (fes:AbstractAdhocQueryExpression)
|
|
75
|
+
# WFS allows multiple names to construct JOIN queries.
|
|
76
|
+
# See https://docs.ogc.org/is/09-025r2/09-025r2.html#107
|
|
77
|
+
# and https://docs.ogc.org/is/09-025r2/09-025r2.html#190
|
|
78
|
+
typeNames: list[str] # typeNames in WFS/FES spec, multiple values means a JOIN is made.
|
|
79
|
+
aliases: list[str] | None = None # aliases for typeNames for joining the same table twice.
|
|
80
|
+
handle: str = "" # only for XML POST requests, is returned in ows:Exception
|
|
81
|
+
# Query tag attributes
|
|
82
|
+
srsName: CRS | None = None
|
|
83
|
+
|
|
84
|
+
# Projection clause (fes:AbstractProjectionClause)
|
|
85
|
+
property_names: list[PropertyName] | None = None
|
|
86
|
+
|
|
87
|
+
# Selection clause (fes:AbstractSelectionClause):
|
|
88
|
+
# - for XML POST this is encoded in a <fes:Query>
|
|
89
|
+
# - for HTTP GET, this is encoded as FILTER, FILTER_LANGUAGE, RESOURCEID, BBOX.
|
|
90
|
+
filter: fes20.Filter | None = None
|
|
91
|
+
|
|
92
|
+
# Sorting Clause (fes:AbstractSortingClause)
|
|
93
|
+
sortBy: fes20.SortBy | None = None
|
|
94
|
+
|
|
95
|
+
def __post_init__(self):
|
|
96
|
+
if len(self.typeNames) > 1:
|
|
97
|
+
raise OperationNotSupported("Join queries are not supported", locator="typeNames")
|
|
98
|
+
if self.aliases:
|
|
99
|
+
raise OperationNotSupported("Join queries are not supported", locator="aliases")
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_xml(cls, element: NSElement) -> AdhocQuery:
|
|
103
|
+
"""Parse the XML element of the Query tag."""
|
|
104
|
+
type_names = [
|
|
105
|
+
element.parse_qname(qname)
|
|
106
|
+
for qname in element.get_str_attribute("typeNames").split(" ")
|
|
107
|
+
]
|
|
108
|
+
aliases = element.attrib.get("aliases", None)
|
|
109
|
+
srsName = element.attrib.get("srsName", None)
|
|
110
|
+
property_names = []
|
|
111
|
+
filter = None
|
|
112
|
+
sortBy = None
|
|
113
|
+
|
|
114
|
+
for child in element:
|
|
115
|
+
# The FES XSD dictates the element ordering, but this is ignored here.
|
|
116
|
+
node = tag_registry.node_from_xml(child, allowed_types=ADHOC_QUERY_ELEMENTS)
|
|
117
|
+
if isinstance(node, PropertyName):
|
|
118
|
+
property_names.append(node)
|
|
119
|
+
elif isinstance(node, fes20.Filter):
|
|
120
|
+
filter = node
|
|
121
|
+
elif isinstance(node, fes20.SortBy):
|
|
122
|
+
sortBy = node
|
|
123
|
+
else:
|
|
124
|
+
raise NotImplementedError(
|
|
125
|
+
f"Parsing {node.__class__} not handled in AdhocQuery.from_xml()"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return AdhocQuery(
|
|
129
|
+
typeNames=type_names,
|
|
130
|
+
aliases=aliases.split(" ") if aliases is not None else None,
|
|
131
|
+
handle=element.attrib.get("handle", ""),
|
|
132
|
+
property_names=property_names or None,
|
|
133
|
+
filter=filter,
|
|
134
|
+
sortBy=sortBy,
|
|
135
|
+
srsName=CRS.from_string(srsName) if srsName else None,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def from_kvp_request(cls, kvp: KVPRequest):
|
|
140
|
+
"""Build this object from an HTTP GET (key-value-pair) request.
|
|
141
|
+
|
|
142
|
+
Note the caller should have split the KVP request parameter lists.
|
|
143
|
+
This class only handles a single parameter pair.
|
|
144
|
+
"""
|
|
145
|
+
# Parse attributes
|
|
146
|
+
typeNames = [
|
|
147
|
+
kvp.parse_qname(qname)
|
|
148
|
+
for qname in kvp.get_list("typeNames", alias="TYPENAME", default=[])
|
|
149
|
+
]
|
|
150
|
+
aliases = kvp.get_list("aliases", default=None)
|
|
151
|
+
srsName = kvp.get_custom("srsName", default=None, parser=CRS.from_string)
|
|
152
|
+
|
|
153
|
+
# KVP requests may omit the typenames if RESOURCEID=... is given.
|
|
154
|
+
if not typeNames and "RESOURCEID" not in kvp:
|
|
155
|
+
raise MissingParameterValue("Empty TYPENAMES parameter", locator="typeNames")
|
|
156
|
+
|
|
157
|
+
# Parse elements
|
|
158
|
+
filter = fes20.Filter.from_kvp_request(kvp)
|
|
159
|
+
sort_by = fes20.SortBy.from_kvp_request(kvp)
|
|
160
|
+
|
|
161
|
+
# Parse projection
|
|
162
|
+
property_names = None
|
|
163
|
+
if "PROPERTYNAME" in kvp:
|
|
164
|
+
names = kvp.get_list("propertyName", default=[])
|
|
165
|
+
# Check for WFS 1.x syntax of ?PROPERTYNAME=*
|
|
166
|
+
if names != ["*"]:
|
|
167
|
+
property_names = [
|
|
168
|
+
PropertyName(xpath=name, xpath_ns_aliases=kvp.ns_aliases) for name in names
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
return AdhocQuery(
|
|
172
|
+
# Attributes
|
|
173
|
+
typeNames=typeNames,
|
|
174
|
+
aliases=aliases,
|
|
175
|
+
srsName=srsName,
|
|
176
|
+
# Elements
|
|
177
|
+
property_names=property_names,
|
|
178
|
+
filter=filter,
|
|
179
|
+
sortBy=sort_by,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
@cached_property
|
|
183
|
+
def query_locator(self):
|
|
184
|
+
"""Overrides the 'query_locator' attribute, so the 'locator' argument is correctly set."""
|
|
185
|
+
if self.filter is not None and self.filter.get_resource_id_types():
|
|
186
|
+
return "resourceId"
|
|
187
|
+
else:
|
|
188
|
+
return "filter"
|
|
189
|
+
|
|
190
|
+
def get_type_names(self) -> list[str]:
|
|
191
|
+
"""Tell which type names this query uses."""
|
|
192
|
+
if not self.typeNames and self.filter is not None:
|
|
193
|
+
# Also make the behavior consistent, always supply the type name.
|
|
194
|
+
return self.filter.get_resource_id_types() or []
|
|
195
|
+
else:
|
|
196
|
+
return self.typeNames
|
|
197
|
+
|
|
198
|
+
def get_projection(self) -> FeatureProjection:
|
|
199
|
+
"""Tell how the <wfs:Query> element should be displayed."""
|
|
200
|
+
return FeatureProjection(
|
|
201
|
+
self.feature_types,
|
|
202
|
+
self.property_names,
|
|
203
|
+
self.value_reference,
|
|
204
|
+
output_crs=self.srsName,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def bind(self, *args, **kwargs):
|
|
208
|
+
"""Make sure the 'locator' points to the actual object that defined the type."""
|
|
209
|
+
try:
|
|
210
|
+
super().bind(*args, **kwargs)
|
|
211
|
+
except InvalidParameterValue as e:
|
|
212
|
+
if not self.typeNames:
|
|
213
|
+
e.locator = "resourceId"
|
|
214
|
+
raise
|
|
215
|
+
|
|
216
|
+
# Validate the srsName too
|
|
217
|
+
if self.srsName is not None:
|
|
218
|
+
self.srsName = self.feature_types[0].resolve_crs(self.srsName, locator="srsName")
|
|
219
|
+
|
|
220
|
+
def build_query(self, compiler: CompiledQuery) -> Q | None:
|
|
221
|
+
"""Apply our collected filter data to the compiler."""
|
|
222
|
+
# Add the
|
|
223
|
+
if self.sortBy is not None:
|
|
224
|
+
self.sortBy.build_ordering(compiler)
|
|
225
|
+
|
|
226
|
+
if self.filter is not None:
|
|
227
|
+
# Generate the internal query object from the <fes:Filter>,
|
|
228
|
+
# this can return a Q object.
|
|
229
|
+
return self.filter.build_query(compiler)
|
|
230
|
+
else:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def as_kvp(self):
|
|
234
|
+
"""Translate the POST request into KVP GET parameters. This is needed for pagination."""
|
|
235
|
+
params = super().as_kvp()
|
|
236
|
+
params["TYPENAMES"] = ",".join(self.typeNames)
|
|
237
|
+
if self.srsName is not None:
|
|
238
|
+
params["SRSNAME"] = str(self.srsName)
|
|
239
|
+
|
|
240
|
+
if self.filter is not None:
|
|
241
|
+
raise NotImplementedError() # not going to parse that, nor does mapserver
|
|
242
|
+
if self.sortBy is not None:
|
|
243
|
+
params["SORTBY"] = self.sortBy.as_kvp()
|
|
244
|
+
|
|
245
|
+
return params
|