django-gisserver 1.4.1__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 (73) hide show
  1. {django_gisserver-1.4.1.dist-info → django_gisserver-2.0.dist-info}/METADATA +23 -13
  2. django_gisserver-2.0.dist-info/RECORD +66 -0
  3. {django_gisserver-1.4.1.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 +63 -60
  8. gisserver/exceptions.py +47 -9
  9. gisserver/extensions/__init__.py +4 -0
  10. gisserver/{parsers/fes20 → extensions}/functions.py +11 -5
  11. gisserver/extensions/queries.py +261 -0
  12. gisserver/features.py +267 -240
  13. gisserver/geometries.py +34 -39
  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 +129 -305
  18. gisserver/operations/wfs20.py +428 -336
  19. gisserver/output/__init__.py +10 -48
  20. gisserver/output/base.py +198 -143
  21. gisserver/output/csv.py +81 -85
  22. gisserver/output/geojson.py +63 -72
  23. gisserver/output/gml32.py +310 -281
  24. gisserver/output/iters.py +207 -0
  25. gisserver/output/results.py +71 -30
  26. gisserver/output/stored.py +143 -0
  27. gisserver/output/utils.py +75 -154
  28. gisserver/output/xmlschema.py +86 -47
  29. gisserver/parsers/__init__.py +10 -10
  30. gisserver/parsers/ast.py +320 -0
  31. gisserver/parsers/fes20/__init__.py +15 -11
  32. gisserver/parsers/fes20/expressions.py +89 -50
  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 +336 -128
  37. gisserver/parsers/fes20/sorting.py +107 -34
  38. gisserver/parsers/gml/__init__.py +12 -11
  39. gisserver/parsers/gml/base.py +6 -3
  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 +11 -11
  58. gisserver/templates/gisserver/wfs/feature_field.html +2 -2
  59. gisserver/templatetags/gisserver_tags.py +20 -0
  60. gisserver/types.py +375 -258
  61. gisserver/views.py +206 -75
  62. django_gisserver-1.4.1.dist-info/RECORD +0 -53
  63. gisserver/parsers/base.py +0 -149
  64. gisserver/parsers/fes20/query.py +0 -275
  65. gisserver/parsers/tags.py +0 -102
  66. gisserver/queries/__init__.py +0 -34
  67. gisserver/queries/adhoc.py +0 -181
  68. gisserver/queries/base.py +0 -146
  69. gisserver/queries/stored.py +0 -205
  70. gisserver/templates/gisserver/wfs/2.0.0/describe_stored_queries.xml +0 -20
  71. gisserver/templates/gisserver/wfs/2.0.0/list_stored_queries.xml +0 -14
  72. {django_gisserver-1.4.1.dist-info → django_gisserver-2.0.dist-info}/LICENSE +0 -0
  73. {django_gisserver-1.4.1.dist-info → django_gisserver-2.0.dist-info}/top_level.txt +0 -0
gisserver/features.py CHANGED
@@ -17,23 +17,26 @@ 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
21
- from collections import defaultdict
20
+ import itertools
21
+ import logging
22
22
  from dataclasses import dataclass
23
- from functools import cached_property, lru_cache, reduce
24
- from typing import Union
23
+ from functools import cached_property, lru_cache
24
+ from typing import TYPE_CHECKING, Literal, Union
25
25
 
26
- from django.conf import settings
27
26
  from django.contrib.gis.db import models as gis_models
28
27
  from django.contrib.gis.db.models import Extent, GeometryField
29
28
  from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
30
29
  from django.db import models
31
- from django.db.models.fields.related import ForeignObjectRel # Django 2.2 import
30
+ from django.http import HttpRequest
32
31
 
33
- from gisserver.db import conditional_transform
34
- 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
35
36
  from gisserver.geometries import CRS, WGS84, BoundingBox
37
+ from gisserver.parsers.xml import parse_qname, xmlns
36
38
  from gisserver.types import (
39
+ GeometryXsdElement,
37
40
  GmlBoundedByElement,
38
41
  GmlIdAttribute,
39
42
  GmlNameElement,
@@ -44,18 +47,10 @@ 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
- try:
53
- from typing import Literal # Python 3.8
54
-
55
- _all_ = Literal["__all__"]
56
- except ImportError:
57
- _all_ = str
50
+ if TYPE_CHECKING:
51
+ from gisserver.projection import FeatureRelation
58
52
 
53
+ _all_ = Literal["__all__"]
59
54
 
60
55
  __all__ = [
61
56
  "FeatureType",
@@ -65,6 +60,8 @@ __all__ = [
65
60
  "get_basic_field_type",
66
61
  ]
67
62
 
63
+ logger = logging.getLogger(__name__)
64
+
68
65
  XSD_TYPES = {
69
66
  models.CharField: XsdTypes.string,
70
67
  models.TextField: XsdTypes.string,
@@ -91,7 +88,7 @@ DEFAULT_XSD_TYPE = XsdTypes.anyType
91
88
 
92
89
 
93
90
  def get_basic_field_type(
94
- field_name: str, model_field: models.Field | ForeignObjectRel
91
+ field_name: str, model_field: models.Field | models.ForeignObjectRel
95
92
  ) -> XsdAnyType:
96
93
  """Determine the XSD field type for a Django field."""
97
94
  if ArrayField is not None and isinstance(model_field, ArrayField):
@@ -99,6 +96,10 @@ def get_basic_field_type(
99
96
  # The array notation is written as "is_many"
100
97
  model_field = model_field.base_field
101
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
+
102
103
  try:
103
104
  # Direct instance, quickly resolved!
104
105
  return XSD_TYPES[model_field.__class__]
@@ -108,7 +109,7 @@ def get_basic_field_type(
108
109
  if isinstance(model_field, models.ForeignKey):
109
110
  # Don't let it query on the relation value yet
110
111
  return get_basic_field_type(field_name, model_field.target_field)
111
- elif isinstance(model_field, ForeignObjectRel):
112
+ elif isinstance(model_field, models.ForeignObjectRel):
112
113
  # e.g. ManyToOneRel descriptor of a foreignkey_id field.
113
114
  return get_basic_field_type(field_name, model_field.remote_field.target_field)
114
115
  else:
@@ -125,7 +126,12 @@ def get_basic_field_type(
125
126
  return DEFAULT_XSD_TYPE
126
127
 
127
128
 
128
- def _get_model_fields(model, fields, parent=None):
129
+ def _get_model_fields(
130
+ model: type[models.Model],
131
+ fields: _all_ | list[str],
132
+ parent: ComplexFeatureField | None = None,
133
+ feature_type: FeatureType | None = None,
134
+ ):
129
135
  if fields == "__all__":
130
136
  # All regular fields
131
137
  # Relationships will not be expanded since it can expose so many other fields.
@@ -136,15 +142,16 @@ def _get_model_fields(model, fields, parent=None):
136
142
  # .bind() is called directly by providing these arguments via init:
137
143
  model=model,
138
144
  parent=parent,
145
+ feature_type=feature_type,
139
146
  )
140
147
  for f in model._meta.get_fields()
141
- 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
142
149
  ]
143
150
  else:
144
151
  # Only defined fields
145
152
  fields = [f if isinstance(f, FeatureField) else FeatureField(f) for f in fields]
146
- for field in fields:
147
- field.bind(model, parent=parent)
153
+ for field in fields: # type: FeatureField
154
+ field.bind(model, parent=parent, feature_type=feature_type)
148
155
  return fields
149
156
 
150
157
 
@@ -169,10 +176,10 @@ class FeatureField:
169
176
  """
170
177
 
171
178
  #: Allow to override the XSD element type that this field will generate.
172
- xsd_element_class: type[XsdElement] = XsdElement
179
+ xsd_element_class: type[XsdElement] = None
173
180
 
174
181
  model: type[models.Model] | None
175
- model_field: models.Field | ForeignObjectRel | None
182
+ model_field: models.Field | models.ForeignObjectRel | None
176
183
 
177
184
  def __init__(
178
185
  self,
@@ -180,26 +187,47 @@ class FeatureField:
180
187
  model_attribute=None,
181
188
  model=None,
182
189
  parent: ComplexFeatureField | None = None,
190
+ feature_type: FeatureType | None = None,
183
191
  abstract=None,
184
192
  xsd_class: type[XsdElement] | None = None,
185
193
  ):
194
+ """
195
+ Initialize a single field of the feature.
196
+
197
+ :param name: Name of the model field.
198
+ :param model_attribute: Which model attribute to access. This can be a dotted field path.
199
+ :param model: Which model is accessed. Usually this is passed via :meth:`bind`.
200
+ :param parent: The parent field of this element. Usually this is passed via :meth:`bind`.
201
+ :param feature_type: The feature this field is a part of. Usually this is passed via :meth:`bind`.
202
+ :param abstract: The "help text" or short abstract/description for this field.
203
+ :param xsd_class: Override which class is used to construct the internal XsdElement.
204
+ This controls the schema rendering, value retrieval and rendering of the element.
205
+ """
186
206
  self.name = name
187
207
  self.model_attribute = model_attribute
188
208
  self.model = None
189
209
  self.model_field = None
190
210
  self.parent = parent
211
+ self.feature_type = feature_type
191
212
  self.abstract = abstract
213
+
192
214
  # Allow to override the class attribute on 'self',
193
215
  # which avoids having to subclass this field class as well.
194
216
  if xsd_class is not None:
195
217
  self.xsd_element_class = xsd_class
196
218
 
197
219
  self._nillable_relation = False
220
+ self._nillable_output_field = False
198
221
  if model is not None:
199
- self.bind(model)
222
+ self.bind(model, parent=parent, feature_type=feature_type)
200
223
 
201
224
  def __repr__(self):
202
- 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
+ )
203
231
 
204
232
  def _get_xsd_type(self):
205
233
  return get_basic_field_type(self.name, self.model_field)
@@ -208,6 +236,7 @@ class FeatureField:
208
236
  self,
209
237
  model: type[models.Model],
210
238
  parent: ComplexFeatureField | None = None,
239
+ feature_type: FeatureType | None = None,
211
240
  ):
212
241
  """Late-binding for the model.
213
242
 
@@ -220,40 +249,61 @@ class FeatureField:
220
249
  :param model: The model is field is linked to.
221
250
  :param parent: When this element is part of a complex feature,
222
251
  this links to the parent field.
252
+ :param feature_type: The original feature type that his element was mentioned in.
223
253
  """
224
254
  if self.model is not None:
225
255
  raise RuntimeError(f"Feature field '{self.name}' cannot be reused")
226
256
  self.model = model
227
257
  self.parent = parent
258
+ self.feature_type = feature_type
228
259
 
229
260
  if self.model_attribute:
230
261
  # Support dot based field traversal.
231
262
  field_path = self.model_attribute.split(".")
232
263
  try:
233
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
234
280
  except FieldDoesNotExist as e:
235
281
  raise ImproperlyConfigured(
236
282
  f"FeatureField '{self.name}' has an invalid"
237
283
  f" model_attribute: '{self.model_attribute}' can't be"
238
284
  f" resolved for model '{self.model.__name__}'."
239
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
240
296
 
241
- for name in field_path[1:]:
242
- if field.null:
243
- self._nillable_relation = True
244
-
245
- if not field.is_relation:
246
- # Note this tests the previous loop variable, so it checks whether the
247
- # field can be walked into in order to resolve the next dotted name below.
248
- raise ImproperlyConfigured(
249
- f"FeatureField '{field.name}' has an invalid model_attribute: "
250
- f"field '{name}' is a '{field.__class__.__name__}', not a relation."
251
- )
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}")
252
302
 
253
- field = field.related_model._meta.get_field(name)
254
- 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}"
255
305
  else:
256
- self.model_field = self.model._meta.get_field(self.name)
306
+ return self.model_attribute or self.name
257
307
 
258
308
  @cached_property
259
309
  def xsd_element(self) -> XsdElement:
@@ -266,8 +316,10 @@ class FeatureField:
266
316
  if self.model_field is None:
267
317
  raise RuntimeError(f"bind() was not called for {self!r}")
268
318
 
319
+ xsd_type = self._get_xsd_type()
320
+
269
321
  # Determine max number of occurrences.
270
- if isinstance(self.model_field, GeometryField):
322
+ if xsd_type.is_geometry:
271
323
  max_occurs = 1 # be explicit here, like mapserver does.
272
324
  elif self.model_field.many_to_many or self.model_field.one_to_many:
273
325
  max_occurs = "unbounded" # M2M or reverse FK field
@@ -276,14 +328,24 @@ class FeatureField:
276
328
  else:
277
329
  max_occurs = None # default is 1, but attribute can be left out.
278
330
 
279
- 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(
280
337
  name=self.name,
281
- type=self._get_xsd_type(),
282
- 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
+ ),
283
343
  min_occurs=0,
284
344
  max_occurs=max_occurs,
285
345
  model_attribute=self.model_attribute,
346
+ absolute_model_attribute=self.absolute_model_attribute,
286
347
  source=self.model_field,
348
+ feature_type=self.feature_type,
287
349
  )
288
350
 
289
351
 
@@ -307,13 +369,18 @@ class ComplexFeatureField(FeatureField):
307
369
  model=None,
308
370
  abstract=None,
309
371
  xsd_class=None,
310
- xsd_base_type=XsdTypes.gmlAbstractFeatureType,
372
+ xsd_base_type=None,
311
373
  ):
312
374
  """
313
375
  :param name: Name of the model field.
314
- :param fields: List of fields to expose for the target model. This can
315
- be a list of :class:`FeatureField` objects, or plain field names.
376
+ :param fields: If the field exposes a foreign key, provide its child element names.
377
+ This can be a list of :func:`field` elements, or plain field names.
316
378
  Using ``__all__`` also works but is not recommended outside testing.
379
+ :param model_attribute: Which model attribute to access. This can be a dotted field path.
380
+ :param abstract: The "help text" or short abstract/description for this field.
381
+ :param xsd_class: Override which class is used to construct the internal XsdElement.
382
+ This controls the schema rendering, value retrieval and rendering of the element.
383
+ :param xsd_base_type: Override which class is the base class for the element.
317
384
  """
318
385
  super().__init__(
319
386
  name,
@@ -328,7 +395,9 @@ class ComplexFeatureField(FeatureField):
328
395
  @cached_property
329
396
  def fields(self) -> list[FeatureField]:
330
397
  """Provide all fields that will be rendered as part of this complex field."""
331
- 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
+ )
332
401
 
333
402
  def _get_xsd_type(self) -> XsdComplexType:
334
403
  """Generate the XSD description for the field with an object relation."""
@@ -336,6 +405,7 @@ class ComplexFeatureField(FeatureField):
336
405
 
337
406
  return XsdComplexType(
338
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.
339
409
  elements=[field.xsd_element for field in self.fields],
340
410
  attributes=[
341
411
  # Add gml:id attribute definition, so it can be resolved in xpath
@@ -343,6 +413,7 @@ class ComplexFeatureField(FeatureField):
343
413
  type_name=self.name,
344
414
  source=pk_field,
345
415
  model_attribute=pk_field.name,
416
+ feature_type=self.feature_type,
346
417
  )
347
418
  ],
348
419
  base=self.xsd_base_type,
@@ -377,9 +448,13 @@ def field(
377
448
  so little knowledge is needed about the internal working.
378
449
 
379
450
  :param name: Name of the model field.
380
- :param fields: If the field exposes a foreign key, provide it's child element names.
451
+ :param model_attribute: Which model attribute to access. This can be a dotted field path.
452
+ :param abstract: The "help text" or short abstract/description for this field.
453
+ :param fields: If the field exposes a foreign key, provide its child element names.
381
454
  This can be a list of :func:`field` elements, or plain field names.
382
455
  Using ``__all__`` also works but is not recommended outside testing.
456
+ :param xsd_class: Override which class is used to construct the internal XsdElement.
457
+ This controls the schema rendering, value retrieval and rendering of the element.
383
458
  """
384
459
  if fields is not None:
385
460
  return ComplexFeatureField(
@@ -398,46 +473,10 @@ def field(
398
473
  )
399
474
 
400
475
 
401
- @dataclass
402
- class FeatureRelation:
403
- """Tell which related fields are queried by the feature.
404
- Each dict holds an ORM-path, with the relevant sub-elements.
405
- """
406
-
407
- #: The ORM path that is queried for this particular relation
408
- orm_path: str
409
- #: The fields that will be retrieved for that path
410
- orm_fields: set[str]
411
- #: The model that is accessed for this relation (if set)
412
- related_model: type[models.Model] | None
413
- #: The source elements that access this relation.
414
- xsd_elements: list[XsdElement]
415
-
416
- @cached_property
417
- def _local_model_field_names(self) -> list[str]:
418
- """Tell which local fields of the model will be accessed by this feature."""
419
-
420
- meta = self.related_model._meta
421
- result = []
422
- for name in self.orm_fields:
423
- model_field = meta.get_field(name)
424
- if not model_field.many_to_many and not model_field.one_to_many:
425
- result.append(name)
426
-
427
- # When this relation is retrieved through a ManyToOneRel (reverse FK),
428
- # the prefetch_related() also needs to have the original foreign key
429
- # in order to link all prefetches to the proper parent instance.
430
- for xsd_element in self.xsd_elements:
431
- if xsd_element.source is not None and xsd_element.source.one_to_many:
432
- result.append(xsd_element.source.field.name)
433
-
434
- return result
435
-
436
-
437
476
  class FeatureType:
438
477
  """Declare a feature that is exposed on the map.
439
478
 
440
- All WFS operations use this class to read the feature ype.
479
+ All WFS operations use this class to read the feature type.
441
480
  You may subclass this class to provide extensions,
442
481
  such as redefining :meth:`get_queryset`.
443
482
 
@@ -464,14 +503,14 @@ class FeatureType:
464
503
  metadata_url: str | None = None,
465
504
  # Settings
466
505
  show_name_field: bool = True,
467
- xml_prefix: str = "app",
506
+ xml_namespace: str | None = None,
468
507
  ):
469
508
  """
470
509
  :param queryset: The queryset to retrieve the data.
471
510
  :param fields: Define which fields to show in the WFS data.
472
511
  This can be a list of field names, or :class:`FeatureField` objects.
473
512
  :param display_field_name: Name of the field that's used as general string representation.
474
- :param geometry_field: Name of the geometry field to expose (default = auto detect).
513
+ :param geometry_field_name: Name of the geometry field to expose (default = auto-detect).
475
514
  :param name: Name, also used as XML tag name.
476
515
  :param title: Used in WFS metadata.
477
516
  :param abstract: Used in WFS metadata.
@@ -481,7 +520,7 @@ class FeatureType:
481
520
  :param metadata_url: Used in WFS metadata.
482
521
  :param show_name_field: Whether to show the ``gml:name`` or the GeoJSON ``geometry_name``
483
522
  field. Default is to show a field when ``name_field`` is given.
484
- :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.
485
524
  """
486
525
  if isinstance(queryset, models.QuerySet):
487
526
  self.queryset = queryset
@@ -506,23 +545,34 @@ class FeatureType:
506
545
 
507
546
  # Settings
508
547
  self.show_name_field = show_name_field
509
- self.xml_prefix = xml_prefix
548
+ self.xml_namespace = xml_namespace
510
549
 
511
550
  # Validate that the name doesn't require XML escaping.
512
551
  if html.escape(self.name) != self.name or " " in self.name or ":" in self.name:
513
552
  raise ValueError(f"Invalid feature name for XML: <{self.xml_name}>")
514
553
 
515
- self._cached_resolver = lru_cache(100)(self._inner_resolve_element)
554
+ self._cached_resolver = lru_cache(200)(self._inner_resolve_element)
516
555
 
517
- def check_permissions(self, request):
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
560
+
561
+ def check_permissions(self, request: HttpRequest):
518
562
  """Hook that allows subclasses to reject access for datasets.
519
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.
520
570
  """
521
571
 
522
572
  @cached_property
523
- def xml_name(self):
524
- """Return the feature name with xml namespace prefix."""
525
- 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
526
576
 
527
577
  @cached_property
528
578
  def supported_crs(self) -> list[CRS]:
@@ -530,61 +580,19 @@ class FeatureType:
530
580
  return [self.crs] + self.other_crs
531
581
 
532
582
  @cached_property
533
- def geometry_fields(self) -> list[GeometryField]:
534
- """Tell which fields of the model have a geometry field.
535
- This only compares against fields that are mentioned in this feature type.
536
- """
537
- return [
538
- ff.model_field or getattr(self.model, ff.model_attribute)
539
- for ff in self.fields
540
- if ff.xsd_element.is_geometry
541
- ]
542
-
543
- @cached_property
544
- def orm_relations(self) -> list[FeatureRelation]:
545
- """Tell which fields will be retrieved from related fields.
546
-
547
- This gives an object layout based on the XSD elements,
548
- that can be used for prefetching data.
549
- """
550
- models = {}
551
- fields = defaultdict(set)
552
- elements = defaultdict(list)
553
-
554
- # Check all elements that render as "dotted" flattened relation
555
- for xsd_element in self.xsd_type.flattened_elements:
556
- if xsd_element.source is not None:
557
- # Split "relation.field" notation into path, and take the field as child attribute.
558
- obj_path, field = xsd_element.orm_relation
559
- elements[obj_path].append(xsd_element)
560
- fields[obj_path].add(field)
561
- # field is already on relation:
562
- models[obj_path] = xsd_element.source.model
563
-
564
- # Check all elements that render as "nested" complex type:
565
- for xsd_element in self.xsd_type.complex_elements:
566
- # The complex element itself points to the root of the path,
567
- # all sub elements become the child attributes.
568
- obj_path = xsd_element.orm_path
569
- elements[obj_path].append(xsd_element)
570
- fields[obj_path] = {
571
- f.orm_path
572
- for f in xsd_element.type.elements
573
- if not f.is_many or f.is_array # exclude M2M, but include ArrayField
574
- }
575
- if xsd_element.source:
576
- # field references a related object:
577
- models[obj_path] = xsd_element.source.related_model
578
-
579
- return [
580
- FeatureRelation(
581
- orm_path=obj_path,
582
- orm_fields=sub_fields,
583
- related_model=models.get(obj_path),
584
- xsd_elements=elements[obj_path],
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
585
594
  )
586
- for obj_path, sub_fields in fields.items()
587
- ]
595
+ )
588
596
 
589
597
  @cached_property
590
598
  def _local_model_field_names(self) -> list[str]:
@@ -600,6 +608,17 @@ class FeatureType:
600
608
  @cached_property
601
609
  def fields(self) -> list[FeatureField]:
602
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
+
603
622
  # This lazy reading allows providing 'fields' as lazy value.
604
623
  if self._fields is None:
605
624
  # Autoconfig, no fields defined.
@@ -610,9 +629,10 @@ class FeatureType:
610
629
  if isinstance(f, gis_models.GeometryField)
611
630
  )
612
631
 
613
- return [FeatureField(self._geometry_field_name, model=self.model)]
632
+ return [FeatureField(self._geometry_field_name, model=self.model, feature_type=self)]
614
633
  else:
615
- return _get_model_fields(self.model, self._fields)
634
+ # Either __all__ or an explicit list.
635
+ return _get_model_fields(self.model, self._fields, feature_type=self)
616
636
 
617
637
  @cached_property
618
638
  def display_field(self) -> models.Field | None:
@@ -623,49 +643,36 @@ class FeatureType:
623
643
  return self.model._meta.get_field(self.display_field_name)
624
644
 
625
645
  @cached_property
626
- def geometry_field(self) -> gis_models.GeometryField:
627
- """Give access to the Django field that holds the geometry."""
628
- if not self.geometry_fields:
629
- raise ImproperlyConfigured(
630
- f"FeatureType '{self.name}' does not expose a geometry field."
631
- ) from None
632
-
633
- if self._geometry_field_name:
634
- # Explicitly mentioned, use that field.
635
- # Check whether the server is not accidentally exposing another field
636
- # that is not part of the type definition.
637
- field = next(
638
- (
639
- field
640
- for field in self.geometry_fields
641
- if field.name == self._geometry_field_name
642
- ),
643
- None,
644
- )
645
- 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:
646
665
  raise ImproperlyConfigured(
647
666
  f"FeatureType '{self.name}' does not expose the geometry field "
648
667
  f"'{self._geometry_field_name}' as part of its definition."
649
- )
650
-
651
- return field
652
- else:
653
- # Default: take the first geometry
654
- return self.geometry_fields[0]
655
-
656
- @cached_property
657
- def geometry_field_name(self) -> str:
658
- """Tell which field is the geometry field."""
659
- # Due to internal reorganization this property is no longer needed,
660
- # retained for backward compatibility and to reflect any used input parameters.
661
- return self.geometry_field.name
668
+ ) from None
662
669
 
663
670
  @cached_property
664
671
  def crs(self) -> CRS:
665
672
  """Tell which projection the data should be presented at."""
666
673
  if self._crs is None:
667
674
  # Default CRS
668
- 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
669
676
  else:
670
677
  return self._crs
671
678
 
@@ -673,6 +680,11 @@ class FeatureType:
673
680
  """Return the queryset that is used as basis for this feature."""
674
681
  # Return a queryset that only retrieves the fields that are actually displayed.
675
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
+ )
676
688
  return self.queryset.only(*self._local_model_field_names)
677
689
 
678
690
  def get_related_queryset(self, feature_relation: FeatureRelation) -> models.QuerySet:
@@ -683,9 +695,15 @@ class FeatureType:
683
695
  f"source model is not defined for: {feature_relation.xsd_elements!r}"
684
696
  )
685
697
  # Return a queryset that only retrieves the fields that are displayed.
686
- return self.filter_related_queryset(
687
- 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,
702
+ )
703
+ queryset = feature_relation.related_model.objects.only(
704
+ *feature_relation._local_model_field_names
688
705
  )
706
+ return self.filter_related_queryset(queryset) # Allow overriding by FeatureType subclasses
689
707
 
690
708
  def filter_related_queryset(self, queryset: models.QuerySet) -> models.QuerySet:
691
709
  """When a related object returns a queryset, this hook allows extra filtering."""
@@ -698,37 +716,13 @@ class FeatureType:
698
716
  when the database table is empty, or the custom queryset doesn't
699
717
  return any results.
700
718
  """
701
- if not self.geometry_fields:
719
+ if not self.main_geometry_element:
702
720
  return None
703
721
 
704
- geo_expression = conditional_transform(
705
- self.geometry_field.name, self.geometry_field.srid, WGS84.srid
706
- )
707
-
722
+ geo_expression = get_db_geometry_target(self.main_geometry_element, WGS84)
708
723
  bbox = self.get_queryset().aggregate(a=Extent(geo_expression))["a"]
709
724
  return BoundingBox(*bbox, crs=WGS84) if bbox else None
710
725
 
711
- def get_envelope(self, instance, crs: CRS | None = None) -> BoundingBox | None:
712
- """Get the bounding box for a single instance.
713
-
714
- This is only used for native Python rendering. When the database
715
- rendering is enabled (GISSERVER_USE_DB_RENDERING=True), the calculation
716
- is entirely performed within the query.
717
- """
718
- geometries = [
719
- geom
720
- for geom in (getattr(instance, f.name) for f in self.geometry_fields)
721
- if geom is not None
722
- ]
723
- if not geometries:
724
- return None
725
-
726
- # Perform the combining of geometries inside libgeos
727
- geometry = geometries[0] if len(geometries) == 1 else reduce(operator.or_, geometries)
728
- if crs is not None and geometry.srid != crs.srid:
729
- crs.apply_to(geometry) # avoid clone
730
- return BoundingBox.from_geometry(geometry, crs=crs)
731
-
732
726
  def get_display_value(self, instance: models.Model) -> str:
733
727
  """Generate the display name value"""
734
728
  if self.display_field_name:
@@ -747,12 +741,8 @@ class FeatureType:
747
741
  """
748
742
  pk_field = self.model._meta.pk
749
743
 
750
- # Define <gml:boundedBy>, if the feature has a geometry
751
- base_elements = []
752
- if self.geometry_fields:
753
- # Without a geometry, boundaries are not possible.
754
- base_elements.append(GmlBoundedByElement(feature_type=self))
755
-
744
+ # Define <gml:boundedBy>
745
+ base_elements = [GmlBoundedByElement(feature_type=self)]
756
746
  if self.show_name_field:
757
747
  # Add <gml:name>
758
748
  gml_name = GmlNameElement(
@@ -762,16 +752,19 @@ class FeatureType:
762
752
  )
763
753
  base_elements.insert(0, gml_name)
764
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.
765
757
  return XsdComplexType(
766
- prefix="gml",
767
758
  name="AbstractFeatureType",
759
+ namespace=xmlns.gml.value,
768
760
  elements=base_elements,
769
761
  attributes=[
770
- # 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
771
763
  GmlIdAttribute(
772
764
  type_name=self.name,
773
765
  source=pk_field,
774
766
  model_attribute=pk_field.name,
767
+ feature_type=self,
775
768
  )
776
769
  ],
777
770
  base=XsdTypes.gmlAbstractGMLType,
@@ -781,13 +774,14 @@ class FeatureType:
781
774
  def xsd_type(self) -> XsdComplexType:
782
775
  """Return the definition of this feature as an XSD Complex Type."""
783
776
  return self.xsd_type_class(
784
- name=f"{self.name.title()}Type",
777
+ name=f"{self.name[0].upper()}{self.name[1:]}Type",
785
778
  elements=[field.xsd_element for field in self.fields],
779
+ namespace=self.xml_namespace,
786
780
  base=self.xsd_base_type,
787
781
  source=self.model,
788
782
  )
789
783
 
790
- def resolve_element(self, xpath: str) -> XPathMatch:
784
+ def resolve_element(self, xpath: str, ns_aliases: dict[str, str]) -> XPathMatch:
791
785
  """Resolve the element, and the matching object.
792
786
 
793
787
  This is used to convert XPath references in requests
@@ -795,13 +789,28 @@ class FeatureType:
795
789
 
796
790
  Internally, this method caches results.
797
791
  """
798
- 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
+
799
808
  if nodes is None:
800
809
  raise ExternalValueError(f"Field '{xpath}' does not exist.")
801
810
 
802
811
  return XPathMatch(self, nodes, query=xpath)
803
812
 
804
- def _inner_resolve_element(self, xpath: str):
813
+ def _inner_resolve_element(self, xpath: str, ns_aliases: dict[str, str]):
805
814
  """Inner part of resolve_element() that is cached.
806
815
  This performs any additional checks that happen at the root-level only.
807
816
  """
@@ -818,16 +827,34 @@ class FeatureType:
818
827
  elif "(" in xpath:
819
828
  raise NotImplementedError(f"XPath selectors with functions are not supported: {xpath}")
820
829
 
821
- # Allow /app:ElementName/.. as "absolute" path.
822
- # Given our internal resolver logic, simple solution is to strip it.
823
- for root_prefix in (
824
- f"{self.name}/",
825
- f"/{self.name}/",
826
- f"{self.xml_name}/",
827
- f"/{self.xml_name}/",
828
- ):
829
- if xpath.startswith(root_prefix):
830
- xpath = xpath[len(root_prefix) :]
831
- 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()."""
832
858
 
833
- return self.xsd_type.resolve_element_path(xpath)
859
+ def __hash__(self):
860
+ return hash(frozenset(self.items()))