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
gisserver/features.py CHANGED
@@ -17,24 +17,27 @@ For example, model relationships can be modelled to a different XML layout.
17
17
  from __future__ import annotations
18
18
 
19
19
  import html
20
- import operator
20
+ import itertools
21
+ import logging
21
22
  from dataclasses import dataclass
22
- from functools import cached_property, lru_cache, reduce
23
+ from functools import cached_property, lru_cache
23
24
  from typing import TYPE_CHECKING, Literal, Union
24
25
 
25
- from django.conf import settings
26
26
  from django.contrib.gis.db import models as gis_models
27
27
  from django.contrib.gis.db.models import Extent, GeometryField
28
28
  from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
29
29
  from django.db import models
30
- from django.db.models.fields.related import ForeignObjectRel # Django 2.2 import
30
+ from django.http import HttpRequest
31
31
 
32
- from gisserver.db import conditional_transform
33
- from gisserver.exceptions import ExternalValueError
32
+ from gisserver import conf
33
+ from gisserver.compat import ArrayField, GeneratedField
34
+ from gisserver.db import get_db_geometry_target
35
+ from gisserver.exceptions import ExternalValueError, InvalidParameterValue
34
36
  from gisserver.geometries import CRS, WGS84, BoundingBox
37
+ from gisserver.parsers.xml import parse_qname, xmlns
35
38
  from gisserver.types import (
39
+ GeometryXsdElement,
36
40
  GmlBoundedByElement,
37
- GmlElement,
38
41
  GmlIdAttribute,
39
42
  GmlNameElement,
40
43
  XPathMatch,
@@ -44,13 +47,8 @@ from gisserver.types import (
44
47
  XsdTypes,
45
48
  )
46
49
 
47
- if "django.contrib.postgres" in settings.INSTALLED_APPS:
48
- from django.contrib.postgres.fields import ArrayField
49
- else:
50
- ArrayField = None
51
-
52
50
  if TYPE_CHECKING:
53
- from gisserver.queries import FeatureRelation
51
+ from gisserver.projection import FeatureRelation
54
52
 
55
53
  _all_ = Literal["__all__"]
56
54
 
@@ -62,6 +60,8 @@ __all__ = [
62
60
  "get_basic_field_type",
63
61
  ]
64
62
 
63
+ logger = logging.getLogger(__name__)
64
+
65
65
  XSD_TYPES = {
66
66
  models.CharField: XsdTypes.string,
67
67
  models.TextField: XsdTypes.string,
@@ -88,7 +88,7 @@ DEFAULT_XSD_TYPE = XsdTypes.anyType
88
88
 
89
89
 
90
90
  def get_basic_field_type(
91
- field_name: str, model_field: models.Field | ForeignObjectRel
91
+ field_name: str, model_field: models.Field | models.ForeignObjectRel
92
92
  ) -> XsdAnyType:
93
93
  """Determine the XSD field type for a Django field."""
94
94
  if ArrayField is not None and isinstance(model_field, ArrayField):
@@ -96,6 +96,10 @@ def get_basic_field_type(
96
96
  # The array notation is written as "is_many"
97
97
  model_field = model_field.base_field
98
98
 
99
+ if GeneratedField is not None and isinstance(model_field, GeneratedField):
100
+ # Allow things like: models.GeneratedField(SomeFunction("geofield"), output_field=models.GeometryField())
101
+ model_field = model_field.output_field
102
+
99
103
  try:
100
104
  # Direct instance, quickly resolved!
101
105
  return XSD_TYPES[model_field.__class__]
@@ -105,7 +109,7 @@ def get_basic_field_type(
105
109
  if isinstance(model_field, models.ForeignKey):
106
110
  # Don't let it query on the relation value yet
107
111
  return get_basic_field_type(field_name, model_field.target_field)
108
- elif isinstance(model_field, ForeignObjectRel):
112
+ elif isinstance(model_field, models.ForeignObjectRel):
109
113
  # e.g. ManyToOneRel descriptor of a foreignkey_id field.
110
114
  return get_basic_field_type(field_name, model_field.remote_field.target_field)
111
115
  else:
@@ -141,12 +145,12 @@ def _get_model_fields(
141
145
  feature_type=feature_type,
142
146
  )
143
147
  for f in model._meta.get_fields()
144
- if not f.is_relation or f.many_to_one or f.one_to_one # ForeignKey # OneToOneField
148
+ if not f.is_relation or f.many_to_one or f.one_to_one # ForeignKey, OneToOneField
145
149
  ]
146
150
  else:
147
151
  # Only defined fields
148
152
  fields = [f if isinstance(f, FeatureField) else FeatureField(f) for f in fields]
149
- for field in fields:
153
+ for field in fields: # type: FeatureField
150
154
  field.bind(model, parent=parent, feature_type=feature_type)
151
155
  return fields
152
156
 
@@ -172,10 +176,10 @@ class FeatureField:
172
176
  """
173
177
 
174
178
  #: Allow to override the XSD element type that this field will generate.
175
- xsd_element_class: type[XsdElement] = XsdElement
179
+ xsd_element_class: type[XsdElement] = None
176
180
 
177
181
  model: type[models.Model] | None
178
- model_field: models.Field | ForeignObjectRel | None
182
+ model_field: models.Field | models.ForeignObjectRel | None
179
183
 
180
184
  def __init__(
181
185
  self,
@@ -213,11 +217,17 @@ class FeatureField:
213
217
  self.xsd_element_class = xsd_class
214
218
 
215
219
  self._nillable_relation = False
220
+ self._nillable_output_field = False
216
221
  if model is not None:
217
222
  self.bind(model, parent=parent, feature_type=feature_type)
218
223
 
219
224
  def __repr__(self):
220
- return f"<{self.__class__.__name__}: {self.name}>"
225
+ if self.model_field is None:
226
+ return f"<{self.__class__.__name__}: {self.name} (unbound)>"
227
+ else:
228
+ return (
229
+ f"<{self.__class__.__name__}: {self.name}, source={self.absolute_model_attribute}>"
230
+ )
221
231
 
222
232
  def _get_xsd_type(self):
223
233
  return get_basic_field_type(self.name, self.model_field)
@@ -252,29 +262,48 @@ class FeatureField:
252
262
  field_path = self.model_attribute.split(".")
253
263
  try:
254
264
  field: models.Field = self.model._meta.get_field(field_path[0])
265
+
266
+ for name in field_path[1:]:
267
+ if field.null:
268
+ self._nillable_relation = True
269
+
270
+ if not field.is_relation:
271
+ # Note this tests the previous loop variable, so it checks whether the
272
+ # field can be walked into in order to resolve the next dotted name below.
273
+ raise ImproperlyConfigured(
274
+ f"FeatureField '{self.name}' has an invalid model_attribute: "
275
+ f"field '{name}' is a '{field.__class__.__name__}', not a relation."
276
+ )
277
+
278
+ field = field.related_model._meta.get_field(name)
279
+ self.model_field = field
255
280
  except FieldDoesNotExist as e:
256
281
  raise ImproperlyConfigured(
257
282
  f"FeatureField '{self.name}' has an invalid"
258
283
  f" model_attribute: '{self.model_attribute}' can't be"
259
284
  f" resolved for model '{self.model.__name__}'."
260
285
  ) from e
286
+ else:
287
+ try:
288
+ self.model_field = self.model._meta.get_field(self.name)
289
+ if GeneratedField is not None and isinstance(self.model_field, GeneratedField):
290
+ self._nillable_output_field = self.model_field.output_field.null
291
+ except FieldDoesNotExist as e:
292
+ raise ImproperlyConfigured(
293
+ f"FeatureField '{self.name}' can't be resolved for model '{self.model.__name__}'."
294
+ " Either set 'model_attribute' or change the name."
295
+ ) from e
261
296
 
262
- for name in field_path[1:]:
263
- if field.null:
264
- self._nillable_relation = True
265
-
266
- if not field.is_relation:
267
- # Note this tests the previous loop variable, so it checks whether the
268
- # field can be walked into in order to resolve the next dotted name below.
269
- raise ImproperlyConfigured(
270
- f"FeatureField '{field.name}' has an invalid model_attribute: "
271
- f"field '{name}' is a '{field.__class__.__name__}', not a relation."
272
- )
297
+ @cached_property
298
+ def absolute_model_attribute(self) -> str:
299
+ """Determine the full attribute of the field."""
300
+ if self.model_field is None:
301
+ raise RuntimeError(f"bind() was not called for {self!r}")
273
302
 
274
- field = field.related_model._meta.get_field(name)
275
- self.model_field = field
303
+ if self.parent is not None:
304
+ return f"{self.parent.absolute_model_attribute}.{self.model_attribute or self.name}"
276
305
  else:
277
- self.model_field = self.model._meta.get_field(self.name)
306
+ return self.model_attribute or self.name
278
307
 
279
308
  @cached_property
280
309
  def xsd_element(self) -> XsdElement:
@@ -287,8 +316,10 @@ class FeatureField:
287
316
  if self.model_field is None:
288
317
  raise RuntimeError(f"bind() was not called for {self!r}")
289
318
 
319
+ xsd_type = self._get_xsd_type()
320
+
290
321
  # Determine max number of occurrences.
291
- if isinstance(self.model_field, GeometryField):
322
+ if xsd_type.is_geometry:
292
323
  max_occurs = 1 # be explicit here, like mapserver does.
293
324
  elif self.model_field.many_to_many or self.model_field.one_to_many:
294
325
  max_occurs = "unbounded" # M2M or reverse FK field
@@ -297,13 +328,22 @@ class FeatureField:
297
328
  else:
298
329
  max_occurs = None # default is 1, but attribute can be left out.
299
330
 
300
- return self.xsd_element_class(
331
+ # Determine which subclass to use for the element.
332
+ xsd_element_class = self.xsd_element_class or (
333
+ GeometryXsdElement if xsd_type.is_geometry else XsdElement
334
+ )
335
+
336
+ return xsd_element_class(
301
337
  name=self.name,
302
- type=self._get_xsd_type(),
303
- nillable=self.model_field.null or self._nillable_relation,
338
+ type=xsd_type,
339
+ namespace=self.feature_type.xml_namespace, # keep all types in the same namespace for now.
340
+ nillable=(
341
+ self.model_field.null or self._nillable_relation or self._nillable_output_field
342
+ ),
304
343
  min_occurs=0,
305
344
  max_occurs=max_occurs,
306
345
  model_attribute=self.model_attribute,
346
+ absolute_model_attribute=self.absolute_model_attribute,
307
347
  source=self.model_field,
308
348
  feature_type=self.feature_type,
309
349
  )
@@ -329,7 +369,7 @@ class ComplexFeatureField(FeatureField):
329
369
  model=None,
330
370
  abstract=None,
331
371
  xsd_class=None,
332
- xsd_base_type=XsdTypes.gmlAbstractFeatureType,
372
+ xsd_base_type=None,
333
373
  ):
334
374
  """
335
375
  :param name: Name of the model field.
@@ -355,7 +395,9 @@ class ComplexFeatureField(FeatureField):
355
395
  @cached_property
356
396
  def fields(self) -> list[FeatureField]:
357
397
  """Provide all fields that will be rendered as part of this complex field."""
358
- return _get_model_fields(self.target_model, self._fields, parent=self)
398
+ return _get_model_fields(
399
+ self.target_model, self._fields, parent=self, feature_type=self.feature_type
400
+ )
359
401
 
360
402
  def _get_xsd_type(self) -> XsdComplexType:
361
403
  """Generate the XSD description for the field with an object relation."""
@@ -363,6 +405,7 @@ class ComplexFeatureField(FeatureField):
363
405
 
364
406
  return XsdComplexType(
365
407
  name=f"{self.target_model._meta.object_name}Type",
408
+ namespace=self.feature_type.xml_namespace, # keep all types in the same namespace for now.
366
409
  elements=[field.xsd_element for field in self.fields],
367
410
  attributes=[
368
411
  # Add gml:id attribute definition, so it can be resolved in xpath
@@ -433,7 +476,7 @@ def field(
433
476
  class FeatureType:
434
477
  """Declare a feature that is exposed on the map.
435
478
 
436
- All WFS operations use this class to read the feature ype.
479
+ All WFS operations use this class to read the feature type.
437
480
  You may subclass this class to provide extensions,
438
481
  such as redefining :meth:`get_queryset`.
439
482
 
@@ -460,7 +503,7 @@ class FeatureType:
460
503
  metadata_url: str | None = None,
461
504
  # Settings
462
505
  show_name_field: bool = True,
463
- xml_prefix: str = "app",
506
+ xml_namespace: str | None = None,
464
507
  ):
465
508
  """
466
509
  :param queryset: The queryset to retrieve the data.
@@ -477,7 +520,7 @@ class FeatureType:
477
520
  :param metadata_url: Used in WFS metadata.
478
521
  :param show_name_field: Whether to show the ``gml:name`` or the GeoJSON ``geometry_name``
479
522
  field. Default is to show a field when ``name_field`` is given.
480
- :param xml_prefix: The XML namespace prefix to use.
523
+ :param xml_namespace: The XML namespace to use, will be set by :meth:`bind_namespace` otherwise.
481
524
  """
482
525
  if isinstance(queryset, models.QuerySet):
483
526
  self.queryset = queryset
@@ -502,23 +545,34 @@ class FeatureType:
502
545
 
503
546
  # Settings
504
547
  self.show_name_field = show_name_field
505
- self.xml_prefix = xml_prefix
548
+ self.xml_namespace = xml_namespace
506
549
 
507
550
  # Validate that the name doesn't require XML escaping.
508
551
  if html.escape(self.name) != self.name or " " in self.name or ":" in self.name:
509
552
  raise ValueError(f"Invalid feature name for XML: <{self.xml_name}>")
510
553
 
511
- self._cached_resolver = lru_cache(100)(self._inner_resolve_element)
554
+ self._cached_resolver = lru_cache(200)(self._inner_resolve_element)
555
+
556
+ def bind_namespace(self, default_xml_namespace: str):
557
+ """Make sure the feature type receives the settings from the parent view."""
558
+ if not self.xml_namespace:
559
+ self.xml_namespace = default_xml_namespace
512
560
 
513
- def check_permissions(self, request):
561
+ def check_permissions(self, request: HttpRequest):
514
562
  """Hook that allows subclasses to reject access for datasets.
515
563
  It may raise a Django PermissionDenied error.
564
+
565
+ This can check for example whether ``request.user`` may access this feature.
566
+
567
+ The parsed WFS request is available as ``request.ows_request``.
568
+ Currently, this can be a :class:`~gisserver.parsers.wfs20.GetFeature`
569
+ or :class:`~gisserver.parsers.wfs20.GetPropertyValue` instance.
516
570
  """
517
571
 
518
572
  @cached_property
519
- def xml_name(self):
520
- """Return the feature name with xml namespace prefix."""
521
- return f"{self.xml_prefix}:{self.name}"
573
+ def xml_name(self) -> str:
574
+ """Return the feature tag as XML Full Qualified name"""
575
+ return f"{{{self.xml_namespace}}}{self.name}" if self.xml_namespace else self.name
522
576
 
523
577
  @cached_property
524
578
  def supported_crs(self) -> list[CRS]:
@@ -526,20 +580,19 @@ class FeatureType:
526
580
  return [self.crs] + self.other_crs
527
581
 
528
582
  @cached_property
529
- def geometry_elements(self) -> list[GmlElement]:
530
- """Tell which fields of the XSD have a geometry field."""
531
- return [ff.xsd_element for ff in self.fields if ff.xsd_element.is_geometry]
532
-
533
- @cached_property
534
- def geometry_fields(self) -> list[GeometryField]:
535
- """Tell which fields of the model have a geometry field.
536
- This only compares against fields that are mentioned in this feature type.
537
- """
538
- return [
539
- ff.model_field or getattr(self.model, ff.model_attribute)
540
- for ff in self.fields
541
- if ff.xsd_element.is_geometry
542
- ]
583
+ def all_geometry_elements(self) -> list[GeometryXsdElement]:
584
+ """Provide access to all geometry elements from *all* nested levels."""
585
+ return self.xsd_type.geometry_elements + list(
586
+ itertools.chain.from_iterable(
587
+ # Take the geometry elements at each object level.
588
+ xsd_element.type.geometry_elements
589
+ for xsd_element in self.xsd_type.all_complex_elements
590
+ # The 'None' level is the root node, which is already added before.
591
+ # For now, don't support geometry fields on an M2M relation.
592
+ # If that use-case is needed, it would require additional work to implement.
593
+ if xsd_element is not None and not xsd_element.is_many
594
+ )
595
+ )
543
596
 
544
597
  @cached_property
545
598
  def _local_model_field_names(self) -> list[str]:
@@ -555,6 +608,17 @@ class FeatureType:
555
608
  @cached_property
556
609
  def fields(self) -> list[FeatureField]:
557
610
  """Define which fields to render."""
611
+ if (
612
+ self._geometry_field_name
613
+ and "." in self._geometry_field_name
614
+ and (self._fields is None or self._fields == "__all__")
615
+ ):
616
+ # If you want to define more complex relationships, please be explicit in which fields you want.
617
+ # Allowing autoconfiguration of this is complex and likely not what you're looking for either.
618
+ raise ImproperlyConfigured(
619
+ "Using a geometry_field_name path requires defining 'fields' explicitly."
620
+ )
621
+
558
622
  # This lazy reading allows providing 'fields' as lazy value.
559
623
  if self._fields is None:
560
624
  # Autoconfig, no fields defined.
@@ -567,6 +631,7 @@ class FeatureType:
567
631
 
568
632
  return [FeatureField(self._geometry_field_name, model=self.model, feature_type=self)]
569
633
  else:
634
+ # Either __all__ or an explicit list.
570
635
  return _get_model_fields(self.model, self._fields, feature_type=self)
571
636
 
572
637
  @cached_property
@@ -578,56 +643,36 @@ class FeatureType:
578
643
  return self.model._meta.get_field(self.display_field_name)
579
644
 
580
645
  @cached_property
581
- def geometry_field(self) -> gis_models.GeometryField:
582
- """Give access to the Django field that holds the geometry."""
583
- if not self.geometry_fields:
584
- raise ImproperlyConfigured(
585
- f"FeatureType '{self.name}' does not expose a geometry field."
586
- ) from None
587
-
588
- if self._geometry_field_name:
589
- # Explicitly mentioned, use that field.
590
- # Check whether the server is not accidentally exposing another field
591
- # that is not part of the type definition.
592
- field = next(
593
- (
594
- field
595
- for field in self.geometry_fields
596
- if field.name == self._geometry_field_name
597
- ),
598
- None,
599
- )
600
- if field is None:
646
+ def main_geometry_element(self) -> GeometryXsdElement:
647
+ """Give access to the main geometry element."""
648
+ if not self._geometry_field_name:
649
+ try:
650
+ # Take the first element that has a geometry.
651
+ return self.all_geometry_elements[0]
652
+ except IndexError:
653
+ raise ImproperlyConfigured(
654
+ f"FeatureType '{self.name}' does not expose any geometry field "
655
+ f"as part of its definition."
656
+ ) from None
657
+ else:
658
+ try:
659
+ return next(
660
+ e
661
+ for e in self.all_geometry_elements
662
+ if e.absolute_model_attribute == self._geometry_field_name
663
+ )
664
+ except StopIteration:
601
665
  raise ImproperlyConfigured(
602
666
  f"FeatureType '{self.name}' does not expose the geometry field "
603
667
  f"'{self._geometry_field_name}' as part of its definition."
604
- )
605
-
606
- return field
607
- else:
608
- # Default: take the first geometry
609
- return self.geometry_fields[0]
610
-
611
- @cached_property
612
- def main_geometry_element(self) -> GmlElement:
613
- """Give access to the main geometry element."""
614
- # NOTE: this property should make it easier to move away from having a main geometry field
615
- # at the model level, and allow a geometry field in any depth of the data model.
616
- return next((e for e in self.geometry_elements if e.source is self.geometry_field), None)
617
-
618
- @cached_property
619
- def geometry_field_name(self) -> str:
620
- """Tell which field is the geometry field."""
621
- # Due to internal reorganization this property is no longer needed,
622
- # retained for backward compatibility and to reflect any used input parameters.
623
- return self.geometry_field.name
668
+ ) from None
624
669
 
625
670
  @cached_property
626
671
  def crs(self) -> CRS:
627
672
  """Tell which projection the data should be presented at."""
628
673
  if self._crs is None:
629
674
  # Default CRS
630
- return CRS.from_srid(self.geometry_field.srid) # checks lookup too
675
+ return CRS.from_srid(self.main_geometry_element.source_srid) # checks lookup too
631
676
  else:
632
677
  return self._crs
633
678
 
@@ -635,6 +680,11 @@ class FeatureType:
635
680
  """Return the queryset that is used as basis for this feature."""
636
681
  # Return a queryset that only retrieves the fields that are actually displayed.
637
682
  # That that without .only(), use at least `self.queryset.all()` so a clone is returned.
683
+ logger.debug(
684
+ "QuerySet for %s default only retrieves: %r",
685
+ self.queryset.model._meta.label,
686
+ self._local_model_field_names,
687
+ )
638
688
  return self.queryset.only(*self._local_model_field_names)
639
689
 
640
690
  def get_related_queryset(self, feature_relation: FeatureRelation) -> models.QuerySet:
@@ -645,9 +695,15 @@ class FeatureType:
645
695
  f"source model is not defined for: {feature_relation.xsd_elements!r}"
646
696
  )
647
697
  # Return a queryset that only retrieves the fields that are displayed.
648
- return self.filter_related_queryset(
649
- feature_relation.related_model.objects.only(*feature_relation._local_model_field_names)
698
+ logger.debug(
699
+ "QuerySet for %s by default only retrieves: %r",
700
+ feature_relation.related_model._meta.label,
701
+ feature_relation._local_model_field_names,
650
702
  )
703
+ queryset = feature_relation.related_model.objects.only(
704
+ *feature_relation._local_model_field_names
705
+ )
706
+ return self.filter_related_queryset(queryset) # Allow overriding by FeatureType subclasses
651
707
 
652
708
  def filter_related_queryset(self, queryset: models.QuerySet) -> models.QuerySet:
653
709
  """When a related object returns a queryset, this hook allows extra filtering."""
@@ -660,37 +716,13 @@ class FeatureType:
660
716
  when the database table is empty, or the custom queryset doesn't
661
717
  return any results.
662
718
  """
663
- if not self.geometry_fields:
719
+ if not self.main_geometry_element:
664
720
  return None
665
721
 
666
- geo_expression = conditional_transform(
667
- self.geometry_field.name, self.geometry_field.srid, WGS84.srid
668
- )
669
-
722
+ geo_expression = get_db_geometry_target(self.main_geometry_element, WGS84)
670
723
  bbox = self.get_queryset().aggregate(a=Extent(geo_expression))["a"]
671
724
  return BoundingBox(*bbox, crs=WGS84) if bbox else None
672
725
 
673
- def get_envelope(self, instance, crs: CRS | None = None) -> BoundingBox | None:
674
- """Get the bounding box for a single instance.
675
-
676
- This is only used for native Python rendering. When the database
677
- rendering is enabled (GISSERVER_USE_DB_RENDERING=True), the calculation
678
- is entirely performed within the query.
679
- """
680
- geometries = [
681
- geom
682
- for geom in (getattr(instance, f.name) for f in self.geometry_fields)
683
- if geom is not None
684
- ]
685
- if not geometries:
686
- return None
687
-
688
- # Perform the combining of geometries inside libgeos
689
- geometry = geometries[0] if len(geometries) == 1 else reduce(operator.or_, geometries)
690
- if crs is not None and geometry.srid != crs.srid:
691
- crs.apply_to(geometry) # avoid clone
692
- return BoundingBox.from_geometry(geometry, crs=crs)
693
-
694
726
  def get_display_value(self, instance: models.Model) -> str:
695
727
  """Generate the display name value"""
696
728
  if self.display_field_name:
@@ -709,12 +741,8 @@ class FeatureType:
709
741
  """
710
742
  pk_field = self.model._meta.pk
711
743
 
712
- # Define <gml:boundedBy>, if the feature has a geometry
713
- base_elements = []
714
- if self.geometry_fields:
715
- # Without a geometry, boundaries are not possible.
716
- base_elements.append(GmlBoundedByElement(feature_type=self))
717
-
744
+ # Define <gml:boundedBy>
745
+ base_elements = [GmlBoundedByElement(feature_type=self)]
718
746
  if self.show_name_field:
719
747
  # Add <gml:name>
720
748
  gml_name = GmlNameElement(
@@ -724,12 +752,14 @@ class FeatureType:
724
752
  )
725
753
  base_elements.insert(0, gml_name)
726
754
 
755
+ # Write out the definition of XsdTypes.gmlAbstractFeatureType as actual class definition.
756
+ # By having these base elements, the XPath queries can also resolve these like any other feature field elements.
727
757
  return XsdComplexType(
728
- prefix="gml",
729
758
  name="AbstractFeatureType",
759
+ namespace=xmlns.gml.value,
730
760
  elements=base_elements,
731
761
  attributes=[
732
- # Add gml:id attribute definition so it can be resolved in xpath
762
+ # Add gml:id="..." attribute definition so it can be resolved in xpath
733
763
  GmlIdAttribute(
734
764
  type_name=self.name,
735
765
  source=pk_field,
@@ -744,13 +774,14 @@ class FeatureType:
744
774
  def xsd_type(self) -> XsdComplexType:
745
775
  """Return the definition of this feature as an XSD Complex Type."""
746
776
  return self.xsd_type_class(
747
- name=f"{self.name.title()}Type",
777
+ name=f"{self.name[0].upper()}{self.name[1:]}Type",
748
778
  elements=[field.xsd_element for field in self.fields],
779
+ namespace=self.xml_namespace,
749
780
  base=self.xsd_base_type,
750
781
  source=self.model,
751
782
  )
752
783
 
753
- def resolve_element(self, xpath: str) -> XPathMatch:
784
+ def resolve_element(self, xpath: str, ns_aliases: dict[str, str]) -> XPathMatch:
754
785
  """Resolve the element, and the matching object.
755
786
 
756
787
  This is used to convert XPath references in requests
@@ -758,13 +789,28 @@ class FeatureType:
758
789
 
759
790
  Internally, this method caches results.
760
791
  """
761
- nodes = self._cached_resolver(xpath) # calls _inner_resolve_element
792
+ # When the XML POST request used xmlns="http://www.opengis.net/wfs/2.0",
793
+ # this will become the default namespace elements are translated into.
794
+ # However, the XPath elements should be interpreted within our application namespace instead.
795
+ # When the default namespace is missing, it should also resolve to our feature.
796
+ ns_aliases = ns_aliases.copy()
797
+ ns_aliases[""] = self.xml_namespace
798
+
799
+ if len(ns_aliases) > 10:
800
+ # Avoid filling memory by caching large namespace blobs.
801
+ nodes = self._inner_resolve_element(xpath, ns_aliases)
802
+ else:
803
+ # Go through lru_cache() for faster lookup of the same elements.
804
+ # Note 1: the cache will be less effective when clients use different namespace aliases.
805
+ # Note 2: when WFSView overrides get_feature_types() the cache may only exist per request.
806
+ nodes = self._cached_resolver(xpath, HDict(ns_aliases))
807
+
762
808
  if nodes is None:
763
809
  raise ExternalValueError(f"Field '{xpath}' does not exist.")
764
810
 
765
811
  return XPathMatch(self, nodes, query=xpath)
766
812
 
767
- def _inner_resolve_element(self, xpath: str):
813
+ def _inner_resolve_element(self, xpath: str, ns_aliases: dict[str, str]):
768
814
  """Inner part of resolve_element() that is cached.
769
815
  This performs any additional checks that happen at the root-level only.
770
816
  """
@@ -781,16 +827,34 @@ class FeatureType:
781
827
  elif "(" in xpath:
782
828
  raise NotImplementedError(f"XPath selectors with functions are not supported: {xpath}")
783
829
 
784
- # Allow /app:ElementName/.. as "absolute" path.
785
- # Given our internal resolver logic, simple solution is to strip it.
786
- for root_prefix in (
787
- f"{self.name}/",
788
- f"/{self.name}/",
789
- f"{self.xml_name}/",
790
- f"/{self.xml_name}/",
791
- ):
792
- if xpath.startswith(root_prefix):
793
- xpath = xpath[len(root_prefix) :]
794
- break
830
+ # Allow /app:ElementName/.. as "absolute" path, simply strip it for now.
831
+ # This is only actually needed to support join queries, which we don't.
832
+ xpath = xpath.lstrip("/")
833
+ root_name, _, _ = xpath.partition("/")
834
+ root_xml_name = parse_qname(root_name, ns_aliases)
835
+ if root_xml_name == self.xml_name:
836
+ xpath = xpath[len(root_name) + 1 :]
837
+
838
+ return self.xsd_type.resolve_element_path(xpath, ns_aliases)
839
+
840
+ def resolve_crs(self, crs: CRS, locator="") -> CRS:
841
+ """Check a parsed CRS against the list of supported types."""
842
+ pos = self.supported_crs.index(crs)
843
+ if pos != -1:
844
+ # Replace the parsed CRS with the declared one, which may have a 'backend' configured.
845
+ return self.supported_crs[pos]
846
+
847
+ if conf.GISSERVER_SUPPORTED_CRS_ONLY:
848
+ raise InvalidParameterValue(
849
+ f"Feature '{self.name}' does not support SRID {crs.srid}.",
850
+ locator=locator,
851
+ )
852
+ else:
853
+ return crs
854
+
855
+
856
+ class HDict(dict):
857
+ """Dict that can be used in lru_cache()."""
795
858
 
796
- return self.xsd_type.resolve_element_path(xpath)
859
+ def __hash__(self):
860
+ return hash(frozenset(self.items()))