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
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: django-gisserver
3
- Version: 1.5.0
3
+ Version: 2.0
4
4
  Summary: Django speaking WFS 2.0 (exposing GeoDjango model fields)
5
5
  Home-page: https://github.com/amsterdam/django-gisserver
6
6
  Author: Diederik van der Boor
7
7
  Author-email: opensource@edoburu.nl
8
8
  License: Mozilla Public License 2.0
9
- Classifier: Development Status :: 4 - Beta
9
+ Platform: UNKNOWN
10
+ Classifier: Development Status :: 5 - Production/Stable
10
11
  Classifier: Environment :: Web Environment
11
12
  Classifier: Intended Audience :: Developers
12
13
  Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
@@ -16,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.9
16
17
  Classifier: Programming Language :: Python :: 3.10
17
18
  Classifier: Programming Language :: Python :: 3.11
18
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
19
21
  Classifier: Framework :: Django
20
22
  Classifier: Framework :: Django :: 3.2
21
23
  Classifier: Framework :: Django :: 4.0
@@ -27,12 +29,12 @@ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
27
29
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
28
30
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
29
31
  Requires: Django (>=3.2)
30
- Requires-Python: >=3.8
32
+ Requires-Python: >=3.9
31
33
  Description-Content-Type: text/markdown
32
34
  License-File: LICENSE
33
35
  Requires-Dist: Django>=3.2
34
36
  Requires-Dist: defusedxml>=0.6.0
35
- Requires-Dist: lru_dict>=1.1.7
37
+ Requires-Dist: lru-dict>=1.1.7
36
38
  Requires-Dist: orjson>=3.9.15
37
39
  Provides-Extra: tests
38
40
  Requires-Dist: django-environ>=0.4.5; extra == "tests"
@@ -66,6 +68,12 @@ Django speaking WFS 2.0 to expose geo data.
66
68
 
67
69
  For more details, see: <https://django-gisserver.readthedocs.io/>
68
70
 
71
+ ## Test drive
72
+
73
+ * Run `docker compose up`.
74
+ * Open http://localhost:8000/ to see this works.
75
+ * Connect to http://localhost:8000/wfs/ in your GIS client (e.g. QGis) and zoom to Amsterdam.
76
+ * Add more data on the map using the Django admin, username and password are both "admin".
69
77
 
70
78
  ## Quickstart
71
79
 
@@ -168,3 +176,5 @@ software developed with public money is also publicly available.
168
176
 
169
177
  This package is initially developed by the City of Amsterdam, but the tools
170
178
  and concepts created in this project can be used in any city.
179
+
180
+
@@ -0,0 +1,66 @@
1
+ gisserver/__init__.py,sha256=e6waXF42UPN4o15WEYAQ7DskYjyZB_gs3IANkuyGZUk,38
2
+ gisserver/compat.py,sha256=qmLeT7wGRIyA2ujUtVEuUX7qgr98snak9b5OTvnkhas,451
3
+ gisserver/conf.py,sha256=ppoyVS95mMoQXVbPxPar-tkq1t7fE0J3F6IyKitMWuA,2563
4
+ gisserver/db.py,sha256=G4xF3xd8BQjGbKdf6QpOwzUfnm-phTarS71IRCV0Rys,5385
5
+ gisserver/exceptions.py,sha256=q0EZJrlZ7JPf8tWB9de6fPWX74JBrlsoHYN4pBdCGck,6132
6
+ gisserver/features.py,sha256=b7XrDC8XMn6SK3YdOuC5DvXlral5losrFrCeweecJUM,35744
7
+ gisserver/geometries.py,sha256=Ok9e6fbAhW32iglg6gc955z0ZTN2p2lQd3WlN-FPJho,12338
8
+ gisserver/projection.py,sha256=TStCF9xF4V_aozKqTD8uW9mPofZogobVDpdltXwsyZo,14859
9
+ gisserver/types.py,sha256=5J18OUeL8o0VOdxlMGn7fPja7z4s-9Dgy6G-cjXReZM,41453
10
+ gisserver/views.py,sha256=e7Bqr05FaflXOjOXMfQSix59bHqRmb_ASCRqqJlWqEY,18998
11
+ gisserver/extensions/__init__.py,sha256=MsYUsNsoxxjeDHhx4DW9eGXvmOnI5PkoUXvKxRZaRRw,136
12
+ gisserver/extensions/functions.py,sha256=Gv_IoWgJbCONbGg2P4GXdZDI18GvNtHDWLWQjOQMJLY,9085
13
+ gisserver/extensions/queries.py,sha256=_UNWjHMgGgWtBzOl9Ez587e6WeE1_9uN8Qyjzfi4PMo,9435
14
+ gisserver/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ gisserver/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ gisserver/management/commands/loadgeojson.py,sha256=bdCdt37cKPIYV9cQtLFJ0BzFcVgQQ9y59kTtPX7bYkA,11493
17
+ gisserver/operations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ gisserver/operations/base.py,sha256=m56SRTnC6GVWfwugtXgdlIVNh6Vr1h8r_OdfBjWQQ5M,9839
19
+ gisserver/operations/wfs20.py,sha256=WIvGXg1-M01s52VB8nSJGM0tKoviMhnNghQIrfqzudM,24226
20
+ gisserver/output/__init__.py,sha256=5DTFnVEvs-lno_6lOOZheXhF4F96VkwAk71Q5juYe1M,977
21
+ gisserver/output/base.py,sha256=EF3PFUrj2pGzbh2Yt4bpjlwkoGDoI63rHn6wkstXA3g,10303
22
+ gisserver/output/csv.py,sha256=wKtcgwY37NjeLhBxAm2qgKk8UDX-4uUZEzK9LmmVKrQ,6624
23
+ gisserver/output/geojson.py,sha256=fTEB-MRaYLYbJzQVvmZ8ev-Cwrl3lgsR8Jif2zWYSKg,11525
24
+ gisserver/output/gml32.py,sha256=OtBXGup5m1GA9dM_p33E9IXtD-9KcZ58MgMyg-lkGd4,32567
25
+ gisserver/output/iters.py,sha256=YbvtIIy977Snm5m7cYyQext0GCpo8S_u4azTCBNqqgw,8355
26
+ gisserver/output/results.py,sha256=vvwJK1JEgRbs9S4sg5fmsd_avw5ztiC107OizygHk5M,15149
27
+ gisserver/output/stored.py,sha256=m3JcR4MUilyuM_5J6XJDOIyzrhRJbcpg-6FMKy69csc,5763
28
+ gisserver/output/utils.py,sha256=H4lD5Dn_xo8lAHcFv5LjKyj-SNMzzZsZrtDny9HISXk,2671
29
+ gisserver/output/xmlschema.py,sha256=fHj8h9-vG508uRmM-kD5r2omde2wa_myJWWwcWe2lus,6445
30
+ gisserver/parsers/__init__.py,sha256=eGORBeT0F8fR3AadV1uq8dbCeZtIeK0-__CcXwSvXgs,402
31
+ gisserver/parsers/ast.py,sha256=DVUF3977RTjIpsu5yxbKS23Yxepn6JMj1NIUdRmQq2Q,11806
32
+ gisserver/parsers/query.py,sha256=Ndipmq94reShe-DNKwJ96Ig0GdmshRhtVIF6OEGMGeg,6386
33
+ gisserver/parsers/values.py,sha256=7AuOX8Zgb6Cnwb1FuY8Jl0r6TbLrGlNmhICw48sNiCE,2320
34
+ gisserver/parsers/xml.py,sha256=jMsK1enKN0i_rdtjzGqcKdxnr9-n2-9jPY6hX56sU4g,9309
35
+ gisserver/parsers/fes20/__init__.py,sha256=ji0bIRap4fvNW_WWhjnbkBm7ozuEblccIHNzL403Y3E,883
36
+ gisserver/parsers/fes20/expressions.py,sha256=sjBO-S0aQDIorAPg6z7SSUwOreveJEA0w7qcj26UXoA,11275
37
+ gisserver/parsers/fes20/filters.py,sha256=nMAQwgSXl2c7SvVZY_PJSO9XSf55ltvDPnCmIQ5SM9s,6518
38
+ gisserver/parsers/fes20/identifiers.py,sha256=E-6ijz050fjoG-wyzzaIYwxVPplE9RVtoLN_iFNIYw8,3513
39
+ gisserver/parsers/fes20/lookups.py,sha256=nVdqNP0uZntgT1czTxuuUMBVhGKbRsKm8RU-YwGt6EE,5362
40
+ gisserver/parsers/fes20/operators.py,sha256=E5sdepKSxKmklDfHZoNwBwPCqihx5RQJfiA91bv_Hxc,31196
41
+ gisserver/parsers/fes20/sorting.py,sha256=MZXT2RCRKWO9368lkbCbd17IIlTHKuRroafAQVNQuXo,4769
42
+ gisserver/parsers/gml/__init__.py,sha256=DS6CRpFayQp3i5UdwdnAenBb6V6B8vSkn4zQmpo-pBM,1573
43
+ gisserver/parsers/gml/base.py,sha256=hyYAVg-pagH43MFvVgLBzPbIY-uAR1ytDPCtNaXLeww,1165
44
+ gisserver/parsers/gml/geometries.py,sha256=DSBwzjsT16iwSH-bPixLmB5LWpNNUVBL0byl3iMN_Eg,4654
45
+ gisserver/parsers/ows/__init__.py,sha256=0SeM6vfwjWI-WeRD7eS_iN4Jsl8uzXr2yMFpZVHokb4,624
46
+ gisserver/parsers/ows/kvp.py,sha256=et-vlI3EjcK_z1ArGt26RxDyipuwI85NNDe0FVcg7eI,7093
47
+ gisserver/parsers/ows/requests.py,sha256=TZRPX_4RGeNX0qN-OJiRZMGYIWfbn-4vF7zVL4Z1nSk,5496
48
+ gisserver/parsers/wfs20/__init__.py,sha256=6_0TZcLVu48J7Pz802CvkrzEoDDIlI7Bz1t7_VPaOjs,881
49
+ gisserver/parsers/wfs20/adhoc.py,sha256=yM88MrGudYz692QZTL5H6KO1KKZHg0DL44NKWezbYxU,9066
50
+ gisserver/parsers/wfs20/base.py,sha256=_yEIJLrkzPxxj8sjePeSn8PA6L4PTnon5UruDWXejBo,5601
51
+ gisserver/parsers/wfs20/projection.py,sha256=iGpg75VnHj3GrFnSJodEuXbhCF04Phh1IfcAFpurkfw,3930
52
+ gisserver/parsers/wfs20/requests.py,sha256=fl7CUHrqhnEm__M67CHG3hVB9c2r7jFTHJIqlVbW8fI,16756
53
+ gisserver/parsers/wfs20/stored.py,sha256=4YtlildS_lUnY2iZyGQiVQM9TkSjgaaD7iDWtumg82c,7090
54
+ gisserver/static/gisserver/index.css,sha256=h24POXHqJOWtcBRNBFMxZiiknbATRTRWCL6nPyTgPr0,440
55
+ gisserver/templates/gisserver/index.html,sha256=Gq6nasptfquoyd7ihKLtj02tyadijK_Qh8ies6pZxy8,920
56
+ gisserver/templates/gisserver/service_description.html,sha256=y61iVhrcjg44qp-EymaFkUwdsB0LeYTGTbFO0JYOCEs,932
57
+ gisserver/templates/gisserver/wfs/feature_field.html,sha256=Kzbs96GcallCdGLeTPYsFnT6wJ5ar-feyqAmHzJT0u8,587
58
+ gisserver/templates/gisserver/wfs/feature_type.html,sha256=pIfa1cMsTlNIrzNiEOaq9LjzzF7_vZ9C7eN6KDBLOHE,788
59
+ gisserver/templates/gisserver/wfs/2.0.0/get_capabilities.xml,sha256=SGZSn7cDU_Oai6d45xrkqhax5CPyI1oIc8BZLOEw1X4,8695
60
+ gisserver/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
+ gisserver/templatetags/gisserver_tags.py,sha256=L2YG-PxiKR0QWBMRLLrL_VR94p6wCb5HzgnrPMg4hug,995
62
+ django_gisserver-2.0.dist-info/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
63
+ django_gisserver-2.0.dist-info/METADATA,sha256=c9OYER5wjNqHxdxWRA3E3F-v5QMDqPvZ-yCM0NRlFMk,6217
64
+ django_gisserver-2.0.dist-info/WHEEL,sha256=tZoeGjtWxWRfdplE7E3d45VPlLNQnvbKiYnx7gwAy8A,92
65
+ django_gisserver-2.0.dist-info/top_level.txt,sha256=8zFCEMmkpixE4TPiOAlxSq3PD2EXvAeFKwG4yZoIIOQ,10
66
+ django_gisserver-2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.6.0)
2
+ Generator: bdist_wheel (0.45.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
gisserver/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.5.0" # follows PEP440
1
+ __version__ = "2.0" # follows PEP440
gisserver/compat.py ADDED
@@ -0,0 +1,23 @@
1
+ """Compatability imports"""
2
+
3
+ import sys
4
+
5
+ import django
6
+ from django.conf import settings
7
+ from django.db import models
8
+
9
+ if (
10
+ "django.contrib.postgres" in settings.INSTALLED_APPS
11
+ or "django.contrib.postgres" in sys.modules
12
+ ):
13
+ from django.contrib.postgres.fields import ArrayField
14
+ else:
15
+ ArrayField = None
16
+
17
+ GeneratedField = models.GeneratedField if django.VERSION >= (5, 0) else None
18
+
19
+
20
+ __all__ = (
21
+ "ArrayField",
22
+ "GeneratedField",
23
+ )
gisserver/conf.py CHANGED
@@ -28,6 +28,13 @@ GISSERVER_SUPPORTED_CRS_ONLY = getattr(settings, "GISSERVER_SUPPORTED_CRS_ONLY",
28
28
  # 0 = No counting, 1 = all pages, 2 = only for the first page.
29
29
  GISSERVER_COUNT_NUMBER_MATCHED = getattr(settings, "GISSERVER_COUNT_NUMBER_MATCHED", 1)
30
30
 
31
+ # -- output rendering
32
+
33
+ GISSERVER_EXTRA_OUTPUT_FORMATS = getattr(settings, "GISSERVER_EXTRA_OUTPUT_FORMATS", {})
34
+ GISSERVER_GET_FEATURE_OUTPUT_FORMATS = getattr(
35
+ settings, "GISSERVER_GET_FEATURE_OUTPUT_FORMATS", {}
36
+ )
37
+
31
38
  # -- max page size
32
39
 
33
40
  # Allow tuning the page size without having to override code.
gisserver/db.py CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  from functools import lru_cache, reduce
6
7
 
7
8
  from django.contrib.gis.db.models import functions
@@ -9,7 +10,9 @@ from django.db import connection, connections, models
9
10
 
10
11
  from gisserver import conf
11
12
  from gisserver.geometries import CRS
12
- from gisserver.types import GmlElement
13
+ from gisserver.types import GeometryXsdElement
14
+
15
+ logger = logging.getLogger(__name__)
13
16
 
14
17
 
15
18
  class AsEWKT(functions.GeoFunc):
@@ -78,63 +81,69 @@ def get_geometries_union(
78
81
  return reduce(functions.Union, expressions)
79
82
 
80
83
 
81
- def conditional_transform(
82
- expression: str | functions.GeoFunc, expression_srid: int, output_srid: int
83
- ) -> str | functions.Transform:
84
- """Apply a CRS Transform to the queried field if that is needed."""
85
- if expression_srid != output_srid:
86
- expression = functions.Transform(expression, srid=output_srid)
87
- return expression
88
-
89
-
90
- def build_db_annotations(
91
- selects: dict[str, str | functions.Func],
92
- name_template: str,
93
- wrapper_func: type[functions.Func],
94
- ) -> dict:
95
- """Utility to build annotations for all geometry fields for an XSD type.
96
- This is used by various DB-optimized rendering methods.
84
+ def replace_queryset_geometries(
85
+ queryset: models.QuerySet,
86
+ geo_elements: list[GeometryXsdElement],
87
+ output_crs: CRS,
88
+ wrapper_func: type[functions.GeoFunc],
89
+ ) -> models.QuerySet:
90
+ """Replace the queryset geometry retrieval with a database-rendered version.
91
+
92
+ This uses absolute paths in the queryset, but can use relative paths for related querysets.
97
93
  """
98
- return {
99
- escape_xml_name(name, name_template): wrapper_func(target)
100
- for name, target in selects.items()
101
- }
94
+ defer_names = []
95
+ as_geo_map = {}
96
+ for geo_element in geo_elements:
97
+ if geo_element.source is not None: # excludes GmlBoundedByElement
98
+ defer_names.append(geo_element.local_orm_path)
99
+ annotation_name = _as_annotation_name(geo_element.local_orm_path, wrapper_func)
100
+ as_geo_map[annotation_name] = wrapper_func(
101
+ get_db_geometry_target(geo_element, output_crs, use_relative_path=True)
102
+ )
103
+
104
+ if not defer_names:
105
+ return queryset
106
+
107
+ logger.debug(
108
+ "DB rendering: QuerySet for %s replacing %r with %r",
109
+ queryset.model._meta.label,
110
+ defer_names,
111
+ list(as_geo_map.keys()),
112
+ )
113
+ return queryset.defer(*defer_names).annotate(**as_geo_map)
102
114
 
103
115
 
104
- def get_db_annotation(instance: models.Model, name: str, name_template: str):
105
- """Retrieve the value that an annotation has added to the model."""
106
- # The "name" allows any XML-tag elements, escape the most obvious
107
- escaped_name = escape_xml_name(name, name_template)
116
+ def get_db_rendered_geometry(
117
+ instance: models.Model, geo_element: GeometryXsdElement, replacement: type[functions.GeoFunc]
118
+ ) -> str:
119
+ """Retrieve the database-rendered geometry.
120
+ This includes formatted EWKT or GML output, rendered by the database.
121
+ """
122
+ annotation_name = _as_annotation_name(geo_element.local_orm_path, replacement)
108
123
  try:
109
- return getattr(instance, escaped_name)
124
+ return getattr(instance, annotation_name)
110
125
  except AttributeError as e:
126
+ prefix = _as_annotation_name("", replacement)
127
+ available = ", ".join(key for key in instance.__dict__ if key.startswith(prefix)) or "none"
111
128
  raise AttributeError(
112
- f" DB annotation {instance._meta.model_name}.{escaped_name}"
113
- f" not found (using {name_template})"
129
+ f" DB annotation {instance._meta.label}.{annotation_name} not found. Found {available}."
114
130
  ) from e
115
131
 
116
132
 
117
133
  @lru_cache
118
- def escape_xml_name(name: str, template="{name}") -> str:
134
+ def _as_annotation_name(name: str, func: type[functions.GeoFunc]) -> str:
119
135
  """Escape an XML name to be used as annotation name."""
120
- return template.format(name=name.replace(".", "_"))
136
+ return f"_{func.__name__}_{name.replace('.', '_')}"
121
137
 
122
138
 
123
- def get_db_geometry_selects(
124
- gml_elements: list[GmlElement], output_crs: CRS
125
- ) -> dict[str, str | functions.Transform]:
126
- """Utility to generate select clauses for the geometry fields of a type.
127
- Key is the xsd element name, value is the database select expression.
139
+ def get_db_geometry_target(
140
+ geo_element: GeometryXsdElement, output_crs: CRS, use_relative_path: bool = False
141
+ ) -> str | functions.Transform:
142
+ """Translate a GML geometry field into the proper expression for retrieving it from the database.
143
+ The path will be wrapped into a CRS Transform function if needed.
128
144
  """
129
- return {
130
- gml_element.name: get_db_geometry_target(gml_element, output_crs)
131
- for gml_element in gml_elements
132
- if gml_element.source is not None
133
- }
134
-
135
-
136
- def get_db_geometry_target(gml_element: GmlElement, output_crs: CRS) -> str | functions.Transform:
137
- """Wrap the selection of a geometry field in a CRS Transform if needed."""
138
- return conditional_transform(
139
- gml_element.orm_path, gml_element.source.srid, output_srid=output_crs.srid
140
- )
145
+ orm_path = geo_element.local_orm_path if use_relative_path else geo_element.orm_path
146
+ if geo_element.source_srid != output_crs.srid:
147
+ return functions.Transform(orm_path, srid=output_crs.srid)
148
+ else:
149
+ return orm_path
gisserver/exceptions.py CHANGED
@@ -8,10 +8,32 @@ https://docs.opengeospatial.org/is/09-025r2/09-025r2.html#35
8
8
  https://docs.opengeospatial.org/is/09-025r2/09-025r2.html#411
9
9
  """
10
10
 
11
+ import logging
12
+ from contextlib import contextmanager
13
+
11
14
  from django.conf import settings
12
15
  from django.http import HttpResponse
13
16
  from django.utils.html import format_html
14
17
 
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @contextmanager
22
+ def wrap_parser_errors(name: str, locator: str):
23
+ """Convert the value into a Python format.
24
+ This catches any typical exceptions and transforms them into an OWSException.
25
+ """
26
+ try:
27
+ yield
28
+ except ExternalParsingError as e:
29
+ raise OperationParsingFailed(
30
+ f"Unable to parse {name} argument: {e}", locator=locator
31
+ ) from None
32
+ except (TypeError, ValueError, NotImplementedError) as e:
33
+ # TypeError/ValueError are raised by most handlers for unexpected data
34
+ # The NotImplementedError can be raised by fes parsing.
35
+ raise InvalidParameterValue(f"Invalid {name} argument: {e}", locator=locator) from None
36
+
15
37
 
16
38
  class ExternalValueError(ValueError):
17
39
  """Raise a ValueError for external input.
@@ -38,7 +60,7 @@ class OWSException(Exception):
38
60
  def __init__(self, text=None, code=None, locator=None, status_code=None):
39
61
  text = text or self.text_template.format(code=self.code, locator=locator)
40
62
  if (code and len(text) < len(code)) or (locator and len(text) < len(locator)):
41
- raise ValueError("text/locator arguments are switched")
63
+ raise ValueError(f"text/locator arguments are switched: {text!r}, locator={locator!r}")
42
64
 
43
65
  super().__init__(text)
44
66
  self.locator = locator
@@ -47,8 +69,10 @@ class OWSException(Exception):
47
69
  self.status_code = status_code or self.status_code
48
70
 
49
71
  def as_response(self):
72
+ logger.debug("Returning HTTP %d for %s: %s", self.status_code, self.code, self.text)
73
+ xml_body = self.as_xml()
50
74
  return HttpResponse(
51
- b'<?xml version="1.0" encoding="UTF-8"?>\n%b' % self.as_xml().encode("utf-8"),
75
+ b'<?xml version="1.0" encoding="UTF-8"?>\n%b' % xml_body.encode("utf-8"),
52
76
  content_type="text/xml; charset=utf-8",
53
77
  status=self.status_code,
54
78
  reason=self.reason,
@@ -0,0 +1,4 @@
1
+ """Package that allows third party code to extend the server.
2
+
3
+ This allows registering extra stored procedures or filter functions.
4
+ """
@@ -22,8 +22,14 @@ FesFunctionBody = Union[models.Func, Callable[..., models.Func]]
22
22
 
23
23
  @dataclass(order=True)
24
24
  class FesFunction:
25
- """Wrapper that defines a fes function, with type descriptions.
26
- This is also used to provide metadata to the GetCapabilities call.
25
+ """A registered database function that can be used by ``<fes:Function name="...">`.
26
+
27
+ The :class:`~gisserver.parsers.fes20.expressions.Function` class will resolve
28
+ these registered functions by name, and call :meth:`build_query` to include them
29
+ in the database query. This will actually insert a Django ORM function in the query!
30
+
31
+ This wrapper class also provides the metadata and type descriptions of the function,
32
+ which is exposed in the ``GetCapabilities`` call.
27
33
  """
28
34
 
29
35
  #: Name of the function
@@ -50,7 +56,7 @@ class FesFunction:
50
56
 
51
57
 
52
58
  class FesFunctionRegistry:
53
- """Registry of functions to be callable by <fes:Function>.
59
+ """Registry of functions to be callable by ``<fes:Function>``.
54
60
 
55
61
  The registered functions should be capable of running an SQL function.
56
62
  """
@@ -102,7 +108,7 @@ class FesFunctionRegistry:
102
108
  return self.functions[function_name]
103
109
  except KeyError:
104
110
  raise InvalidParameterValue(
105
- "filter", f"Unsupported function: {function_name}"
111
+ f"Unsupported function: {function_name}", locator="filter"
106
112
  ) from None
107
113
 
108
114
 
@@ -0,0 +1,261 @@
1
+ """Storage and registry for stored queries.
2
+ These definitions follow the WFS spec.
3
+
4
+ By using the registry, custom stored queries can be registered in this server.
5
+ Out of the box, only the mandatory built-in ``GetFeatureById`` query is present.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import typing
11
+ from collections.abc import Iterable, Iterator
12
+ from dataclasses import dataclass, field
13
+ from functools import partial
14
+ from xml.etree.ElementTree import Element
15
+
16
+ from django.db.models import Q
17
+
18
+ from gisserver.exceptions import InvalidParameterValue, NotFound, OperationNotSupported
19
+ from gisserver.features import FeatureType
20
+ from gisserver.parsers import fes20
21
+ from gisserver.parsers.query import CompiledQuery
22
+ from gisserver.types import XsdTypes
23
+
24
+ if typing.TYPE_CHECKING:
25
+ from gisserver.output import SimpleFeatureCollection
26
+
27
+ __all__ = (
28
+ "GetFeatureById",
29
+ "QueryExpressionText",
30
+ "StoredQueryDescription",
31
+ "StoredQueryRegistry",
32
+ "stored_query_registry",
33
+ )
34
+
35
+ WFS_LANGUAGE = "urn:ogc:def:queryLanguage:OGC-WFS::WFS_QueryExpression" # body is <wfs:Query>
36
+ FES_LANGUAGE = "urn:ogc:def:queryLanguage:OGC-FES:Filter" # body is <fes:Filter>
37
+
38
+
39
+ @dataclass
40
+ class QueryExpressionText:
41
+ """Define the body of a stored query.
42
+
43
+ This object type is defined in the WFS spec.
44
+ It may contain a wfs:Query or wfs:StoredQuery element.
45
+ """
46
+
47
+ #: Which types the query will return.
48
+ return_feature_types: list[str] | None = None
49
+ #: The internal language of the query. Can be a WFS/FES-filter, or "python"
50
+ language: str = FES_LANGUAGE
51
+ #: Whether the implementation_text will be show or hidden from users.
52
+ is_private: bool = True
53
+
54
+ #: Body
55
+ implementation_text: str | Element | None = None
56
+
57
+
58
+ @dataclass
59
+ class StoredQueryDescription:
60
+ """WFS metadata of a stored query.
61
+ This is based on the ``<wfs:StoredQueryDescription>`` element,
62
+ and returned in ``DescribeStoredQueries``.
63
+
64
+ While it's possible to define multiple QueryExpressionText nodes
65
+ as metadata to describe a query, there is still only one implementation.
66
+ Note there is no 'typeNames=...' parameter for stored queries.
67
+ Only direct parameters act as input.
68
+ """
69
+
70
+ #: The ID of the query
71
+ id: str
72
+ #: User-visible title
73
+ title: str
74
+ #: User-visible description
75
+ abstract: str
76
+ #: Parameter declaration
77
+ parameters: dict[str, XsdTypes]
78
+
79
+ #: Metadata describing the query body
80
+ expressions: list[QueryExpressionText] = field(
81
+ default_factory=lambda: [QueryExpressionText(language=FES_LANGUAGE)]
82
+ )
83
+
84
+ #: Python-based implementation for the query.
85
+ implementation_class: type[StoredQueryImplementation] = field(init=False, default=None)
86
+
87
+
88
+ class StoredQueryImplementation:
89
+ """A custom stored query.
90
+
91
+ This receives the parameters as init arguments,
92
+ and should implement :meth:`build_query`.
93
+ The function is registered using ``StoredQueryRegistry.register()``.
94
+ """
95
+
96
+ # Registered metadata
97
+ _meta: StoredQueryDescription
98
+
99
+ # Allow queries to return only the XML nodes, without any <wfs:FeatureCollection> wrapper.
100
+ has_standalone_output: bool = False
101
+
102
+ def __repr__(self):
103
+ return f"<{self.__class__.__name__} implementing '{self._meta.id}'>"
104
+
105
+ def bind(self, source_query, feature_types: list[FeatureType]):
106
+ """Associate this query with the application data."""
107
+ self.source_query = source_query
108
+ self.feature_types = feature_types
109
+ if len(feature_types) > 1:
110
+ raise OperationNotSupported("Join queries are not supported", locator="typeNames")
111
+
112
+ def get_type_names(self) -> list[str]:
113
+ """Tell which type names this query applies to."""
114
+ raise NotImplementedError()
115
+
116
+ def build_query(self, compiler: CompiledQuery) -> Q | None:
117
+ """Contribute our filter expression to the internal query.
118
+
119
+ This should add the filter expressions to the internal query compiler.
120
+ The top-level ``<wfs:StoredQuery>`` object will add
121
+ the ``<wfs:PropertyName>`` logic and other elements.
122
+ """
123
+ raise NotImplementedError()
124
+
125
+ def finalize_results(self, result: SimpleFeatureCollection):
126
+ """Hook to allow subclasses to inspect the results."""
127
+
128
+
129
+ class StoredQueryRegistry:
130
+ """Registry of functions to be callable by <wfs:StoredQuery>."""
131
+
132
+ def __init__(self):
133
+ self.stored_queries: dict[str, type(StoredQueryImplementation)] = {}
134
+
135
+ def __bool__(self):
136
+ return bool(self.stored_queries)
137
+
138
+ def __iter__(self) -> Iterator[StoredQueryDescription]:
139
+ return iter(self.stored_queries.values())
140
+
141
+ def get_queries(self) -> Iterable[StoredQueryDescription]:
142
+ """Find all descriptions for stored queries."""
143
+ return self.stored_queries.values()
144
+
145
+ def register(
146
+ self,
147
+ meta: StoredQueryDescription | None = None,
148
+ query_expression: type[StoredQueryImplementation] | None = None,
149
+ **meta_kwargs,
150
+ ):
151
+ """Register a custom class that handles a stored query.
152
+ This function can be used as decorator or normal call.
153
+ """
154
+ if meta is None:
155
+ meta = StoredQueryDescription(**meta_kwargs)
156
+ elif meta_kwargs:
157
+ raise TypeError("Either provide the 'meta' object or 'meta_kwargs'")
158
+
159
+ if query_expression is not None:
160
+ self._register(meta, query_expression)
161
+ return meta
162
+ else:
163
+ return partial(self._register, meta) # decorator effect.
164
+
165
+ def _register(
166
+ self, meta: StoredQueryDescription, implementation_class: type[StoredQueryImplementation]
167
+ ):
168
+ """Internal registration method."""
169
+ if not issubclass(implementation_class, StoredQueryImplementation):
170
+ raise TypeError(f"Expecting {StoredQueryImplementation}' subclass")
171
+ if meta.implementation_class is not None:
172
+ raise RuntimeError("Can't register same StoredQueryDescription again.")
173
+
174
+ # for now link both. There is always a single implementation for the metadata.
175
+ meta.implementation_class = implementation_class
176
+ self.stored_queries[meta.id] = meta
177
+
178
+ def resolve_query(self, query_id) -> type[StoredQueryDescription]:
179
+ """Find the stored procedure using the ID."""
180
+ try:
181
+ return self.stored_queries[query_id]
182
+ except KeyError:
183
+ raise InvalidParameterValue(
184
+ f"Stored query does not exist: {query_id}",
185
+ locator="STOREDQUERY_ID",
186
+ ) from None
187
+
188
+
189
+ stored_query_registry = StoredQueryRegistry()
190
+
191
+
192
+ @stored_query_registry.register(
193
+ id="urn:ogc:def:query:OGC-WFS::GetFeatureById",
194
+ title="Get feature by identifier",
195
+ abstract="Returns the single feature that corresponds with the ID argument",
196
+ parameters={"id": XsdTypes.string},
197
+ expressions=[QueryExpressionText(language=WFS_LANGUAGE)],
198
+ )
199
+ class GetFeatureById(StoredQueryImplementation):
200
+ """The stored query for GetFeatureById.
201
+
202
+ This can be called using::
203
+
204
+ <wfs:StoredQuery id="urn:ogc:def:query:OGC-WFS::GetFeatureById">
205
+ <wfs:Parameter name="ID">typename.ID</wfs:Parameter>
206
+ </wfs:StoredQuery>
207
+
208
+ or using KVP syntax::
209
+
210
+ ?...&REQUEST=GetFeature&STOREDQUERY_ID=urn:ogc:def:query:OGC-WFS::GetFeatureById&ID=typename.ID
211
+
212
+ The execution of the ``GetFeatureById`` query is essentially the same as::
213
+
214
+ <wfs:Query xmlns:wfs='..." xmlns:fes='...'>
215
+ <fes:Filter><fes:ResourceId rid='{ID}'/></fes:Filter>
216
+ </wfs:Query>
217
+
218
+ or::
219
+
220
+ <wfs:Query typeName="{typename}">
221
+ <fes:Filter>
222
+ <fes:PropertyIsEqualTo>
223
+ <fes:ValueReference>{primary-key-field}</fes:ValueReference>
224
+ <fes:Literal>{ID-value}</fes:Literal>
225
+ </fes:PropertyIsEqualTo>
226
+ </fes:Filter>
227
+ </wfs:Query>
228
+
229
+ Except that the response is supposed to contain only the item itself.
230
+ """
231
+
232
+ # Projection of GetFeatureById only returns the document nodes, not a <wfs:FeatureCollection> wrapper
233
+ has_standalone_output = True
234
+
235
+ def __init__(self, id: str, ns_aliases: dict[str, str]):
236
+ """Initialize the query with the request parameters."""
237
+ if "." not in id:
238
+ # Always report this as 404
239
+ raise NotFound("Expected typeName.id for ID parameter", locator="ID") from None
240
+
241
+ # GetFeatureById is essentially a ResourceId lookup, reuse that logic here.
242
+ self.resource_id = fes20.ResourceId.from_string(id, ns_aliases)
243
+
244
+ def get_type_names(self) -> list[str]:
245
+ """Tell which type names this query applies to."""
246
+ return [self.resource_id.get_type_name()]
247
+
248
+ def build_query(self, compiler: CompiledQuery) -> Q:
249
+ """Contribute our filter expression to the internal query."""
250
+ try:
251
+ return self.resource_id.build_query(compiler)
252
+ except InvalidParameterValue as e:
253
+ raise InvalidParameterValue(f"Invalid ID value: {e.__cause__}", locator="ID") from e
254
+
255
+ def finalize_results(self, results: SimpleFeatureCollection):
256
+ """Override to implement 404 checking."""
257
+ # Directly attempt to collect the data.
258
+ # Avoid having to do that in the output renderer.
259
+ if results.first() is None:
260
+ # WFS 2.0.2: Return NotFound instead of InvalidParameterValue
261
+ raise NotFound(f"Feature not found with ID {self.resource_id.rid}.", locator="ID")