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