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
gisserver/features.py CHANGED
@@ -1,14 +1,15 @@
1
- """The configuration of a "feature type" in the WFS server.
1
+ """The main configuration for exposing model data in the WFS server.
2
2
 
3
3
  The "feature type" definitions define what models and attributes are exposed in the WFS server.
4
4
  When a model attribute is mentioned in the feature type, it can be exposed and queried against.
5
5
  Any field that is not mentioned in a definition, will therefore not be available, nor queryable.
6
6
  This metadata is used in the ``GetCapabilities`` call to advertise all available feature types.
7
7
 
8
- To handle other WFS request types besides ``GetCapabilities``, the "feature type" definition
9
- is translated internally into an internal XML Schema Definition (:mod:`gisserver.types`).
8
+ The "feature type" definitions ares translated internally into
9
+ an internal XML Schema Definition (made from :mod:`gisserver.types`).
10
10
  That schema maps all model attributes to a specific XML layout, and includes
11
11
  all XSD Complex Types, elements and attributes linked to the Django model metadata.
12
+
12
13
  The feature type classes (and field types) offer a flexible translation
13
14
  from attribute listings into a schema definition.
14
15
  For example, model relationships can be modelled to a different XML layout.
@@ -17,24 +18,28 @@ For example, model relationships can be modelled to a different XML layout.
17
18
  from __future__ import annotations
18
19
 
19
20
  import html
20
- import operator
21
+ import itertools
22
+ import logging
21
23
  from dataclasses import dataclass
22
- from functools import cached_property, lru_cache, reduce
23
- from typing import TYPE_CHECKING, Literal, Union
24
+ from functools import cached_property, lru_cache
25
+ from typing import TYPE_CHECKING, Literal
24
26
 
25
- from django.conf import settings
26
27
  from django.contrib.gis.db import models as gis_models
27
- from django.contrib.gis.db.models import Extent, GeometryField
28
+ from django.contrib.gis.db.models import GeometryField
28
29
  from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
29
30
  from django.db import models
30
- from django.db.models.fields.related import ForeignObjectRel # Django 2.2 import
31
-
32
- from gisserver.db import conditional_transform
33
- from gisserver.exceptions import ExternalValueError
34
- from gisserver.geometries import CRS, WGS84, BoundingBox
31
+ from django.http import HttpRequest
32
+
33
+ from gisserver import conf
34
+ from gisserver.compat import ArrayField, GeneratedField
35
+ from gisserver.crs import CRS
36
+ from gisserver.db import get_wgs84_bounding_box
37
+ from gisserver.exceptions import ExternalValueError, InvalidParameterValue
38
+ from gisserver.geometries import WGS84BoundingBox
39
+ from gisserver.parsers.xml import parse_qname, xmlns
35
40
  from gisserver.types import (
41
+ GeometryXsdElement,
36
42
  GmlBoundedByElement,
37
- GmlElement,
38
43
  GmlIdAttribute,
39
44
  GmlNameElement,
40
45
  XPathMatch,
@@ -44,35 +49,33 @@ from gisserver.types import (
44
49
  XsdTypes,
45
50
  )
46
51
 
47
- if "django.contrib.postgres" in settings.INSTALLED_APPS:
48
- from django.contrib.postgres.fields import ArrayField
49
- else:
50
- ArrayField = None
51
-
52
52
  if TYPE_CHECKING:
53
- from gisserver.queries import FeatureRelation
54
-
55
- _all_ = Literal["__all__"]
53
+ from gisserver.projection import FeatureRelation
56
54
 
57
55
  __all__ = [
58
56
  "FeatureType",
59
57
  "field",
60
58
  "FeatureField",
61
59
  "ComplexFeatureField",
62
- "get_basic_field_type",
63
60
  ]
64
61
 
62
+ logger = logging.getLogger(__name__)
63
+
65
64
  XSD_TYPES = {
66
65
  models.CharField: XsdTypes.string,
67
66
  models.TextField: XsdTypes.string,
68
67
  models.BooleanField: XsdTypes.boolean,
69
68
  models.IntegerField: XsdTypes.integer,
69
+ models.PositiveIntegerField: XsdTypes.nonNegativeInteger,
70
+ models.PositiveBigIntegerField: XsdTypes.nonNegativeInteger,
71
+ models.PositiveSmallIntegerField: XsdTypes.nonNegativeInteger,
70
72
  models.AutoField: XsdTypes.integer, # Only as of Django 3.0 this extends from IntegerField
71
73
  models.FloatField: XsdTypes.double,
72
74
  models.DecimalField: XsdTypes.decimal,
73
75
  models.TimeField: XsdTypes.time,
74
76
  models.DateTimeField: XsdTypes.dateTime, # note: DateTimeField extends DateField!
75
77
  models.DateField: XsdTypes.date,
78
+ models.DurationField: XsdTypes.duration,
76
79
  models.URLField: XsdTypes.anyURI,
77
80
  gis_models.PointField: XsdTypes.gmlPointPropertyType,
78
81
  gis_models.PolygonField: XsdTypes.gmlSurfacePropertyType,
@@ -87,8 +90,8 @@ XSD_TYPES = {
87
90
  DEFAULT_XSD_TYPE = XsdTypes.anyType
88
91
 
89
92
 
90
- def get_basic_field_type(
91
- field_name: str, model_field: models.Field | ForeignObjectRel
93
+ def _get_basic_field_type(
94
+ field_name: str, model_field: models.Field | models.ForeignObjectRel
92
95
  ) -> XsdAnyType:
93
96
  """Determine the XSD field type for a Django field."""
94
97
  if ArrayField is not None and isinstance(model_field, ArrayField):
@@ -96,6 +99,10 @@ def get_basic_field_type(
96
99
  # The array notation is written as "is_many"
97
100
  model_field = model_field.base_field
98
101
 
102
+ if GeneratedField is not None and isinstance(model_field, GeneratedField):
103
+ # Allow things like: models.GeneratedField(SomeFunction("geofield"), output_field=models.GeometryField())
104
+ model_field = model_field.output_field
105
+
99
106
  try:
100
107
  # Direct instance, quickly resolved!
101
108
  return XSD_TYPES[model_field.__class__]
@@ -104,10 +111,10 @@ def get_basic_field_type(
104
111
 
105
112
  if isinstance(model_field, models.ForeignKey):
106
113
  # Don't let it query on the relation value yet
107
- return get_basic_field_type(field_name, model_field.target_field)
108
- elif isinstance(model_field, ForeignObjectRel):
114
+ return _get_basic_field_type(field_name, model_field.target_field)
115
+ elif isinstance(model_field, models.ForeignObjectRel):
109
116
  # e.g. ManyToOneRel descriptor of a foreignkey_id field.
110
- return get_basic_field_type(field_name, model_field.remote_field.target_field)
117
+ return _get_basic_field_type(field_name, model_field.remote_field.target_field)
111
118
  else:
112
119
  # Subclass checks:
113
120
  for field_cls, xsd_type in XSD_TYPES.items():
@@ -124,7 +131,7 @@ def get_basic_field_type(
124
131
 
125
132
  def _get_model_fields(
126
133
  model: type[models.Model],
127
- fields: _all_ | list[str],
134
+ fields: list[str] | Literal["__all__"],
128
135
  parent: ComplexFeatureField | None = None,
129
136
  feature_type: FeatureType | None = None,
130
137
  ):
@@ -141,12 +148,12 @@ def _get_model_fields(
141
148
  feature_type=feature_type,
142
149
  )
143
150
  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
151
+ if not f.is_relation or f.many_to_one or f.one_to_one # ForeignKey, OneToOneField
145
152
  ]
146
153
  else:
147
154
  # Only defined fields
148
155
  fields = [f if isinstance(f, FeatureField) else FeatureField(f) for f in fields]
149
- for field in fields:
156
+ for field in fields: # type: FeatureField
150
157
  field.bind(model, parent=parent, feature_type=feature_type)
151
158
  return fields
152
159
 
@@ -172,10 +179,10 @@ class FeatureField:
172
179
  """
173
180
 
174
181
  #: Allow to override the XSD element type that this field will generate.
175
- xsd_element_class: type[XsdElement] = XsdElement
182
+ xsd_element_class: type[XsdElement] = None
176
183
 
177
184
  model: type[models.Model] | None
178
- model_field: models.Field | ForeignObjectRel | None
185
+ model_field: models.Field | models.ForeignObjectRel | None
179
186
 
180
187
  def __init__(
181
188
  self,
@@ -213,14 +220,20 @@ class FeatureField:
213
220
  self.xsd_element_class = xsd_class
214
221
 
215
222
  self._nillable_relation = False
223
+ self._nillable_output_field = False
216
224
  if model is not None:
217
225
  self.bind(model, parent=parent, feature_type=feature_type)
218
226
 
219
227
  def __repr__(self):
220
- return f"<{self.__class__.__name__}: {self.name}>"
228
+ if self.model_field is None:
229
+ return f"<{self.__class__.__name__}: {self.name} (unbound)>"
230
+ else:
231
+ return (
232
+ f"<{self.__class__.__name__}: {self.name}, source={self.absolute_model_attribute}>"
233
+ )
221
234
 
222
235
  def _get_xsd_type(self):
223
- return get_basic_field_type(self.name, self.model_field)
236
+ return _get_basic_field_type(self.name, self.model_field)
224
237
 
225
238
  def bind(
226
239
  self,
@@ -252,29 +265,48 @@ class FeatureField:
252
265
  field_path = self.model_attribute.split(".")
253
266
  try:
254
267
  field: models.Field = self.model._meta.get_field(field_path[0])
268
+
269
+ for name in field_path[1:]:
270
+ if field.null:
271
+ self._nillable_relation = True
272
+
273
+ if not field.is_relation:
274
+ # Note this tests the previous loop variable, so it checks whether the
275
+ # field can be walked into in order to resolve the next dotted name below.
276
+ raise ImproperlyConfigured(
277
+ f"FeatureField '{self.name}' has an invalid model_attribute: "
278
+ f"field '{name}' is a '{field.__class__.__name__}', not a relation."
279
+ )
280
+
281
+ field = field.related_model._meta.get_field(name)
282
+ self.model_field = field
255
283
  except FieldDoesNotExist as e:
256
284
  raise ImproperlyConfigured(
257
285
  f"FeatureField '{self.name}' has an invalid"
258
286
  f" model_attribute: '{self.model_attribute}' can't be"
259
287
  f" resolved for model '{self.model.__name__}'."
260
288
  ) from e
289
+ else:
290
+ try:
291
+ self.model_field = self.model._meta.get_field(self.name)
292
+ if GeneratedField is not None and isinstance(self.model_field, GeneratedField):
293
+ self._nillable_output_field = self.model_field.output_field.null
294
+ except FieldDoesNotExist as e:
295
+ raise ImproperlyConfigured(
296
+ f"FeatureField '{self.name}' can't be resolved for model '{self.model.__name__}'."
297
+ " Either set 'model_attribute' or change the name."
298
+ ) from e
261
299
 
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
- )
300
+ @cached_property
301
+ def absolute_model_attribute(self) -> str:
302
+ """Determine the full attribute of the field."""
303
+ if self.model_field is None:
304
+ raise RuntimeError(f"bind() was not called for {self!r}")
273
305
 
274
- field = field.related_model._meta.get_field(name)
275
- self.model_field = field
306
+ if self.parent is not None:
307
+ return f"{self.parent.absolute_model_attribute}.{self.model_attribute or self.name}"
276
308
  else:
277
- self.model_field = self.model._meta.get_field(self.name)
309
+ return self.model_attribute or self.name
278
310
 
279
311
  @cached_property
280
312
  def xsd_element(self) -> XsdElement:
@@ -287,8 +319,10 @@ class FeatureField:
287
319
  if self.model_field is None:
288
320
  raise RuntimeError(f"bind() was not called for {self!r}")
289
321
 
322
+ xsd_type = self._get_xsd_type()
323
+
290
324
  # Determine max number of occurrences.
291
- if isinstance(self.model_field, GeometryField):
325
+ if xsd_type.is_geometry:
292
326
  max_occurs = 1 # be explicit here, like mapserver does.
293
327
  elif self.model_field.many_to_many or self.model_field.one_to_many:
294
328
  max_occurs = "unbounded" # M2M or reverse FK field
@@ -297,22 +331,27 @@ class FeatureField:
297
331
  else:
298
332
  max_occurs = None # default is 1, but attribute can be left out.
299
333
 
300
- return self.xsd_element_class(
334
+ # Determine which subclass to use for the element.
335
+ xsd_element_class = self.xsd_element_class or (
336
+ GeometryXsdElement if xsd_type.is_geometry else XsdElement
337
+ )
338
+
339
+ return xsd_element_class(
301
340
  name=self.name,
302
- type=self._get_xsd_type(),
303
- nillable=self.model_field.null or self._nillable_relation,
341
+ type=xsd_type,
342
+ namespace=self.feature_type.xml_namespace, # keep all types in the same namespace for now.
343
+ nillable=(
344
+ self.model_field.null or self._nillable_relation or self._nillable_output_field
345
+ ),
304
346
  min_occurs=0,
305
347
  max_occurs=max_occurs,
306
348
  model_attribute=self.model_attribute,
349
+ absolute_model_attribute=self.absolute_model_attribute,
307
350
  source=self.model_field,
308
351
  feature_type=self.feature_type,
309
352
  )
310
353
 
311
354
 
312
- _FieldDefinition = Union[str, FeatureField]
313
- _FieldDefinitions = Union[_all_, list[_FieldDefinition]]
314
-
315
-
316
355
  class ComplexFeatureField(FeatureField):
317
356
  """The configuration for an embedded relation field.
318
357
 
@@ -324,12 +363,12 @@ class ComplexFeatureField(FeatureField):
324
363
  def __init__(
325
364
  self,
326
365
  name: str,
327
- fields: _FieldDefinitions,
366
+ fields: list[str | FeatureField] | Literal["__all__"],
328
367
  model_attribute=None,
329
368
  model=None,
330
369
  abstract=None,
331
370
  xsd_class=None,
332
- xsd_base_type=XsdTypes.gmlAbstractFeatureType,
371
+ xsd_base_type=None,
333
372
  ):
334
373
  """
335
374
  :param name: Name of the model field.
@@ -355,7 +394,9 @@ class ComplexFeatureField(FeatureField):
355
394
  @cached_property
356
395
  def fields(self) -> list[FeatureField]:
357
396
  """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)
397
+ return _get_model_fields(
398
+ self.target_model, self._fields, parent=self, feature_type=self.feature_type
399
+ )
359
400
 
360
401
  def _get_xsd_type(self) -> XsdComplexType:
361
402
  """Generate the XSD description for the field with an object relation."""
@@ -363,6 +404,7 @@ class ComplexFeatureField(FeatureField):
363
404
 
364
405
  return XsdComplexType(
365
406
  name=f"{self.target_model._meta.object_name}Type",
407
+ namespace=self.feature_type.xml_namespace, # keep all types in the same namespace for now.
366
408
  elements=[field.xsd_element for field in self.fields],
367
409
  attributes=[
368
410
  # Add gml:id attribute definition, so it can be resolved in xpath
@@ -396,7 +438,7 @@ def field(
396
438
  *,
397
439
  model_attribute=None,
398
440
  abstract: str | None = None,
399
- fields: _FieldDefinitions | None = None,
441
+ fields: list[str | FeatureField] | Literal["__all__"] | None = None,
400
442
  xsd_class: type[XsdElement] | None = None,
401
443
  ) -> FeatureField:
402
444
  """Shortcut to define a WFS field.
@@ -433,7 +475,7 @@ def field(
433
475
  class FeatureType:
434
476
  """Declare a feature that is exposed on the map.
435
477
 
436
- All WFS operations use this class to read the feature ype.
478
+ All WFS operations use this class to read the feature type.
437
479
  You may subclass this class to provide extensions,
438
480
  such as redefining :meth:`get_queryset`.
439
481
 
@@ -447,7 +489,7 @@ class FeatureType:
447
489
  self,
448
490
  queryset: models.QuerySet,
449
491
  *,
450
- fields: _FieldDefinitions | None = None,
492
+ fields: list[str | FeatureField] | Literal["__all__"] | None = None,
451
493
  display_field_name: str | None = None,
452
494
  geometry_field_name: str | None = None,
453
495
  name: str | None = None,
@@ -460,7 +502,7 @@ class FeatureType:
460
502
  metadata_url: str | None = None,
461
503
  # Settings
462
504
  show_name_field: bool = True,
463
- xml_prefix: str = "app",
505
+ xml_namespace: str | None = None,
464
506
  ):
465
507
  """
466
508
  :param queryset: The queryset to retrieve the data.
@@ -477,7 +519,7 @@ class FeatureType:
477
519
  :param metadata_url: Used in WFS metadata.
478
520
  :param show_name_field: Whether to show the ``gml:name`` or the GeoJSON ``geometry_name``
479
521
  field. Default is to show a field when ``name_field`` is given.
480
- :param xml_prefix: The XML namespace prefix to use.
522
+ :param xml_namespace: The XML namespace to use, will be set by :meth:`bind_namespace` otherwise.
481
523
  """
482
524
  if isinstance(queryset, models.QuerySet):
483
525
  self.queryset = queryset
@@ -502,23 +544,41 @@ class FeatureType:
502
544
 
503
545
  # Settings
504
546
  self.show_name_field = show_name_field
505
- self.xml_prefix = xml_prefix
547
+ self.xml_namespace = xml_namespace
506
548
 
507
549
  # Validate that the name doesn't require XML escaping.
508
550
  if html.escape(self.name) != self.name or " " in self.name or ":" in self.name:
509
551
  raise ValueError(f"Invalid feature name for XML: <{self.xml_name}>")
510
552
 
511
- self._cached_resolver = lru_cache(100)(self._inner_resolve_element)
553
+ self._cached_resolver = lru_cache(200)(self._inner_resolve_element)
512
554
 
513
- def check_permissions(self, request):
555
+ def __repr__(self):
556
+ return (
557
+ f"<{self.__class__.__qualname__}: {self.xml_name},"
558
+ f" fields={self.fields!r},"
559
+ f" geometry_field_name={self.main_geometry_element.absolute_model_attribute!r}>"
560
+ )
561
+
562
+ def bind_namespace(self, default_xml_namespace: str):
563
+ """Make sure the feature type receives the settings from the parent view."""
564
+ if not self.xml_namespace:
565
+ self.xml_namespace = default_xml_namespace
566
+
567
+ def check_permissions(self, request: HttpRequest):
514
568
  """Hook that allows subclasses to reject access for datasets.
515
569
  It may raise a Django PermissionDenied error.
570
+
571
+ This can check for example whether ``request.user`` may access this feature.
572
+
573
+ The parsed WFS request is available as ``request.ows_request``.
574
+ Currently, this can be a :class:`~gisserver.parsers.wfs20.GetFeature`
575
+ or :class:`~gisserver.parsers.wfs20.GetPropertyValue` instance.
516
576
  """
517
577
 
518
578
  @cached_property
519
- def xml_name(self):
520
- """Return the feature name with xml namespace prefix."""
521
- return f"{self.xml_prefix}:{self.name}"
579
+ def xml_name(self) -> str:
580
+ """Return the feature tag as XML Full Qualified name"""
581
+ return f"{{{self.xml_namespace}}}{self.name}" if self.xml_namespace else self.name
522
582
 
523
583
  @cached_property
524
584
  def supported_crs(self) -> list[CRS]:
@@ -526,20 +586,19 @@ class FeatureType:
526
586
  return [self.crs] + self.other_crs
527
587
 
528
588
  @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
- ]
589
+ def all_geometry_elements(self) -> list[GeometryXsdElement]:
590
+ """Provide access to all geometry elements from *all* nested levels."""
591
+ return self.xsd_type.geometry_elements + list(
592
+ itertools.chain.from_iterable(
593
+ # Take the geometry elements at each object level.
594
+ xsd_element.type.geometry_elements
595
+ for xsd_element in self.xsd_type.all_complex_elements
596
+ # The 'None' level is the root node, which is already added before.
597
+ # For now, don't support geometry fields on an M2M relation.
598
+ # If that use-case is needed, it would require additional work to implement.
599
+ if xsd_element is not None and not xsd_element.is_many
600
+ )
601
+ )
543
602
 
544
603
  @cached_property
545
604
  def _local_model_field_names(self) -> list[str]:
@@ -555,6 +614,17 @@ class FeatureType:
555
614
  @cached_property
556
615
  def fields(self) -> list[FeatureField]:
557
616
  """Define which fields to render."""
617
+ if (
618
+ self._geometry_field_name
619
+ and "." in self._geometry_field_name
620
+ and (self._fields is None or self._fields == "__all__")
621
+ ):
622
+ # If you want to define more complex relationships, please be explicit in which fields you want.
623
+ # Allowing autoconfiguration of this is complex and likely not what you're looking for either.
624
+ raise ImproperlyConfigured(
625
+ "Using a geometry_field_name path requires defining 'fields' explicitly."
626
+ )
627
+
558
628
  # This lazy reading allows providing 'fields' as lazy value.
559
629
  if self._fields is None:
560
630
  # Autoconfig, no fields defined.
@@ -567,6 +637,7 @@ class FeatureType:
567
637
 
568
638
  return [FeatureField(self._geometry_field_name, model=self.model, feature_type=self)]
569
639
  else:
640
+ # Either __all__ or an explicit list.
570
641
  return _get_model_fields(self.model, self._fields, feature_type=self)
571
642
 
572
643
  @cached_property
@@ -578,56 +649,36 @@ class FeatureType:
578
649
  return self.model._meta.get_field(self.display_field_name)
579
650
 
580
651
  @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:
652
+ def main_geometry_element(self) -> GeometryXsdElement:
653
+ """Give access to the main geometry element."""
654
+ if not self._geometry_field_name:
655
+ try:
656
+ # Take the first element that has a geometry.
657
+ return self.all_geometry_elements[0]
658
+ except IndexError:
659
+ raise ImproperlyConfigured(
660
+ f"FeatureType '{self.name}' does not expose any geometry field "
661
+ f"as part of its definition."
662
+ ) from None
663
+ else:
664
+ try:
665
+ return next(
666
+ e
667
+ for e in self.all_geometry_elements
668
+ if e.absolute_model_attribute == self._geometry_field_name
669
+ )
670
+ except StopIteration:
601
671
  raise ImproperlyConfigured(
602
672
  f"FeatureType '{self.name}' does not expose the geometry field "
603
673
  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
674
+ ) from None
624
675
 
625
676
  @cached_property
626
677
  def crs(self) -> CRS:
627
678
  """Tell which projection the data should be presented at."""
628
679
  if self._crs is None:
629
680
  # Default CRS
630
- return CRS.from_srid(self.geometry_field.srid) # checks lookup too
681
+ return CRS.from_srid(self.main_geometry_element.source_srid) # checks lookup too
631
682
  else:
632
683
  return self._crs
633
684
 
@@ -635,6 +686,11 @@ class FeatureType:
635
686
  """Return the queryset that is used as basis for this feature."""
636
687
  # Return a queryset that only retrieves the fields that are actually displayed.
637
688
  # That that without .only(), use at least `self.queryset.all()` so a clone is returned.
689
+ logger.debug(
690
+ "QuerySet for %s default only retrieves: %r",
691
+ self.queryset.model._meta.label,
692
+ self._local_model_field_names,
693
+ )
638
694
  return self.queryset.only(*self._local_model_field_names)
639
695
 
640
696
  def get_related_queryset(self, feature_relation: FeatureRelation) -> models.QuerySet:
@@ -645,51 +701,34 @@ class FeatureType:
645
701
  f"source model is not defined for: {feature_relation.xsd_elements!r}"
646
702
  )
647
703
  # 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)
704
+ logger.debug(
705
+ "QuerySet for %s by default only retrieves: %r",
706
+ feature_relation.related_model._meta.label,
707
+ feature_relation._local_model_field_names,
650
708
  )
709
+ queryset = feature_relation.related_model.objects.only(
710
+ *feature_relation._local_model_field_names
711
+ )
712
+ return self.filter_related_queryset(queryset) # Allow overriding by FeatureType subclasses
651
713
 
652
714
  def filter_related_queryset(self, queryset: models.QuerySet) -> models.QuerySet:
653
715
  """When a related object returns a queryset, this hook allows extra filtering."""
654
716
  return queryset
655
717
 
656
- def get_bounding_box(self) -> BoundingBox | None:
718
+ def get_bounding_box(self) -> WGS84BoundingBox | None:
657
719
  """Returns a WGS84 BoundingBox for the complete feature.
658
720
 
659
721
  This is used by the GetCapabilities request. It may return ``None``
660
722
  when the database table is empty, or the custom queryset doesn't
661
723
  return any results.
662
- """
663
- if not self.geometry_fields:
664
- return None
665
-
666
- geo_expression = conditional_transform(
667
- self.geometry_field.name, self.geometry_field.srid, WGS84.srid
668
- )
669
-
670
- bbox = self.get_queryset().aggregate(a=Extent(geo_expression))["a"]
671
- return BoundingBox(*bbox, crs=WGS84) if bbox else None
672
-
673
- def get_envelope(self, instance, crs: CRS | None = None) -> BoundingBox | None:
674
- """Get the bounding box for a single instance.
675
724
 
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.
725
+ Note that the ``<ows:WGS84BoundingBox>`` element always uses longitude/latitude,
726
+ as it doesn't describe a CRS.
679
727
  """
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:
728
+ if not self.main_geometry_element:
686
729
  return None
687
730
 
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)
731
+ return get_wgs84_bounding_box(self.get_queryset(), self.main_geometry_element)
693
732
 
694
733
  def get_display_value(self, instance: models.Model) -> str:
695
734
  """Generate the display name value"""
@@ -709,12 +748,8 @@ class FeatureType:
709
748
  """
710
749
  pk_field = self.model._meta.pk
711
750
 
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
-
751
+ # Define <gml:boundedBy>
752
+ base_elements = [GmlBoundedByElement(feature_type=self)]
718
753
  if self.show_name_field:
719
754
  # Add <gml:name>
720
755
  gml_name = GmlNameElement(
@@ -724,12 +759,14 @@ class FeatureType:
724
759
  )
725
760
  base_elements.insert(0, gml_name)
726
761
 
762
+ # Write out the definition of XsdTypes.gmlAbstractFeatureType as actual class definition.
763
+ # By having these base elements, the XPath queries can also resolve these like any other feature field elements.
727
764
  return XsdComplexType(
728
- prefix="gml",
729
765
  name="AbstractFeatureType",
766
+ namespace=xmlns.gml.value,
730
767
  elements=base_elements,
731
768
  attributes=[
732
- # Add gml:id attribute definition so it can be resolved in xpath
769
+ # Add gml:id="..." attribute definition so it can be resolved in xpath
733
770
  GmlIdAttribute(
734
771
  type_name=self.name,
735
772
  source=pk_field,
@@ -744,13 +781,14 @@ class FeatureType:
744
781
  def xsd_type(self) -> XsdComplexType:
745
782
  """Return the definition of this feature as an XSD Complex Type."""
746
783
  return self.xsd_type_class(
747
- name=f"{self.name.title()}Type",
784
+ name=f"{self.name[0].upper()}{self.name[1:]}Type",
748
785
  elements=[field.xsd_element for field in self.fields],
786
+ namespace=self.xml_namespace,
749
787
  base=self.xsd_base_type,
750
788
  source=self.model,
751
789
  )
752
790
 
753
- def resolve_element(self, xpath: str) -> XPathMatch:
791
+ def resolve_element(self, xpath: str, ns_aliases: dict[str, str]) -> XPathMatch:
754
792
  """Resolve the element, and the matching object.
755
793
 
756
794
  This is used to convert XPath references in requests
@@ -758,13 +796,28 @@ class FeatureType:
758
796
 
759
797
  Internally, this method caches results.
760
798
  """
761
- nodes = self._cached_resolver(xpath) # calls _inner_resolve_element
799
+ # When the XML POST request used xmlns="http://www.opengis.net/wfs/2.0",
800
+ # this will become the default namespace elements are translated into.
801
+ # However, the XPath elements should be interpreted within our application namespace instead.
802
+ # When the default namespace is missing, it should also resolve to our feature.
803
+ ns_aliases = ns_aliases.copy()
804
+ ns_aliases[""] = self.xml_namespace
805
+
806
+ if len(ns_aliases) > 10:
807
+ # Avoid filling memory by caching large namespace blobs.
808
+ nodes = self._inner_resolve_element(xpath, ns_aliases)
809
+ else:
810
+ # Go through lru_cache() for faster lookup of the same elements.
811
+ # Note 1: the cache will be less effective when clients use different namespace aliases.
812
+ # Note 2: when WFSView overrides get_feature_types() the cache may only exist per request.
813
+ nodes = self._cached_resolver(xpath, HDict(ns_aliases))
814
+
762
815
  if nodes is None:
763
816
  raise ExternalValueError(f"Field '{xpath}' does not exist.")
764
817
 
765
818
  return XPathMatch(self, nodes, query=xpath)
766
819
 
767
- def _inner_resolve_element(self, xpath: str):
820
+ def _inner_resolve_element(self, xpath: str, ns_aliases: dict[str, str]):
768
821
  """Inner part of resolve_element() that is cached.
769
822
  This performs any additional checks that happen at the root-level only.
770
823
  """
@@ -781,16 +834,35 @@ class FeatureType:
781
834
  elif "(" in xpath:
782
835
  raise NotImplementedError(f"XPath selectors with functions are not supported: {xpath}")
783
836
 
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
795
-
796
- return self.xsd_type.resolve_element_path(xpath)
837
+ # Allow /app:ElementName/.. as "absolute" path, simply strip it for now.
838
+ # This is only actually needed to support join queries, which we don't.
839
+ xpath = xpath.lstrip("/")
840
+ root_name, _, _ = xpath.partition("/")
841
+ root_xml_name = parse_qname(root_name, ns_aliases)
842
+ if root_xml_name == self.xml_name:
843
+ xpath = xpath[len(root_name) + 1 :]
844
+
845
+ return self.xsd_type.resolve_element_path(xpath, ns_aliases)
846
+
847
+ def resolve_crs(self, crs: CRS, locator="") -> CRS:
848
+ """Check a parsed CRS against the list of supported types."""
849
+ try:
850
+ pos = self.supported_crs.index(crs)
851
+
852
+ # Replace the parsed CRS with the declared one, which may have a 'backend' configured.
853
+ return self.supported_crs[pos]
854
+ except ValueError:
855
+ if conf.GISSERVER_SUPPORTED_CRS_ONLY:
856
+ raise InvalidParameterValue(
857
+ f"Feature '{self.name}' does not support SRID {crs.srid}.",
858
+ locator=locator,
859
+ ) from None
860
+ else:
861
+ return crs
862
+
863
+
864
+ class HDict(dict):
865
+ """Dict that can be used in lru_cache()."""
866
+
867
+ def __hash__(self):
868
+ return hash(frozenset(self.items()))