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.
Files changed (74) hide show
  1. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/METADATA +14 -4
  2. django_gisserver-2.0.dist-info/RECORD +66 -0
  3. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/compat.py +23 -0
  6. gisserver/conf.py +7 -0
  7. gisserver/db.py +56 -47
  8. gisserver/exceptions.py +26 -2
  9. gisserver/extensions/__init__.py +4 -0
  10. gisserver/{parsers/fes20 → extensions}/functions.py +10 -4
  11. gisserver/extensions/queries.py +261 -0
  12. gisserver/features.py +220 -156
  13. gisserver/geometries.py +32 -37
  14. gisserver/management/__init__.py +0 -0
  15. gisserver/management/commands/__init__.py +0 -0
  16. gisserver/management/commands/loadgeojson.py +291 -0
  17. gisserver/operations/base.py +122 -308
  18. gisserver/operations/wfs20.py +423 -337
  19. gisserver/output/__init__.py +9 -48
  20. gisserver/output/base.py +178 -139
  21. gisserver/output/csv.py +65 -74
  22. gisserver/output/geojson.py +34 -35
  23. gisserver/output/gml32.py +254 -246
  24. gisserver/output/iters.py +207 -0
  25. gisserver/output/results.py +52 -26
  26. gisserver/output/stored.py +143 -0
  27. gisserver/output/utils.py +75 -170
  28. gisserver/output/xmlschema.py +85 -46
  29. gisserver/parsers/__init__.py +10 -10
  30. gisserver/parsers/ast.py +320 -0
  31. gisserver/parsers/fes20/__init__.py +13 -27
  32. gisserver/parsers/fes20/expressions.py +82 -38
  33. gisserver/parsers/fes20/filters.py +111 -43
  34. gisserver/parsers/fes20/identifiers.py +44 -26
  35. gisserver/parsers/fes20/lookups.py +144 -0
  36. gisserver/parsers/fes20/operators.py +331 -127
  37. gisserver/parsers/fes20/sorting.py +104 -33
  38. gisserver/parsers/gml/__init__.py +12 -11
  39. gisserver/parsers/gml/base.py +5 -2
  40. gisserver/parsers/gml/geometries.py +69 -35
  41. gisserver/parsers/ows/__init__.py +25 -0
  42. gisserver/parsers/ows/kvp.py +190 -0
  43. gisserver/parsers/ows/requests.py +158 -0
  44. gisserver/parsers/query.py +175 -0
  45. gisserver/parsers/values.py +26 -0
  46. gisserver/parsers/wfs20/__init__.py +37 -0
  47. gisserver/parsers/wfs20/adhoc.py +245 -0
  48. gisserver/parsers/wfs20/base.py +143 -0
  49. gisserver/parsers/wfs20/projection.py +103 -0
  50. gisserver/parsers/wfs20/requests.py +482 -0
  51. gisserver/parsers/wfs20/stored.py +192 -0
  52. gisserver/parsers/xml.py +249 -0
  53. gisserver/projection.py +357 -0
  54. gisserver/static/gisserver/index.css +12 -1
  55. gisserver/templates/gisserver/index.html +1 -1
  56. gisserver/templates/gisserver/service_description.html +2 -2
  57. gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml +9 -9
  58. gisserver/templates/gisserver/wfs/feature_field.html +2 -2
  59. gisserver/templatetags/gisserver_tags.py +20 -0
  60. gisserver/types.py +322 -259
  61. gisserver/views.py +198 -56
  62. django_gisserver-1.5.0.dist-info/RECORD +0 -54
  63. gisserver/parsers/base.py +0 -149
  64. gisserver/parsers/fes20/query.py +0 -285
  65. gisserver/parsers/tags.py +0 -102
  66. gisserver/queries/__init__.py +0 -37
  67. gisserver/queries/adhoc.py +0 -185
  68. gisserver/queries/base.py +0 -186
  69. gisserver/queries/projection.py +0 -240
  70. gisserver/queries/stored.py +0 -206
  71. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  72. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  73. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/LICENSE +0 -0
  74. {django_gisserver-1.5.0.dist-info → django_gisserver-2.0.dist-info}/top_level.txt +0 -0
@@ -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
@@ -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
- tbody th { text-align: left; }
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" %}: {% for keyword in service_description.keywords %}{{ keyword }}{% if not forloop.last %}, {% endif %}{% endfor %}</p>
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 %}