django-gisserver 2.0__py3-none-any.whl → 2.1.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 (56) hide show
  1. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/METADATA +27 -10
  2. django_gisserver-2.1.1.dist-info/RECORD +68 -0
  3. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/WHEEL +1 -1
  4. gisserver/__init__.py +1 -1
  5. gisserver/conf.py +23 -1
  6. gisserver/crs.py +452 -0
  7. gisserver/db.py +78 -6
  8. gisserver/exceptions.py +106 -2
  9. gisserver/extensions/functions.py +122 -28
  10. gisserver/extensions/queries.py +15 -10
  11. gisserver/features.py +46 -33
  12. gisserver/geometries.py +64 -306
  13. gisserver/management/commands/loadgeojson.py +41 -21
  14. gisserver/operations/base.py +11 -7
  15. gisserver/operations/wfs20.py +31 -93
  16. gisserver/output/__init__.py +6 -2
  17. gisserver/output/base.py +28 -13
  18. gisserver/output/csv.py +18 -6
  19. gisserver/output/geojson.py +7 -6
  20. gisserver/output/gml32.py +86 -27
  21. gisserver/output/results.py +25 -39
  22. gisserver/output/utils.py +9 -2
  23. gisserver/parsers/ast.py +177 -68
  24. gisserver/parsers/fes20/__init__.py +76 -4
  25. gisserver/parsers/fes20/expressions.py +97 -27
  26. gisserver/parsers/fes20/filters.py +9 -6
  27. gisserver/parsers/fes20/identifiers.py +27 -7
  28. gisserver/parsers/fes20/lookups.py +8 -6
  29. gisserver/parsers/fes20/operators.py +101 -49
  30. gisserver/parsers/fes20/sorting.py +14 -6
  31. gisserver/parsers/gml/__init__.py +10 -19
  32. gisserver/parsers/gml/base.py +32 -14
  33. gisserver/parsers/gml/geometries.py +54 -21
  34. gisserver/parsers/ows/kvp.py +10 -2
  35. gisserver/parsers/ows/requests.py +6 -4
  36. gisserver/parsers/query.py +6 -2
  37. gisserver/parsers/values.py +61 -4
  38. gisserver/parsers/wfs20/__init__.py +2 -0
  39. gisserver/parsers/wfs20/adhoc.py +28 -18
  40. gisserver/parsers/wfs20/base.py +12 -7
  41. gisserver/parsers/wfs20/projection.py +3 -3
  42. gisserver/parsers/wfs20/requests.py +1 -0
  43. gisserver/parsers/wfs20/stored.py +3 -2
  44. gisserver/parsers/xml.py +12 -0
  45. gisserver/projection.py +17 -7
  46. gisserver/static/gisserver/index.css +27 -6
  47. gisserver/templates/gisserver/base.html +15 -0
  48. gisserver/templates/gisserver/index.html +10 -16
  49. gisserver/templates/gisserver/service_description.html +12 -6
  50. gisserver/templates/gisserver/wfs/feature_field.html +1 -1
  51. gisserver/templates/gisserver/wfs/feature_type.html +44 -13
  52. gisserver/types.py +152 -82
  53. gisserver/views.py +47 -24
  54. django_gisserver-2.0.dist-info/RECORD +0 -66
  55. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info/licenses}/LICENSE +0 -0
  56. {django_gisserver-2.0.dist-info → django_gisserver-2.1.1.dist-info}/top_level.txt +0 -0
gisserver/types.py CHANGED
@@ -3,7 +3,7 @@
3
3
  These types are the internal schema definition, and the foundation for all output generation.
4
4
 
5
5
  The end-users of this library typically create a WFS feature type definition by using
6
- the :class:~gisserver.features.FeatureType` / :class:`~gisserver.features.FeatureField` classes.
6
+ the :class:`~gisserver.features.FeatureType` / :class:`~gisserver.features.FeatureField` classes.
7
7
 
8
8
  The feature type classes use the model metadata to construct the internal XMLSchema structure.
9
9
  Nearly all WFS requests are handled by walking this structure (like ``DescribeFeatureType``
@@ -30,9 +30,10 @@ import logging
30
30
  import operator
31
31
  import re
32
32
  from dataclasses import dataclass, field
33
+ from datetime import date, datetime, time, timedelta
33
34
  from decimal import Decimal as D
34
35
  from enum import Enum
35
- from functools import cached_property, reduce
36
+ from functools import cached_property
36
37
  from typing import TYPE_CHECKING, Literal
37
38
 
38
39
  import django
@@ -42,21 +43,21 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
42
43
  from django.db import models
43
44
  from django.db.models import Q
44
45
  from django.db.models.fields.related import RelatedField
45
- from django.utils import dateparse
46
46
 
47
47
  from gisserver.compat import ArrayField, GeneratedField
48
+ from gisserver.crs import CRS
48
49
  from gisserver.exceptions import ExternalParsingError, OperationProcessingFailed
49
- from gisserver.geometries import CRS, BoundingBox
50
+ from gisserver.geometries import BoundingBox
50
51
  from gisserver.parsers import values
51
52
  from gisserver.parsers.xml import parse_qname, split_ns, xmlns
52
53
 
53
54
  logger = logging.getLogger(__name__)
54
- _unbounded = Literal["unbounded"]
55
55
 
56
56
  __all__ = [
57
57
  "GeometryXsdElement",
58
58
  "GmlIdAttribute",
59
59
  "GmlNameElement",
60
+ "GmlBoundedByElement",
60
61
  "ORMPath",
61
62
  "XPathMatch",
62
63
  "XsdAnyType",
@@ -98,13 +99,13 @@ class XsdAnyType:
98
99
 
99
100
 
100
101
  class XsdTypes(XsdAnyType, Enum):
101
- """Brief enumeration of basic XMLSchema types.
102
+ """Brief enumeration of common XMLSchema types.
102
103
 
103
104
  The :class:`XsdElement` and :class:`XsdAttribute` can use these enum members
104
105
  to indicate their value is a well-known XML Schema. Some GML types are included as well.
105
106
 
106
- The default namespace is the "xs:" (XMLSchema).
107
- Based on https://www.w3.org/TR/xmlschema-2/#built-in-datatypes
107
+ Each member value is a fully qualified XML name.
108
+ The output rendering will convert these to the chosen prefixes.
108
109
  """
109
110
 
110
111
  anyType = xmlns.xs.qname("anyType") # not "xsd:any", that is an element.
@@ -149,16 +150,25 @@ class XsdTypes(XsdAnyType, Enum):
149
150
  gmlMultiCurvePropertyType = xmlns.gml.qname("MultiCurvePropertyType")
150
151
  gmlMultiGeometryPropertyType = xmlns.gml.qname("MultiGeometryPropertyType")
151
152
 
152
- # Other typical GML values
153
+ # Other typical GML values:
154
+
155
+ #: The type for ``<gml:name>`` elements.
153
156
  gmlCodeType = xmlns.gml.qname("CodeType") # for <gml:name>
154
- gmlBoundingShapeType = xmlns.gml.qname("BoundingShapeType") # for <gml:boundedBy>
155
157
 
156
- #: A direct geometry value (used as function argument type)
158
+ #: The type for ``<gml:boundedBy>`` elements.
159
+ gmlBoundingShapeType = xmlns.gml.qname("BoundingShapeType")
160
+
161
+ #: The type for ``<gml:Envelope>`` elements, sometimes used as function argument type.
162
+ gmlEnvelopeType = xmlns.gml.qname("EnvelopeType")
163
+
164
+ #: A direct geometry value, sometimes used as function argument type.
157
165
  gmlAbstractGeometryType = xmlns.gml.qname("AbstractGeometryType")
158
166
 
159
167
  #: A feature that has a gml:name and gml:boundedBy as possible child element.
160
168
  gmlAbstractFeatureType = xmlns.gml.qname("AbstractFeatureType")
161
- gmlAbstractGMLType = xmlns.gml.qname("AbstractGMLType") # base of gml:AbstractFeatureType
169
+
170
+ #: The base of gml:AbstractFeatureType
171
+ gmlAbstractGMLType = xmlns.gml.qname("AbstractGMLType")
162
172
 
163
173
  def __str__(self):
164
174
  return self.value
@@ -184,9 +194,12 @@ class XsdTypes(XsdAnyType, Enum):
184
194
  raise NotImplementedError(f'Casting to "{self}" is not implemented.') from None
185
195
 
186
196
  def to_python(self, raw_value):
187
- """Convert a raw string value to this type representation"""
188
- if self.is_geometry:
189
- # Leave complex values as-is.
197
+ """Convert a raw string value to this type representation.
198
+
199
+ :raises ExternalParsingError: When the value can't be converted to the proper type.
200
+ """
201
+ if self.is_geometry or isinstance(raw_value, TYPES_AS_PYTHON[self]):
202
+ # Detect when the value was already parsed, no need to reparse a date for example.
190
203
  return raw_value
191
204
 
192
205
  try:
@@ -195,6 +208,7 @@ class XsdTypes(XsdAnyType, Enum):
195
208
  raise # subclass of ValueError so explicitly caught and reraised
196
209
  except (TypeError, ValueError, ArithmeticError) as e:
197
210
  # ArithmeticError is base of DecimalException
211
+ logger.debug("Parsing error for %r: %s", raw_value, e)
198
212
  name = self.name if self.namespace == xmlns.xsd.value else self.value
199
213
  raise ExternalParsingError(f"Can't cast '{raw_value}' to {name}.") from e
200
214
 
@@ -221,12 +235,12 @@ def _as_is(v):
221
235
  return v
222
236
 
223
237
 
224
- TYPES_TO_PYTHON = {
225
- XsdTypes.date: dateparse.parse_date,
226
- XsdTypes.dateTime: values.parse_iso_datetime,
227
- XsdTypes.time: dateparse.parse_time,
228
- XsdTypes.string: _as_is,
229
- XsdTypes.boolean: values.parse_bool,
238
+ TYPES_AS_PYTHON = {
239
+ XsdTypes.date: date,
240
+ XsdTypes.dateTime: datetime,
241
+ XsdTypes.time: time,
242
+ XsdTypes.string: str,
243
+ XsdTypes.boolean: bool,
230
244
  XsdTypes.integer: int,
231
245
  XsdTypes.int: int,
232
246
  XsdTypes.long: int,
@@ -236,9 +250,28 @@ TYPES_TO_PYTHON = {
236
250
  XsdTypes.unsignedLong: int,
237
251
  XsdTypes.unsignedShort: int,
238
252
  XsdTypes.unsignedByte: int,
239
- XsdTypes.float: D,
253
+ XsdTypes.float: D, # auto_cast() always converts to decimal
240
254
  XsdTypes.double: D,
241
255
  XsdTypes.decimal: D,
256
+ XsdTypes.duration: timedelta,
257
+ XsdTypes.nonNegativeInteger: int,
258
+ XsdTypes.gYear: int,
259
+ XsdTypes.hexBinary: bytes,
260
+ XsdTypes.base64Binary: bytes,
261
+ XsdTypes.token: str,
262
+ XsdTypes.language: str,
263
+ XsdTypes.gmlCodeType: str,
264
+ XsdTypes.anyType: type(Ellipsis),
265
+ }
266
+
267
+ TYPES_TO_PYTHON = {
268
+ **TYPES_AS_PYTHON,
269
+ XsdTypes.date: values.parse_iso_date,
270
+ XsdTypes.dateTime: values.parse_iso_datetime,
271
+ XsdTypes.time: values.parse_iso_time,
272
+ XsdTypes.string: _as_is,
273
+ XsdTypes.boolean: values.parse_bool,
274
+ XsdTypes.duration: values.parse_iso_duration,
242
275
  XsdTypes.gmlCodeType: _as_is,
243
276
  XsdTypes.anyType: values.auto_cast,
244
277
  }
@@ -252,11 +285,16 @@ class XsdNode:
252
285
  parse query input and read model attributes to write as output.
253
286
  """
254
287
 
288
+ #: Whether this node is an :class:`XsdAttribute` (avoids slow ``isinstance()`` checks)
255
289
  is_attribute = False
290
+ #: Whether this node can occur multiple times.
256
291
  is_many = False
257
292
 
293
+ #: The local name of the XML element
258
294
  name: str
259
- type: XsdAnyType # Both XsdComplexType and XsdType are allowed
295
+
296
+ #: The data type of the element/attribute, both :class:`XsdComplexType` and :class:`XsdTypes` are allowed.
297
+ type: XsdAnyType
260
298
 
261
299
  #: XML Namespace of the element
262
300
  namespace: xmlns | str | None
@@ -269,7 +307,7 @@ class XsdNode:
269
307
  #: This supports dot notation to access related attributes.
270
308
  model_attribute: str | None
271
309
 
272
- #: A link back to the parent that described the featuyre this node is a part of.
310
+ #: A link back to the parent that described the feature this node is a part of.
273
311
  #: This helps to perform additional filtering in side meth:get_value: based on user policies.
274
312
  feature_type: FeatureType | None
275
313
 
@@ -291,7 +329,8 @@ class XsdNode:
291
329
  :param source: Original Model field, which can provide more metadata/parsing.
292
330
  :param model_attribute: The Django model path that this element accesses.
293
331
  :param absolute_model_attribute: The full path, including parent elements.
294
- :param feature_type: Typically assigned in :meth:`bind`, needed by some :meth:`get_value` functions.
332
+ :param feature_type: Typically assigned in :meth:`~gisserver.features.FeatureField.bind`,
333
+ needed by some :meth:`get_value` functions.
295
334
  """
296
335
  if ":" in name:
297
336
  raise ValueError(
@@ -466,7 +505,9 @@ class XsdNode:
466
505
  return value
467
506
 
468
507
  def to_python(self, raw_value: str):
469
- """Convert a raw value to the Python data type for this element type."""
508
+ """Convert a raw value to the Python data type for this element type.
509
+ :raises ValidationError: When the value isn't allowed for the field type.
510
+ """
470
511
  try:
471
512
  raw_value = self.type.to_python(raw_value)
472
513
  if self.source is not None:
@@ -493,30 +534,33 @@ class XsdNode:
493
534
  :param tag: The filter operator tag name, e.g. ``PropertyIsEqualTo``.
494
535
  :returns: The parsed Python value.
495
536
  """
496
- if self.source is not None:
497
- # Not calling self.source.validate() as that checks for allowed choices,
498
- # which shouldn't be checked against for a filter query.
499
- raw_value = self.to_python(raw_value)
500
-
501
- # Check whether the Django model field supports the lookup
502
- # This prevents calling LIKE on a datetime or float field.
503
- # For foreign keys, this depends on the target field type.
504
- if self.source.get_lookup(lookup) is None or (
537
+ # Not calling self.source.validate() as that checks for allowed choices,
538
+ # which shouldn't be checked against for a filter query.
539
+ raw_value = self.to_python(raw_value)
540
+
541
+ # Check whether the Django model field supports the lookup
542
+ # This prevents calling LIKE on a datetime or float field.
543
+ # For foreign keys, this depends on the target field type.
544
+ if (
545
+ self.source is not None
546
+ and self.source.get_lookup(lookup) is None
547
+ or (
505
548
  isinstance(self.source, RelatedField)
506
549
  and self.source.target_field.get_lookup(lookup) is None
507
- ):
508
- logger.debug(
509
- "Model field '%s.%s' does not support ORM lookup '%s' used by '%s'.",
510
- self.feature_type.model._meta.model_name,
511
- self.absolute_model_attribute,
512
- lookup,
513
- tag,
514
- )
515
- raise OperationProcessingFailed(
516
- f"Operator '{tag}' is not supported for the '{self.name}' property.",
517
- locator="filter",
518
- status_code=400, # not HTTP 500 here. Spec allows both.
519
- )
550
+ )
551
+ ):
552
+ logger.debug(
553
+ "Model field '%s.%s' does not support ORM lookup '%s' used by '%s'.",
554
+ self.feature_type.model._meta.model_name,
555
+ self.absolute_model_attribute,
556
+ lookup,
557
+ tag,
558
+ )
559
+ raise OperationProcessingFailed(
560
+ f"Operator '{tag}' is not supported for the '{self.name}' property.",
561
+ locator="filter",
562
+ status_code=400, # not HTTP 500 here. Spec allows both.
563
+ )
520
564
 
521
565
  return raw_value
522
566
 
@@ -529,18 +573,21 @@ class XsdElement(XsdNode):
529
573
  This holds the definition for a single property in the WFS server.
530
574
  It's used in ``DescribeFeatureType`` to output the field metadata,
531
575
  and used in ``GetFeature`` to access the actual value from the object.
532
- Overriding :meth:`get_value` allows to override this logic.
576
+ Overriding :meth:`XsdNode.get_value` allows to override this logic.
533
577
 
534
- The :attr:`name` may differ from the underlying :attr:`model_attribute`,
578
+ The :attr:`name` may differ from the underlying :attr:`XsdNode.model_attribute`,
535
579
  so the WFS server can use other field names then the underlying model.
536
580
 
537
- A dotted-path notation can be used for :attr:`model_attribute` to access
581
+ A dotted-path notation can be used for :attr:`XsdNode.model_attribute` to access
538
582
  a related field. For the WFS client, the data appears to be flattened.
539
583
  """
540
584
 
585
+ #: Whether the element can be null
541
586
  nillable: bool | None
587
+ #: The minimal number of times the element occurs in the output.
542
588
  min_occurs: int | None
543
- max_occurs: int | _unbounded | None
589
+ #: The maximum number of times this element occurs in the output.
590
+ max_occurs: int | Literal["unbounded"] | None
544
591
 
545
592
  def __init__(
546
593
  self,
@@ -550,7 +597,7 @@ class XsdElement(XsdNode):
550
597
  *,
551
598
  nillable: bool | None = None,
552
599
  min_occurs: int | None = None,
553
- max_occurs: int | _unbounded | None = None,
600
+ max_occurs: int | Literal["unbounded"] | None = None,
554
601
  source: models.Field | models.ForeignObjectRel | None = None,
555
602
  model_attribute: str | None = None,
556
603
  absolute_model_attribute: str | None = None,
@@ -661,8 +708,8 @@ class GeometryXsdElement(XsdElement):
661
708
 
662
709
 
663
710
  class GmlIdAttribute(XsdAttribute):
664
- """A virtual 'gml:id' attribute that can be queried.
665
- This subclass has overwritten get_value() logic to format the value.
711
+ """A virtual ``gml:id="..."`` attribute that can be queried.
712
+ This subclass has overwritten :meth:`get_value` logic to format the value.
666
713
  """
667
714
 
668
715
  type_name: str
@@ -686,6 +733,7 @@ class GmlIdAttribute(XsdAttribute):
686
733
  object.__setattr__(self, "type_name", type_name)
687
734
 
688
735
  def get_value(self, instance: models.Model):
736
+ """Render the value."""
689
737
  pk = super().get_value(instance) # handle dotted-name notations
690
738
  return f"{self.type_name}.{pk}"
691
739
 
@@ -695,7 +743,7 @@ class GmlIdAttribute(XsdAttribute):
695
743
 
696
744
 
697
745
  class GmlNameElement(XsdElement):
698
- """A subclass to handle the <gml:name> element.
746
+ """A subclass to handle the ``<gml:name>`` element.
699
747
  This displays a human-readable title for the object.
700
748
 
701
749
  Currently, this just reads a single attribute,
@@ -731,7 +779,7 @@ class GmlNameElement(XsdElement):
731
779
 
732
780
 
733
781
  class GmlBoundedByElement(XsdElement):
734
- """A subclass to handle the <gml:boundedBy> element.
782
+ """A subclass to handle the ``<gml:boundedBy>`` element.
735
783
 
736
784
  This override makes sure this non-model element data
737
785
  can be included in the XML tree like every other element.
@@ -780,38 +828,55 @@ class GmlBoundedByElement(XsdElement):
780
828
  if not geometries:
781
829
  return None
782
830
 
783
- # Perform the combining of geometries inside libgeos
784
- if len(geometries) == 1:
785
- geometry = geometries[0]
786
- else:
787
- geometry = reduce(operator.or_, geometries)
788
- if crs is not None and geometry.srid != crs.srid:
789
- crs.apply_to(geometry) # avoid clone
790
-
791
- return BoundingBox.from_geometry(geometry, crs=crs)
831
+ return BoundingBox.from_geometries(geometries, crs)
792
832
 
793
833
 
794
834
  @dataclass(frozen=True)
795
835
  class XsdComplexType(XsdAnyType):
796
- """Define an <xsd:complexType> that represents a whole class definition.
836
+ """Define an ``<xsd:complexType>`` that represents a whole class definition.
797
837
 
798
838
  Typically, this maps into a Django model, with each element pointing to a model field.
839
+ For example:
840
+
841
+ .. code-block:: python
842
+
843
+ XsdComplexType(
844
+ "PersonType",
845
+ elements=[
846
+ XsdElement("name", type=XsdTypes.string),
847
+ XsdElement("age", type=XsdTypes.integer),
848
+ XsdElement("address", type=XsdComplexType(
849
+ "AddressType",
850
+ elements=[
851
+ XsdElement("street", type=XsdTypes.string),
852
+ ...
853
+ ]
854
+ )),
855
+ ],
856
+ attributes=[
857
+ XsdAttribute("id", type=XsdTypes.integer),
858
+ ],
859
+ )
799
860
 
800
861
  A complex type can hold multiple :class:`XsdElement` and :class:`XsdAttribute`
801
- nodes as children, composing an object. The elements themselves can point
802
- to a complex type themselves, to create a nested class structure.
862
+ nodes as children, composing an object. Its :attr:`base` may point to a :class:`XsdComplexType`
863
+ as base class, allowing to define those inherited elements too.
864
+
865
+ Each element can be a complex type themselves, to create a nested class structure.
803
866
  That also allows embedding models with their relations into a single response.
804
867
 
805
- This object definition is the internal "source of truth" regarding
806
- which field names and field elements are used in the WFS server.
807
- The ``DescribeFeatureType`` request uses this definition to render the matching XMLSchema.
808
- Incoming XPath queries are parsed using this object to resolve the XPath to model attributes.
868
+ .. note:: Good to know
869
+ This object definition is the internal "source of truth" regarding
870
+ which field names and field elements are used in the WFS server:
871
+
872
+ * The ``DescribeFeatureType`` request uses this definition to render the matching XMLSchema.
873
+ * Incoming XPath queries are parsed using this object to resolve the XPath to model attributes.
809
874
 
810
- Objects of this type are typically generated by the ``FeatureType`` and
811
- ``ComplexFeatureField`` classes, using the Django model data.
875
+ Objects of this type are typically generated by the :class:`~gisserver.features.FeatureType` and
876
+ :class:`~gisserver.features.ComplexFeatureField` classes, using the Django model data.
812
877
 
813
- By default, The type is declared as subclass of <gml:AbstractFeatureType>,
814
- which allows child elements like <gml:name> and <gml:boundedBy>.
878
+ By default, The :attr:`base` type is detected as ``<gml:AbstractFeatureType>``,
879
+ when there is a geometry element in the definition.
815
880
  """
816
881
 
817
882
  #: Internal class name (without XML namespace/prefix)
@@ -852,7 +917,8 @@ class XsdComplexType(XsdAnyType):
852
917
  return f"{{{self.namespace}}}{self.name}" if self.namespace else self.name
853
918
 
854
919
  @property
855
- def is_complex_type(self):
920
+ def is_complex_type(self) -> bool:
921
+ """Always indicates this is a complex type."""
856
922
  return True # a property to avoid being used as field.
857
923
 
858
924
  @cached_property
@@ -981,7 +1047,7 @@ class ORMPath:
981
1047
 
982
1048
  def build_lhs(self, compiler: CompiledQuery):
983
1049
  """Give the ORM part when this element is used as left-hand-side of a comparison.
984
- For example: "path == value".
1050
+ For example: ``path == value``.
985
1051
  """
986
1052
  if self.is_many:
987
1053
  compiler.add_distinct()
@@ -991,7 +1057,7 @@ class ORMPath:
991
1057
 
992
1058
  def build_rhs(self, compiler: CompiledQuery):
993
1059
  """Give the ORM part when this element would be used as right-hand-side.
994
- For example: "path == path" or "value == path".
1060
+ For example: ``path1 == path2`` or ``value == path``.
995
1061
  """
996
1062
  if self.is_many:
997
1063
  compiler.add_distinct()
@@ -1049,7 +1115,9 @@ class XPathMatch(ORMPath):
1049
1115
  return any(node.is_many for node in self.nodes)
1050
1116
 
1051
1117
  def build_lhs(self, compiler: CompiledQuery):
1052
- """Delegate the LHS construction to the final XsdNode."""
1118
+ """Give the ORM part when this element is used as left-hand-side of a comparison.
1119
+ For example: ``path == value``.
1120
+ """
1053
1121
  if self.is_many:
1054
1122
  compiler.add_distinct()
1055
1123
  if self.orm_filters:
@@ -1057,7 +1125,9 @@ class XPathMatch(ORMPath):
1057
1125
  return self.child.build_lhs_part(compiler, self)
1058
1126
 
1059
1127
  def build_rhs(self, compiler: CompiledQuery):
1060
- """Delegate the RHS construction to the final XsdNode."""
1128
+ """Give the ORM part when this element would be used as right-hand-side.
1129
+ For example: ``path1 == path2`` or ``value == path``.
1130
+ """
1061
1131
  if self.is_many:
1062
1132
  compiler.add_distinct()
1063
1133
  if self.orm_filters:
gisserver/views.py CHANGED
@@ -21,6 +21,7 @@ from gisserver.exceptions import (
21
21
  OperationProcessingFailed,
22
22
  OWSException,
23
23
  PermissionDenied,
24
+ XmlElementNotSupported,
24
25
  )
25
26
  from gisserver.features import FeatureType, ServiceDescription
26
27
  from gisserver.operations import base, wfs20
@@ -46,7 +47,7 @@ class OWSView(View):
46
47
  #: Define the namespace to use in the XML
47
48
  xml_namespace = "http://example.org/gisserver"
48
49
 
49
- #: Define namespace aliases to use, default is {"app": self.xml_namespace}
50
+ #: Define namespace aliases to use, default is ``{"app": self.xml_namespace}``.
50
51
  xml_namespace_aliases = None
51
52
 
52
53
  #: Default version to use
@@ -102,6 +103,7 @@ class OWSView(View):
102
103
  def get_xml_namespace_aliases(cls) -> dict[str, str]:
103
104
  """Provide all namespaces aliases with a namespace.
104
105
  This is most useful for parsing input.
106
+ The default is: ``{"app": cls.xml_namespace}``.
105
107
  """
106
108
  return cls.xml_namespace_aliases or {"app": cls.xml_namespace}
107
109
 
@@ -109,6 +111,7 @@ class OWSView(View):
109
111
  def get_xml_namespaces_to_prefixes(cls) -> dict[str, str]:
110
112
  """Provide a mapping from namespace to prefix.
111
113
  This is most useful for rendering output.
114
+ The default is: ``{cls.xml_namespace: "app"}``.
112
115
  """
113
116
  return {
114
117
  xml_namespace: prefix
@@ -119,7 +122,7 @@ class OWSView(View):
119
122
  """Entry point to handle HTTP GET requests.
120
123
 
121
124
  This parses the 'SERVICE' and 'REQUEST' parameters,
122
- to call the proper operation.
125
+ to call the proper :class:`~gisserver.operations.base.WFSOperation`.
123
126
 
124
127
  All query parameters are handled as case-insensitive.
125
128
  """
@@ -127,7 +130,9 @@ class OWSView(View):
127
130
  if logger.isEnabledFor(logging.DEBUG):
128
131
  logger.debug(
129
132
  "Parsing GET parameters:\n%s",
130
- unquote_plus(request.META["QUERY_STRING"].replace("&", "\n")),
133
+ unquote_plus(
134
+ request.META["QUERY_STRING"].replace("\n&", "\n").replace("&", "\n")
135
+ ).rstrip(),
131
136
  )
132
137
  self.kvp = kvp = KVPRequest(request.GET, ns_aliases=self.get_xml_namespace_aliases())
133
138
 
@@ -159,7 +164,7 @@ class OWSView(View):
159
164
  """Entry point to handle HTTP POST requests.
160
165
 
161
166
  This parses the XML to get the correct service and operation,
162
- to call the proper WFSMethod.
167
+ to call the proper :class:`~gisserver.operations.base.WFSOperation`.
163
168
  """
164
169
  # Parse the XML body
165
170
  if logger.isEnabledFor(logging.DEBUG):
@@ -177,17 +182,24 @@ class OWSView(View):
177
182
  if self.default_service
178
183
  else root.get_str_attribute("service")
179
184
  )
180
- operation = split_ns(root.tag)[1]
185
+
186
+ # Perform early version check. version can be omitted for GetCapabilities
187
+ self.set_version(service, root.attrib.get("version"))
188
+
189
+ # Find the registered operation that handles the request
190
+ namespace, operation = split_ns(root.tag)
181
191
  wfs_operation_cls = self.get_operation_class(service, operation)
182
192
 
183
- # Parse the request syntax
184
- request_cls = wfs_operation_cls.parser_class or resolve_xml_parser_class(root)
185
193
  try:
194
+ # Parse the request syntax
195
+ request_cls = wfs_operation_cls.parser_class or resolve_xml_parser_class(root)
186
196
  self.ows_request = request_cls.from_xml(root)
187
- self.set_version(service, self.ows_request.version)
188
197
 
189
198
  # Process the request!
190
199
  return self.call_operation(wfs_operation_cls)
200
+ except XmlElementNotSupported as e:
201
+ # Unknown XML element, e.g. wrong namespace.
202
+ raise OperationNotSupported(str(e), locator=root.attrib.get("handle")) from e
191
203
  except (OperationParsingFailed, OperationProcessingFailed) as e:
192
204
  # The WFS spec dictates that these exceptions
193
205
  # (and ResponseCacheExpired, CannotLockAllFeatures, FeaturesNotLocked which we don't raise)
@@ -275,7 +287,7 @@ class OWSView(View):
275
287
  except KeyError:
276
288
  allowed = ", ".join(sorted(self.accept_operations.keys()))
277
289
  raise InvalidParameterValue(
278
- f"'{service}' is an invalid service, supported are: {allowed}.",
290
+ f"'{service}' is not supported, available are: {allowed}.",
279
291
  locator="service",
280
292
  ) from None
281
293
 
@@ -305,6 +317,13 @@ class OWSView(View):
305
317
 
306
318
  def set_version(self, service: str, version: str | None):
307
319
  """Enforce a particular version based on the request."""
320
+ if service.upper() not in self.accept_operations:
321
+ allowed = ", ".join(sorted(self.accept_operations.keys()))
322
+ raise InvalidParameterValue(
323
+ f"'{service}' is not supported, available are: {allowed}.",
324
+ locator="service",
325
+ ) from None
326
+
308
327
  if not version:
309
328
  return
310
329
 
@@ -314,7 +333,7 @@ class OWSView(View):
314
333
 
315
334
  if version not in self.accept_versions:
316
335
  raise InvalidParameterValue(
317
- f"{service} Server does not support VERSION {version}.", locator="version"
336
+ f"This server does not support {service} version {version}.", locator="version"
318
337
  )
319
338
 
320
339
  # Enforce the requested version
@@ -417,30 +436,34 @@ class WFSView(OWSView):
417
436
  feature_type.bind_namespace(default_xml_namespace=self.xml_namespace)
418
437
  return feature_types
419
438
 
420
- def get_index_context_data(self, **kwargs):
421
- """Add WFS specific metadata"""
422
- get_feature_operation = self.accept_operations["WFS"]["GetFeature"]
423
- operation = get_feature_operation(self, ows_request=None)
424
-
425
- # Remove aliases
426
- wfs_output_formats = []
427
- seen = set()
428
- for output_format in operation.get_output_formats():
429
- if output_format.identifier not in seen:
430
- wfs_output_formats.append(output_format)
431
- seen.add(output_format.identifier)
432
-
439
+ def get_index_context_data(self, **kwargs) -> dict:
440
+ """Get the context data for the index template"""
433
441
  context = super().get_index_context_data(**kwargs)
434
442
  context.update(
435
443
  {
444
+ "GISSERVER_SUPPORTED_CRS_ONLY": conf.GISSERVER_SUPPORTED_CRS_ONLY,
436
445
  "wfs_features": self.get_bound_feature_types(),
437
- "wfs_output_formats": wfs_output_formats,
446
+ "wfs_output_formats": self._get_wfs_output_formats(),
438
447
  "wfs_filter_capabilities": self.wfs_filter_capabilities,
439
448
  "wfs_service_constraints": self.wfs_service_constraints,
440
449
  }
441
450
  )
442
451
  return context
443
452
 
453
+ def _get_wfs_output_formats(self) -> list[base.OutputFormat]:
454
+ """Find the output formats of the ``GetFeature`` operation for the HTML index."""
455
+ get_feature_operation = self.accept_operations["WFS"]["GetFeature"]
456
+ operation = get_feature_operation(self, ows_request=None)
457
+
458
+ # Get output formats, remove duplicates (e.g. GeoJSON/geojson alias)
459
+ wfs_output_formats = []
460
+ seen = set()
461
+ for output_format in operation.get_output_formats():
462
+ if output_format.identifier not in seen:
463
+ wfs_output_formats.append(output_format)
464
+ seen.add(output_format.identifier)
465
+ return wfs_output_formats
466
+
444
467
  def get_xml_schema_url(self, feature_types: list[FeatureType]) -> str:
445
468
  """Return the XML schema URL for the given feature types.
446
469
  This is used in the GML output rendering.