django-gisserver 1.5.0__py3-none-any.whl → 2.1__py3-none-any.whl

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