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
gisserver/parsers/xml.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""XML parsing for all incoming requests.
|
|
2
|
+
|
|
3
|
+
This logic uses the etree logic from the standard library,
|
|
4
|
+
with some extra extensions to expose the original namespace aliases.
|
|
5
|
+
Using defusedxml, incoming DOS attacks are prevented.
|
|
6
|
+
|
|
7
|
+
To handle more complex XML structures, consider building an Abstract Syntax Tree (AST)
|
|
8
|
+
to translate the XML Element classes into Python objects.
|
|
9
|
+
The :mod:`gisserver.parsers.ast` module provides the building blocks for that.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import typing
|
|
16
|
+
from enum import Enum
|
|
17
|
+
from xml.etree.ElementTree import Element, QName, TreeBuilder
|
|
18
|
+
|
|
19
|
+
from defusedxml.ElementTree import DefusedXMLParser, ParseError
|
|
20
|
+
|
|
21
|
+
from gisserver.exceptions import ExternalParsingError, wrap_parser_errors
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
__all__ = (
|
|
26
|
+
"xmlns",
|
|
27
|
+
"NSElement",
|
|
28
|
+
"parse_xml_from_string",
|
|
29
|
+
"parse_qname",
|
|
30
|
+
"split_ns",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class xmlns(Enum):
|
|
35
|
+
"""Common namespaces within WFS land.
|
|
36
|
+
Note these short aliases are arbitrary in XML syntax; the XML code may use any alias (such as ns0).
|
|
37
|
+
The full qualified name (e.g. ``<{http://www.opengis.net/gml/3.2}Point>``) is the actual tag name.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# XML standard
|
|
41
|
+
xml = "http://www.w3.org/XML/1998/namespace"
|
|
42
|
+
xsd = "http://www.w3.org/2001/XMLSchema"
|
|
43
|
+
xsi = "http://www.w3.org/2001/XMLSchema-instance"
|
|
44
|
+
xlink = "http://www.w3.org/1999/xlink"
|
|
45
|
+
|
|
46
|
+
# APIs by the Open Geospatial Consortium (OGC)
|
|
47
|
+
ogc = "http://www.opengis.net/ogc"
|
|
48
|
+
ows10 = "http://www.opengis.net/ows" # OGC Web Service (OWS) base classes
|
|
49
|
+
ows11 = "http://www.opengis.net/ows/1.1"
|
|
50
|
+
ows20 = "http://www.opengis.net/ows/2.0"
|
|
51
|
+
wms = "http://www.opengis.net/wms" # Web Map Service (WMS)
|
|
52
|
+
wcs = "http://www.opengis.net/wcs" # Web Coverage Service (WCS)
|
|
53
|
+
wps = "http://www.opengis.net/wps/1.0.0" # Web Processing Service (WPS)
|
|
54
|
+
wmts = "http://www.opengis.net/wmts/1.0" # Web Map Tile Service (WMTS)
|
|
55
|
+
wfs20 = "http://www.opengis.net/wfs/2.0" # Web Feature Service (WFS)
|
|
56
|
+
fes20 = "http://www.opengis.net/fes/2.0" # Filter Encoding Standard (FES)
|
|
57
|
+
gml21 = "http://www.opengis.net/gml"
|
|
58
|
+
gml32 = "http://www.opengis.net/gml/3.2"
|
|
59
|
+
|
|
60
|
+
# Internal aliases
|
|
61
|
+
ows = ows11 # alias to currently used version (WFS 2.0 uses OWS 1.1)
|
|
62
|
+
wfs = wfs20
|
|
63
|
+
fes = fes20
|
|
64
|
+
gml = gml32 # alias to latest version
|
|
65
|
+
xs = xsd # commonly used
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def as_ns_aliases(cls) -> dict[str, str]:
|
|
69
|
+
"""Map the namespaces as {alias: uri}"""
|
|
70
|
+
return {prefix: member.value for prefix, member in cls.__members__.items()}
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def as_namespaces(cls) -> dict[str, str]:
|
|
74
|
+
"""Map the namespaces as {uri: alias}. This will use the common aliases (without version numbers)"""
|
|
75
|
+
return {member.value: prefix for prefix, member in cls._member_map_.items()}
|
|
76
|
+
|
|
77
|
+
def __str__(self):
|
|
78
|
+
# Python 3.11+ has StrEnum for this.
|
|
79
|
+
return self.value
|
|
80
|
+
|
|
81
|
+
def qname(self, local_name) -> str:
|
|
82
|
+
"""Convert the tag name into a fully qualified name."""
|
|
83
|
+
return f"{{{self.value}}}{local_name}" # same as QName(..).text
|
|
84
|
+
|
|
85
|
+
def __contains__(self, tag: NSElement | str) -> bool:
|
|
86
|
+
"""Tell whether a given tag exists in this namespace"""
|
|
87
|
+
if isinstance(tag, NSElement):
|
|
88
|
+
tag = tag.tag
|
|
89
|
+
elif not isinstance(tag, str):
|
|
90
|
+
return False
|
|
91
|
+
return tag.startswith(f"{{{self.value}}}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NSElement(Element):
|
|
95
|
+
"""Custom XML element, which also exposes its original namespace aliases.
|
|
96
|
+
That information is needed to parse text content and attributes in WFS
|
|
97
|
+
that hold a QName value. For example:
|
|
98
|
+
|
|
99
|
+
* ``<ValueReference>ns0:elementName</ValueReference>``
|
|
100
|
+
* ``<Query typeNames="ns1:name">``
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, *args, **kwargs):
|
|
104
|
+
super().__init__(*args, **kwargs)
|
|
105
|
+
self.ns_aliases = {} # assigned by NSTreeBuilder, in {prefix: uri} format.
|
|
106
|
+
|
|
107
|
+
def parse_qname(self, qname: str) -> str:
|
|
108
|
+
"""Resolve an aliased QName value to its fully qualified name."""
|
|
109
|
+
return parse_qname(qname, self.ns_aliases)
|
|
110
|
+
|
|
111
|
+
def get_str_attribute(self, name: str) -> str:
|
|
112
|
+
"""Resolve an attribute, raise an error when it's missing."""
|
|
113
|
+
try:
|
|
114
|
+
return self.attrib[name]
|
|
115
|
+
except KeyError:
|
|
116
|
+
raise ExternalParsingError(
|
|
117
|
+
f"Element {self.tag} misses required attribute '{name}'"
|
|
118
|
+
) from None
|
|
119
|
+
|
|
120
|
+
def get_int_attribute(self, name: str, default=None) -> int | None:
|
|
121
|
+
"""Retrieve the integer value from an element attribute."""
|
|
122
|
+
value = self.attrib.get(name)
|
|
123
|
+
if value is None:
|
|
124
|
+
return default
|
|
125
|
+
|
|
126
|
+
with wrap_parser_errors(name, locator=name):
|
|
127
|
+
return int(value)
|
|
128
|
+
|
|
129
|
+
if typing.TYPE_CHECKING:
|
|
130
|
+
# Make sure the type checking knows the actual type of the elements.
|
|
131
|
+
def find(self, path: str, namespaces: dict[str, str] | None = None) -> NSElement | None:
|
|
132
|
+
return super().find(path, namespaces)
|
|
133
|
+
|
|
134
|
+
def findall(self, path: str, namespaces: dict[str, str] | None = None) -> list[NSElement]:
|
|
135
|
+
return super().findall(path, namespaces)
|
|
136
|
+
|
|
137
|
+
def __iter__(self) -> typing.Iterator[NSElement]:
|
|
138
|
+
return super().__iter__()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_qname(qname: str | None, ns_aliases: dict) -> str | None:
|
|
142
|
+
"""Resolve the QName aliases.
|
|
143
|
+
|
|
144
|
+
For example, ``gml:Point`` will be resolved to ``{http://www.opengis.net/gml/3.2}Point``.
|
|
145
|
+
The XML namespace prefix is a custom alias, so if "ns0" is declared as "http://www.opengis.net/gml/3.2",
|
|
146
|
+
it means "ns0:Point" should resolve to the same fully qualified type name.
|
|
147
|
+
"""
|
|
148
|
+
if not qname:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
if "/" in qname:
|
|
152
|
+
raise ExternalParsingError(f"Can't resolve QName '{qname}', this is an XPath notation.")
|
|
153
|
+
|
|
154
|
+
# Allow resolving @gml:id, remove the @ sigm.
|
|
155
|
+
is_attribute = qname[0] == "@"
|
|
156
|
+
if is_attribute:
|
|
157
|
+
qname = qname[1:]
|
|
158
|
+
|
|
159
|
+
prefix, _, localname = qname.rpartition(":")
|
|
160
|
+
if not prefix and ("" not in ns_aliases or ns_aliases[""] == xmlns.wfs20.value):
|
|
161
|
+
# In case a request uses <GetFeature xmlns="http://www.opengis.net/wfs/2.0">,
|
|
162
|
+
# non-prefixed QName values will be interpreted as existing in "wfs" namespace. Avoid that.
|
|
163
|
+
full_name = localname
|
|
164
|
+
else:
|
|
165
|
+
try:
|
|
166
|
+
uri = ns_aliases[prefix]
|
|
167
|
+
except KeyError:
|
|
168
|
+
logger.debug("Can't resolve QName '%s', available namespaces: %r", qname, ns_aliases)
|
|
169
|
+
raise ExternalParsingError(
|
|
170
|
+
f"Can't resolve QName '{qname}', an XML namespace declaration is missing."
|
|
171
|
+
) from None
|
|
172
|
+
|
|
173
|
+
full_name = QName(uri, localname).text
|
|
174
|
+
|
|
175
|
+
return f"@{full_name}" if is_attribute else full_name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class NSTreeBuilder(TreeBuilder):
|
|
179
|
+
"""Custom TreeBuilder to track namespaces."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, extra_ns_aliases, **kwargs):
|
|
182
|
+
super().__init__(element_factory=NSElement, **kwargs)
|
|
183
|
+
# A new stack level is added directly, as start_ns() is called before start()
|
|
184
|
+
if extra_ns_aliases:
|
|
185
|
+
self.ns_stack = [extra_ns_aliases, {}]
|
|
186
|
+
else:
|
|
187
|
+
self.ns_stack = [{}]
|
|
188
|
+
|
|
189
|
+
def start(self, tag, attrs):
|
|
190
|
+
super().start(tag, attrs)
|
|
191
|
+
self.ns_stack.append({}) # reserve stack for child tags
|
|
192
|
+
|
|
193
|
+
def start_ns(self, prefix, uri):
|
|
194
|
+
self.ns_stack[-1][prefix] = uri
|
|
195
|
+
|
|
196
|
+
def end(self, tag) -> Element:
|
|
197
|
+
element = super().end(tag)
|
|
198
|
+
self.ns_stack.pop() # clear reservation for child tags
|
|
199
|
+
element.ns_aliases = self._flatten_ns()
|
|
200
|
+
return element
|
|
201
|
+
|
|
202
|
+
def _flatten_ns(self) -> dict:
|
|
203
|
+
result = {}
|
|
204
|
+
for level in self.ns_stack:
|
|
205
|
+
result.update(level)
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def parse_xml_from_string(
|
|
210
|
+
xml_string: str | bytes, extra_ns_aliases: dict[str, str] | None = None
|
|
211
|
+
) -> NSElement:
|
|
212
|
+
"""Provide a safe and consistent way for parsing XML.
|
|
213
|
+
|
|
214
|
+
This uses a custom parser, so namespace aliases can be tracked.
|
|
215
|
+
All elements also have an :attr:`ns_aliases` attribute that exposes
|
|
216
|
+
the original alias that was used for the namespace.
|
|
217
|
+
"""
|
|
218
|
+
# Passing a custom parser potentially circumvents defusedxml,
|
|
219
|
+
# so note the parser is again configured in the same way:
|
|
220
|
+
parser = DefusedXMLParser(
|
|
221
|
+
target=NSTreeBuilder(extra_ns_aliases=extra_ns_aliases),
|
|
222
|
+
forbid_dtd=True,
|
|
223
|
+
forbid_entities=True,
|
|
224
|
+
forbid_external=True,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Not allowing DTD, do a primitive strip, and allow parsing to fail if it was mangled.
|
|
228
|
+
if isinstance(xml_string, str) and xml_string.startswith("<?"):
|
|
229
|
+
xml_string = xml_string[xml_string.find("?>") + 2 :]
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
parser.feed(xml_string)
|
|
233
|
+
return parser.close()
|
|
234
|
+
except ParseError as e:
|
|
235
|
+
# Offer consistent results for callers to check for invalid data.
|
|
236
|
+
logger.debug("Parsing XML error: %s: %s", e, xml_string)
|
|
237
|
+
raise ExternalParsingError(str(e)) from e
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def split_ns(xml_name: str) -> tuple[str | None, str]:
|
|
241
|
+
"""Split the element tag or attribute/text value into the namespace and
|
|
242
|
+
local name. The stdlib etree doesn't have the properties for this (lxml does).
|
|
243
|
+
"""
|
|
244
|
+
# Tags may start with a `{ns}`
|
|
245
|
+
if xml_name.startswith("{"):
|
|
246
|
+
end = xml_name.index("}")
|
|
247
|
+
return xml_name[1:end], xml_name[end + 1 :]
|
|
248
|
+
else:
|
|
249
|
+
return None, xml_name
|
gisserver/projection.py
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""In WFS, a "projection" is placed on top of the queried data.
|
|
2
|
+
|
|
3
|
+
It translates the incoming data into the subset of properties to display.
|
|
4
|
+
Practically, this code does inform and adjust the constructed QuerySet
|
|
5
|
+
to make sure it will provide only the actual fields that are part of the projection.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import logging
|
|
12
|
+
import operator
|
|
13
|
+
import typing
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from functools import cached_property
|
|
18
|
+
|
|
19
|
+
from gisserver.types import (
|
|
20
|
+
GeometryXsdElement,
|
|
21
|
+
XPathMatch,
|
|
22
|
+
XsdElement,
|
|
23
|
+
XsdNode,
|
|
24
|
+
XsdTypes,
|
|
25
|
+
_XsdElement_WithComplexType,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if typing.TYPE_CHECKING:
|
|
29
|
+
from django.contrib.gis.geos import GEOSGeometry
|
|
30
|
+
from django.db import models
|
|
31
|
+
|
|
32
|
+
from gisserver.features import FeatureType
|
|
33
|
+
from gisserver.geometries import CRS
|
|
34
|
+
from gisserver.parsers import fes20, wfs20
|
|
35
|
+
|
|
36
|
+
__all__ = (
|
|
37
|
+
"FeatureProjection",
|
|
38
|
+
"FeatureRelation",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FeatureProjection:
|
|
45
|
+
"""Tell which fields to access and render for a single feature.
|
|
46
|
+
This is inspired on 'fes:AbstractProjectionClause'.
|
|
47
|
+
|
|
48
|
+
Instead of walking over the full XSD object tree,
|
|
49
|
+
this object wraps that and makes sure only the actual requested fields are used.
|
|
50
|
+
When a PROPERTYNAME is used in the request, this will limit
|
|
51
|
+
which fields to retrieve, which to prefetch, and which to render.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
feature_type: FeatureType
|
|
55
|
+
xsd_root_elements: list[XsdElement]
|
|
56
|
+
xsd_child_nodes: dict[XsdElement | None, list[XsdElement]]
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
feature_types: list[FeatureType],
|
|
61
|
+
property_names: list[wfs20.PropertyName] | None = None,
|
|
62
|
+
value_reference: fes20.ValueReference | None = None,
|
|
63
|
+
output_crs: CRS | None = None,
|
|
64
|
+
output_standalone: bool = False,
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
:param feature_types: The feature types used by this query. Typically one, unless there is a JOIN.
|
|
69
|
+
:param property_names: Limited list of fields to render only.
|
|
70
|
+
:param value_reference: Single element to display fo GetPropertyValue
|
|
71
|
+
:param output_crs: Which coordinate reference system to use for geometry data.
|
|
72
|
+
:param output_standalone: Whether the ``<wfs:
|
|
73
|
+
"""
|
|
74
|
+
self.feature_types = feature_types
|
|
75
|
+
self.feature_type = feature_types[0] # JOIN still not supported.
|
|
76
|
+
self.property_names = property_names
|
|
77
|
+
self.value_reference = value_reference
|
|
78
|
+
self.output_crs: CRS = output_crs or self.feature_type.crs
|
|
79
|
+
self.output_standalone = output_standalone # for GetFeatureById
|
|
80
|
+
self._extra_matches = []
|
|
81
|
+
|
|
82
|
+
if property_names:
|
|
83
|
+
# Only a selection of the tree will be rendered.
|
|
84
|
+
# Discover which elements should be rendered.
|
|
85
|
+
child_nodes = self._get_child_nodes_subset()
|
|
86
|
+
self.xsd_root_elements = child_nodes.pop(None) # Pop it to avoid recursion risks
|
|
87
|
+
self.xsd_child_nodes = child_nodes
|
|
88
|
+
else:
|
|
89
|
+
# All elements of the tree will be rendered, retrieve all elements.
|
|
90
|
+
# Note xsd_child_nodes can be altered below, so don't assign it a cached property.
|
|
91
|
+
self.xsd_root_elements = self.feature_type.xsd_type.elements_including_base
|
|
92
|
+
self.xsd_child_nodes = {
|
|
93
|
+
node: node.type.elements
|
|
94
|
+
for node in self.feature_type.xsd_type.all_complex_elements
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
def _get_child_nodes_subset(self) -> dict[XsdElement | None, list[XsdElement]]:
|
|
98
|
+
"""Translate the PROPERTYNAME into a dictionary of nodes to render."""
|
|
99
|
+
child_nodes = defaultdict(list)
|
|
100
|
+
for xpath_match in self.xpath_matches:
|
|
101
|
+
# Make sure the tree is known beforehand, and it has no duplicate parent nodes.
|
|
102
|
+
# (e.g. PROPERTYNAME=node/child1,node/child2)
|
|
103
|
+
parent = None
|
|
104
|
+
for xsd_node in xpath_match.nodes:
|
|
105
|
+
if xsd_node not in child_nodes[parent]:
|
|
106
|
+
child_nodes[parent].append(xsd_node)
|
|
107
|
+
parent = xsd_node
|
|
108
|
+
|
|
109
|
+
return dict(child_nodes)
|
|
110
|
+
|
|
111
|
+
def add_field(self, xsd_element: XsdElement):
|
|
112
|
+
"""Restore the retrieval of a field that was not asked for in the original query."""
|
|
113
|
+
if xsd_element.type.is_complex_type:
|
|
114
|
+
raise NotImplementedError("Can't restore nested elements right now")
|
|
115
|
+
if not self.property_names or xsd_element in self.xsd_root_elements:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
logger.debug("Projection: added %s", xsd_element)
|
|
119
|
+
self.xsd_root_elements.append(xsd_element)
|
|
120
|
+
self._extra_matches.append(XPathMatch(self.feature_type, [xsd_element], ""))
|
|
121
|
+
_clear_cached_properties(self)
|
|
122
|
+
|
|
123
|
+
def remove_fields(self, predicate: Callable[[XsdElement], bool]):
|
|
124
|
+
"""Remove elements from the projection based on a given rule.
|
|
125
|
+
This helps to remove M2M and Array elements for CSV output for example.
|
|
126
|
+
|
|
127
|
+
Make sure this function is called as early as possible,
|
|
128
|
+
before other logic already read these attributes.
|
|
129
|
+
"""
|
|
130
|
+
self.xsd_root_elements, removed_nodes = _partition(predicate, self.xsd_root_elements)
|
|
131
|
+
|
|
132
|
+
for xsd_child_root, xsd_child_elements in self.xsd_child_nodes.items():
|
|
133
|
+
if xsd_child_root in removed_nodes:
|
|
134
|
+
continue # already removed the top-level, don't bother checking here
|
|
135
|
+
|
|
136
|
+
keep_children, remove_children = _partition(predicate, xsd_child_elements)
|
|
137
|
+
self.xsd_child_nodes[xsd_child_root] = keep_children
|
|
138
|
+
removed_nodes.update(remove_children)
|
|
139
|
+
|
|
140
|
+
# Remove complete trees if their parent was removed.
|
|
141
|
+
for xsd_child_root in list(self.xsd_child_nodes):
|
|
142
|
+
if xsd_child_root in removed_nodes:
|
|
143
|
+
self.xsd_child_nodes.pop(xsd_child_root, None)
|
|
144
|
+
|
|
145
|
+
if logger.isEnabledFor(logging.DEBUG) and removed_nodes:
|
|
146
|
+
logger.debug("Projection: removed %s", removed_nodes)
|
|
147
|
+
|
|
148
|
+
_clear_cached_properties(self)
|
|
149
|
+
|
|
150
|
+
@cached_property
|
|
151
|
+
def _geometry_getter(self):
|
|
152
|
+
return operator.attrgetter(self.main_geometry_element.orm_path)
|
|
153
|
+
|
|
154
|
+
def get_main_geometry_value(self, instance: models.Model) -> GEOSGeometry | None:
|
|
155
|
+
"""Efficiently retrieve the value for the main geometry element."""
|
|
156
|
+
if self.main_geometry_element is None:
|
|
157
|
+
return None
|
|
158
|
+
else:
|
|
159
|
+
return self._geometry_getter(instance)
|
|
160
|
+
|
|
161
|
+
@cached_property
|
|
162
|
+
def xpath_matches(self) -> list[XPathMatch]:
|
|
163
|
+
"""Resolve which elements the property names point to"""
|
|
164
|
+
if not self.property_names:
|
|
165
|
+
raise RuntimeError("This method is only useful for propertyname projections.")
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
self.feature_type.resolve_element(property_name.xpath, property_name.xpath_ns_aliases)
|
|
169
|
+
for property_name in self.property_names
|
|
170
|
+
] + self._extra_matches
|
|
171
|
+
|
|
172
|
+
@cached_property
|
|
173
|
+
def all_elements(self) -> list[XsdElement]:
|
|
174
|
+
"""Return ALL elements of all levels to render."""
|
|
175
|
+
return self.xsd_root_elements + list(
|
|
176
|
+
itertools.chain.from_iterable(self.xsd_child_nodes.values())
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@cached_property
|
|
180
|
+
def all_geometry_elements(self) -> list[GeometryXsdElement]:
|
|
181
|
+
"""Tell which GML elements will be hit."""
|
|
182
|
+
return [e for e in self.all_elements if e.type.is_geometry]
|
|
183
|
+
|
|
184
|
+
@cached_property
|
|
185
|
+
def all_complex_elements(self) -> list[_XsdElement_WithComplexType]:
|
|
186
|
+
"""Return ALL tree elements with a complex type, including child elements with a complex types."""
|
|
187
|
+
return list(self.xsd_child_nodes.keys())
|
|
188
|
+
|
|
189
|
+
@cached_property
|
|
190
|
+
def all_flattened_elements(self) -> list[XsdElement]:
|
|
191
|
+
"""Shortcut to get ALL tree elements with a flattened model attribute"""
|
|
192
|
+
if not self.property_names:
|
|
193
|
+
return self.feature_type.xsd_type.flattened_elements
|
|
194
|
+
else:
|
|
195
|
+
return [e for e in self.all_elements if e.is_flattened]
|
|
196
|
+
|
|
197
|
+
@cached_property
|
|
198
|
+
def has_bounded_by(self) -> bool:
|
|
199
|
+
"""Tell whether the <gml:boundedBy> element is included for rendering."""
|
|
200
|
+
return any(e.type is XsdTypes.gmlBoundingShapeType for e in self.xsd_root_elements)
|
|
201
|
+
|
|
202
|
+
@cached_property
|
|
203
|
+
def main_geometry_element(self) -> GeometryXsdElement | None:
|
|
204
|
+
"""Return the field used to describe the geometry of the feature.
|
|
205
|
+
When the projection excludes the geometry, ``None`` is returned.
|
|
206
|
+
"""
|
|
207
|
+
geo_element = self.feature_type.main_geometry_element
|
|
208
|
+
if self.property_names and geo_element not in self.all_elements:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
return geo_element
|
|
212
|
+
|
|
213
|
+
@cached_property
|
|
214
|
+
def geometry_elements(self) -> list[GeometryXsdElement]:
|
|
215
|
+
"""Tell which GML elements will be hit at the root-level."""
|
|
216
|
+
return [
|
|
217
|
+
e
|
|
218
|
+
for e in self.xsd_root_elements
|
|
219
|
+
if e.type.is_geometry and e.type is not XsdTypes.gmlBoundingShapeType
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
@cached_property
|
|
223
|
+
def property_value_node(self) -> XsdNode:
|
|
224
|
+
"""For GetPropertyValue, resolve the element that is rendered."""
|
|
225
|
+
if self.value_reference is None:
|
|
226
|
+
raise RuntimeError("This method is only useful for GetPropertyValue calls.")
|
|
227
|
+
return (
|
|
228
|
+
self.feature_types[0]
|
|
229
|
+
.resolve_element(self.value_reference.xpath, self.value_reference.xpath_ns_aliases)
|
|
230
|
+
.child
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
@cached_property
|
|
234
|
+
def orm_relations(self) -> list[FeatureRelation]:
|
|
235
|
+
"""Tell which fields will be retrieved from related fields.
|
|
236
|
+
|
|
237
|
+
This gives an object layout based on the XSD elements,
|
|
238
|
+
that can be used for prefetching data.
|
|
239
|
+
"""
|
|
240
|
+
related_models: dict[str, type[models.Model]] = {}
|
|
241
|
+
fields: dict[str, set[XsdElement]] = defaultdict(set)
|
|
242
|
+
elements = defaultdict(list)
|
|
243
|
+
|
|
244
|
+
# Check all elements that render as "dotted" flattened relation
|
|
245
|
+
for xsd_element in self.all_flattened_elements:
|
|
246
|
+
if xsd_element.source is not None:
|
|
247
|
+
# Split "relation.field" notation into path, and take the field as child attribute.
|
|
248
|
+
obj_path, field = xsd_element.orm_relation
|
|
249
|
+
elements[obj_path].append(xsd_element)
|
|
250
|
+
fields[obj_path].add(xsd_element)
|
|
251
|
+
# field is already on relation:
|
|
252
|
+
related_models[obj_path] = xsd_element.source.model
|
|
253
|
+
|
|
254
|
+
# Check all elements that render as "nested" complex type:
|
|
255
|
+
for xsd_element in self.all_complex_elements:
|
|
256
|
+
# The complex element itself points to the root of the path,
|
|
257
|
+
# all sub elements become the child attributes.
|
|
258
|
+
obj_path = xsd_element.orm_path
|
|
259
|
+
elements[obj_path].append(xsd_element)
|
|
260
|
+
fields[obj_path] = {
|
|
261
|
+
f
|
|
262
|
+
for f in self.xsd_child_nodes[xsd_element]
|
|
263
|
+
if not f.is_many or f.is_array # exclude M2M, but include ArrayField
|
|
264
|
+
}
|
|
265
|
+
if xsd_element.source:
|
|
266
|
+
# field references a related object:
|
|
267
|
+
related_models[obj_path] = xsd_element.source.related_model
|
|
268
|
+
|
|
269
|
+
return [
|
|
270
|
+
FeatureRelation(
|
|
271
|
+
orm_path=obj_path,
|
|
272
|
+
sub_fields=sub_fields,
|
|
273
|
+
related_model=related_models.get(obj_path),
|
|
274
|
+
xsd_elements=elements[obj_path],
|
|
275
|
+
)
|
|
276
|
+
for obj_path, sub_fields in fields.items()
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
@cached_property
|
|
280
|
+
def only_fields(self) -> list[str]:
|
|
281
|
+
"""Tell which fields to limit the queryset to.
|
|
282
|
+
This excludes M2M fields because those are not part of the local model data.
|
|
283
|
+
"""
|
|
284
|
+
if self.property_names is not None:
|
|
285
|
+
return [
|
|
286
|
+
# TODO: While ORM rel__child paths can be passed to .only(),
|
|
287
|
+
# these may not be accurately applied to foreign keys yet.
|
|
288
|
+
# Also, this is bypassed by our generated Prefetch() objects.
|
|
289
|
+
xpath_match.orm_path
|
|
290
|
+
for xpath_match in self.xpath_matches
|
|
291
|
+
if not xpath_match.is_many or xpath_match.child.is_array
|
|
292
|
+
]
|
|
293
|
+
else:
|
|
294
|
+
# Also limit the queryset to the actual fields that are shown.
|
|
295
|
+
# No need to request more data
|
|
296
|
+
return [
|
|
297
|
+
f.orm_field
|
|
298
|
+
for f in self.feature_type.xsd_type.elements
|
|
299
|
+
if not f.is_many or f.is_array # avoid M2M fields for .only(), but keep ArrayField
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass
|
|
304
|
+
class FeatureRelation:
|
|
305
|
+
"""Tell which related fields are queried by the feature.
|
|
306
|
+
Each dict holds an ORM-path, with the relevant sub-elements.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
#: The ORM path that is queried for this particular relation
|
|
310
|
+
orm_path: str
|
|
311
|
+
#: The fields that will be retrieved for that path (limited by the projection)
|
|
312
|
+
sub_fields: set[XsdElement]
|
|
313
|
+
#: The model that is accessed for this relation (if set)
|
|
314
|
+
related_model: type[models.Model] | None
|
|
315
|
+
#: The source elements that access this relation. Could be multiple for flattened relations.
|
|
316
|
+
xsd_elements: list[XsdElement]
|
|
317
|
+
|
|
318
|
+
@cached_property
|
|
319
|
+
def _local_model_field_names(self) -> list[str]:
|
|
320
|
+
"""Tell which local fields of the model will be accessed by this feature."""
|
|
321
|
+
return [
|
|
322
|
+
model_field.name
|
|
323
|
+
for field in self.sub_fields
|
|
324
|
+
if not (model_field := field.source).many_to_many and not model_field.one_to_many
|
|
325
|
+
] + self._local_backlink_field_names
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def _local_backlink_field_names(self) -> list[str]:
|
|
329
|
+
# When this relation is retrieved through a ManyToOneRel (reverse FK),
|
|
330
|
+
# the prefetch_related() also needs to have the original foreign key
|
|
331
|
+
# in order to link all prefetches to the proper parent instance.
|
|
332
|
+
return [
|
|
333
|
+
xsd_element.source.field.name
|
|
334
|
+
for xsd_element in self.xsd_elements
|
|
335
|
+
if xsd_element.source is not None and xsd_element.source.one_to_many
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def geometry_elements(self) -> list[GeometryXsdElement]:
|
|
340
|
+
"""Tell which geometry elements this relation will access."""
|
|
341
|
+
return [f for f in self.sub_fields if f.type.is_geometry]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _partition(predicate, items: list) -> tuple[list, set]:
|
|
345
|
+
"""Semi-efficient way to split a list into items that match/don't match the condition."""
|
|
346
|
+
# more_itertools.partition() is faster, but that can be neglected with a short list.
|
|
347
|
+
return list(itertools.filterfalse(predicate, items)), set(filter(predicate, items))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _clear_cached_properties(object):
|
|
351
|
+
"""Remove the caches from the cached_property decorator on an object."""
|
|
352
|
+
cls = object.__class__
|
|
353
|
+
for property_name in list(object.__dict__):
|
|
354
|
+
if (prop := getattr(cls, property_name, None)) is not None and isinstance(
|
|
355
|
+
prop, cached_property
|
|
356
|
+
):
|
|
357
|
+
del object.__dict__[property_name]
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
.meta-page {
|
|
2
|
+
font-family: Arial, sans-serif;
|
|
3
|
+
line-height: 1.5;
|
|
4
|
+
margin: 0 2rem 4rem 2rem;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.meta-page h2 { margin: 2rem 0 0.5rem 0; }
|
|
8
|
+
.meta-page h3 { margin: 1rem 0 0.5rem 0; }
|
|
9
|
+
.meta-page p { margin: 0 0 1rem 0; }
|
|
10
|
+
|
|
11
|
+
tbody th { text-align: left; padding-right: 2rem; }
|
|
12
|
+
tbody td { padding-right: 2rem; }
|
|
2
13
|
.complex-level-1 th { padding-left: 16px; }
|
|
3
14
|
.complex-level-2 th { padding-left: 32px; }
|
|
4
15
|
.complex-level-3 th { padding-left: 48px; }
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{% block link %}<link rel="stylesheet" type="text/css" href="{% static 'gisserver/index.css' %}">{% endblock %}
|
|
7
7
|
{% block extrahead %}{% endblock %}
|
|
8
8
|
</head>
|
|
9
|
-
<body>
|
|
9
|
+
<body class="{% block body-class %}meta-page{% endblock %}">
|
|
10
10
|
<header>
|
|
11
11
|
{% block header %}{% include "gisserver/service_description.html" %}{% endblock %}
|
|
12
12
|
</header>
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
{% if service_description.abstract %}{{ service_description.abstract|linebreaks }}{% endif %}
|
|
4
4
|
|
|
5
5
|
{% if service_description.keywords %}
|
|
6
|
-
<p>{% trans "Keywords" %}: {
|
|
6
|
+
<p>{% trans "Keywords" %}: {{ service_description.keywords|join:", " }}</p>
|
|
7
7
|
{% endif %}
|
|
8
8
|
{% if service_description.provider_name %}
|
|
9
|
-
{% trans "Provider" %}: {% if service_description.provider_site %}<a href="{{ service_description.provider_site }}">{% endif %}{{ service_description.provider_name }}{% if service_description.provider_site %}</a>{% endif %}
|
|
9
|
+
<p>{% trans "Provider" %}: {% if service_description.provider_site %}<a href="{{ service_description.provider_site }}">{% endif %}{{ service_description.provider_name }}{% if service_description.provider_site %}</a></p>{% endif %}
|
|
10
10
|
{% if service_description.contact_person %}<p>{% trans "Contact" %}: {{ service_description.contact_person }}</p>{% endif %}
|
|
11
11
|
{% endif %}
|
|
12
12
|
{% if connect_url %}
|