c2cgeoportal-geoportal 2.3.5.80__py3-none-any.whl → 2.9rc1__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 (197) hide show
  1. c2cgeoportal_geoportal/__init__.py +960 -0
  2. c2cgeoportal_geoportal/lib/__init__.py +256 -0
  3. c2cgeoportal_geoportal/lib/authentication.py +250 -0
  4. c2cgeoportal_geoportal/lib/bashcolor.py +46 -0
  5. c2cgeoportal_geoportal/lib/cacheversion.py +75 -0
  6. c2cgeoportal_geoportal/lib/caching.py +176 -0
  7. c2cgeoportal_geoportal/lib/check_collector.py +80 -0
  8. c2cgeoportal_geoportal/lib/checker.py +295 -0
  9. c2cgeoportal_geoportal/lib/common_headers.py +170 -0
  10. c2cgeoportal_geoportal/lib/dbreflection.py +266 -0
  11. c2cgeoportal_geoportal/lib/filter_capabilities.py +360 -0
  12. c2cgeoportal_geoportal/lib/fulltextsearch.py +50 -0
  13. c2cgeoportal_geoportal/lib/functionality.py +166 -0
  14. c2cgeoportal_geoportal/lib/headers.py +62 -0
  15. c2cgeoportal_geoportal/lib/i18n.py +38 -0
  16. c2cgeoportal_geoportal/lib/layers.py +132 -0
  17. c2cgeoportal_geoportal/lib/lingva_extractor.py +937 -0
  18. c2cgeoportal_geoportal/lib/loader.py +57 -0
  19. c2cgeoportal_geoportal/lib/metrics.py +117 -0
  20. c2cgeoportal_geoportal/lib/oauth2.py +1186 -0
  21. c2cgeoportal_geoportal/lib/oidc.py +302 -0
  22. c2cgeoportal_geoportal/lib/wmstparsing.py +353 -0
  23. c2cgeoportal_geoportal/lib/xsd.py +166 -0
  24. c2cgeoportal_geoportal/py.typed +0 -0
  25. c2cgeoportal_geoportal/resources.py +49 -0
  26. c2cgeoportal_geoportal/scaffolds/advance_create/ci/config.yaml +26 -0
  27. c2cgeoportal_geoportal/scaffolds/advance_create/cookiecutter.json +18 -0
  28. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.dockerignore +6 -0
  29. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.eslintrc.yaml +19 -0
  30. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/.prospector.yaml +30 -0
  31. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/Dockerfile +75 -0
  32. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/Makefile +6 -0
  33. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/alembic.ini +58 -0
  34. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/alembic.yaml +19 -0
  35. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/development.ini +121 -0
  36. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/gunicorn.conf.py +139 -0
  37. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/language_mapping +3 -0
  38. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/lingva-client.cfg +5 -0
  39. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/lingva-server.cfg +6 -0
  40. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/production.ini +38 -0
  41. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/requirements.txt +2 -0
  42. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/setup.py +25 -0
  43. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.api.js +41 -0
  44. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.apps.js +64 -0
  45. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.commons.js +11 -0
  46. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/webpack.config.js +22 -0
  47. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/__init__.py +42 -0
  48. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/authentication.py +10 -0
  49. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/dev.py +14 -0
  50. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/models.py +8 -0
  51. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/multi_organization.py +7 -0
  52. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/resources.py +11 -0
  53. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static-ngeo/api/index.js +12 -0
  54. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static-ngeo/js/{{cookiecutter.package}}module.js +25 -0
  55. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/subscribers.py +39 -0
  56. c2cgeoportal_geoportal/scaffolds/advance_create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/views/__init__.py +0 -0
  57. c2cgeoportal_geoportal/scaffolds/advance_update/cookiecutter.json +18 -0
  58. c2cgeoportal_geoportal/scaffolds/advance_update/{{cookiecutter.project}}/geoportal/CONST_Makefile +121 -0
  59. c2cgeoportal_geoportal/scaffolds/create/cookiecutter.json +18 -0
  60. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.dockerignore +14 -0
  61. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.editorconfig +17 -0
  62. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/main.yaml +73 -0
  63. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/rebuild.yaml +50 -0
  64. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.github/workflows/update_l10n.yaml +66 -0
  65. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.gitignore +16 -0
  66. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.pre-commit-config.yaml +35 -0
  67. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.prettierignore +1 -0
  68. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/.prettierrc.yaml +2 -0
  69. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Dockerfile +75 -0
  70. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/Makefile +70 -0
  71. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/README.rst +29 -0
  72. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/build +179 -0
  73. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/config.yaml +22 -0
  74. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/docker-compose-check +25 -0
  75. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/ci/requirements.txt +2 -0
  76. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-db.yaml +24 -0
  77. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-lib.yaml +511 -0
  78. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose-qgis.yaml +21 -0
  79. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose.override.sample.yaml +59 -0
  80. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/docker-compose.yaml +121 -0
  81. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/env.default +102 -0
  82. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/env.project +69 -0
  83. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/vars.yaml +430 -0
  84. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/locale/en/LC_MESSAGES/{{cookiecutter.package}}_geoportal-client.po +6 -0
  85. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/desktop.css +0 -0
  86. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/iframe_api.css +0 -0
  87. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/css/mobile.css +0 -0
  88. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/banner_left.png +0 -0
  89. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/banner_right.png +0 -0
  90. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/blank.png +0 -0
  91. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-blue.png +0 -0
  92. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-gold.png +0 -0
  93. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker-green.png +0 -0
  94. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/images/markers/marker.png +0 -0
  95. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/geoportal/{{cookiecutter.package}}_geoportal/static/robot.txt.tmpl +3 -0
  96. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/data/Readme.txt +69 -0
  97. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/data/TM_EUROPE_BORDERS-0.3.sql +70 -0
  98. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/demo.map.tmpl +224 -0
  99. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arial.ttf +0 -0
  100. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arialbd.ttf +0 -0
  101. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Arialbi.ttf +0 -0
  102. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Ariali.ttf +0 -0
  103. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Bold.ttf +0 -0
  104. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-BoldItalic.ttf +0 -0
  105. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Italic.ttf +0 -0
  106. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/NotoSans-Regular.ttf +0 -0
  107. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdana.ttf +0 -0
  108. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanab.ttf +0 -0
  109. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanai.ttf +0 -0
  110. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts/Verdanaz.ttf +0 -0
  111. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/fonts.conf +12 -0
  112. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.conf +15 -0
  113. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/mapserver.map.tmpl +87 -0
  114. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/mapserver/tinyows.xml.tmpl +36 -0
  115. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A3_Landscape.jrxml +207 -0
  116. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A3_Portrait.jrxml +185 -0
  117. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A4_Landscape.jrxml +200 -0
  118. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/A4_Portrait.jrxml +170 -0
  119. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/config.yaml.tmpl +175 -0
  120. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/legend.jrxml +109 -0
  121. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/localisation.properties +4 -0
  122. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/localisation_fr.properties +4 -0
  123. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/logo.png +0 -0
  124. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/north.svg +93 -0
  125. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/print/print-apps/{{cookiecutter.package}}/results.jrxml +25 -0
  126. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/project.yaml +18 -0
  127. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/pyproject.toml +7 -0
  128. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/qgisserver/pg_service.conf.tmpl +15 -0
  129. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/run_alembic.sh +11 -0
  130. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/scripts/db-backup +126 -0
  131. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/scripts/db-restore +132 -0
  132. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/setup.cfg +7 -0
  133. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/spell-ignore-words.txt +5 -0
  134. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tests/__init__.py +0 -0
  135. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tests/test_app.py +43 -0
  136. c2cgeoportal_geoportal/scaffolds/create/{{cookiecutter.project}}/tilegeneration/config.yaml.tmpl +195 -0
  137. c2cgeoportal_geoportal/scaffolds/update/cookiecutter.json +18 -0
  138. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/.upgrade.yaml +67 -0
  139. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/CONST_CHANGELOG.txt +295 -0
  140. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/CONST_create_template/tests/test_testapp.py +48 -0
  141. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_config-schema.yaml +922 -0
  142. c2cgeoportal_geoportal/scaffolds/update/{{cookiecutter.project}}/geoportal/CONST_vars.yaml +1503 -0
  143. c2cgeoportal_geoportal/scripts/__init__.py +64 -0
  144. c2cgeoportal_geoportal/scripts/c2cupgrade.py +879 -0
  145. c2cgeoportal_geoportal/scripts/create_demo_theme.py +80 -0
  146. c2cgeoportal_geoportal/scripts/manage_users.py +140 -0
  147. c2cgeoportal_geoportal/scripts/pcreate.py +314 -0
  148. c2cgeoportal_geoportal/scripts/theme2fts.py +347 -0
  149. c2cgeoportal_geoportal/scripts/urllogin.py +81 -0
  150. c2cgeoportal_geoportal/templates/login.html +90 -0
  151. c2cgeoportal_geoportal/templates/notlogin.html +62 -0
  152. c2cgeoportal_geoportal/templates/testi18n.html +12 -0
  153. c2cgeoportal_geoportal/views/__init__.py +59 -0
  154. c2cgeoportal_geoportal/views/dev.py +57 -0
  155. c2cgeoportal_geoportal/views/dynamic.py +208 -0
  156. c2cgeoportal_geoportal/views/entry.py +174 -0
  157. c2cgeoportal_geoportal/views/fulltextsearch.py +189 -0
  158. c2cgeoportal_geoportal/views/geometry_processing.py +75 -0
  159. c2cgeoportal_geoportal/views/i18n.py +129 -0
  160. c2cgeoportal_geoportal/views/layers.py +713 -0
  161. c2cgeoportal_geoportal/views/login.py +679 -0
  162. c2cgeoportal_geoportal/views/mapserverproxy.py +191 -0
  163. c2cgeoportal_geoportal/views/memory.py +90 -0
  164. c2cgeoportal_geoportal/views/ogcproxy.py +120 -0
  165. c2cgeoportal_geoportal/views/pdfreport.py +245 -0
  166. c2cgeoportal_geoportal/views/printproxy.py +143 -0
  167. c2cgeoportal_geoportal/views/profile.py +127 -0
  168. c2cgeoportal_geoportal/views/proxy.py +259 -0
  169. c2cgeoportal_geoportal/views/raster.py +193 -0
  170. c2cgeoportal_geoportal/views/resourceproxy.py +73 -0
  171. c2cgeoportal_geoportal/views/shortener.py +152 -0
  172. c2cgeoportal_geoportal/views/theme.py +1322 -0
  173. c2cgeoportal_geoportal/views/tinyowsproxy.py +189 -0
  174. c2cgeoportal_geoportal/views/vector_tiles.py +83 -0
  175. {c2cgeoportal_geoportal-2.3.5.80.dist-info → c2cgeoportal_geoportal-2.9rc1.dist-info}/METADATA +21 -24
  176. c2cgeoportal_geoportal-2.9rc1.dist-info/RECORD +192 -0
  177. {c2cgeoportal_geoportal-2.3.5.80.dist-info → c2cgeoportal_geoportal-2.9rc1.dist-info}/WHEEL +1 -1
  178. c2cgeoportal_geoportal-2.9rc1.dist-info/entry_points.txt +28 -0
  179. c2cgeoportal_geoportal-2.9rc1.dist-info/top_level.txt +2 -0
  180. tests/__init__.py +100 -0
  181. tests/test_cachebuster.py +71 -0
  182. tests/test_caching.py +275 -0
  183. tests/test_checker.py +85 -0
  184. tests/test_decimaljson.py +47 -0
  185. tests/test_headerstween.py +64 -0
  186. tests/test_i18n.py +31 -0
  187. tests/test_init.py +193 -0
  188. tests/test_locale_negociator.py +69 -0
  189. tests/test_mapserverproxy_route_predicate.py +64 -0
  190. tests/test_raster.py +267 -0
  191. tests/test_wmstparsing.py +238 -0
  192. tests/xmlstr.py +103 -0
  193. c2cgeoportal_geoportal-2.3.5.80.dist-info/DESCRIPTION.rst +0 -8
  194. c2cgeoportal_geoportal-2.3.5.80.dist-info/RECORD +0 -7
  195. c2cgeoportal_geoportal-2.3.5.80.dist-info/entry_points.txt +0 -22
  196. c2cgeoportal_geoportal-2.3.5.80.dist-info/metadata.json +0 -1
  197. c2cgeoportal_geoportal-2.3.5.80.dist-info/top_level.txt +0 -1
@@ -0,0 +1,302 @@
1
+ # Copyright (c) 2024, Camptocamp SA
2
+ # All rights reserved.
3
+
4
+ # Redistribution and use in source and binary forms, with or without
5
+ # modification, are permitted provided that the following conditions are met:
6
+
7
+ # 1. Redistributions of source code must retain the above copyright notice, this
8
+ # list of conditions and the following disclaimer.
9
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ # this list of conditions and the following disclaimer in the documentation
11
+ # and/or other materials provided with the distribution.
12
+
13
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
+ # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
+ # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17
+ # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
+ # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
+ # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+ # The views and conclusions contained in the software and documentation are those
25
+ # of the authors and should not be interpreted as representing official policies,
26
+ # either expressed or implied, of the FreeBSD Project.
27
+
28
+ import datetime
29
+ import json
30
+ import logging
31
+ from typing import TYPE_CHECKING, Any, NamedTuple, Optional, TypedDict, Union
32
+
33
+ import pyramid.request
34
+ import pyramid.response
35
+ import simple_openid_connect.client
36
+ import simple_openid_connect.data
37
+ from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError, HTTPUnauthorized
38
+ from pyramid.security import remember
39
+
40
+ from c2cgeoportal_geoportal.lib.caching import get_region
41
+
42
+ if TYPE_CHECKING:
43
+ from c2cgeoportal_commons.models import main, static
44
+
45
+ _LOG = logging.getLogger(__name__)
46
+ _CACHE_REGION_OBJ = get_region("obj")
47
+
48
+
49
+ # User create on demand
50
+ class DynamicUser(NamedTuple):
51
+ """
52
+ User created dynamically.
53
+ """
54
+
55
+ username: str
56
+ display_name: str
57
+ email: str
58
+ settings_role: Optional["main.Role"]
59
+ roles: list["main.Role"]
60
+
61
+
62
+ @_CACHE_REGION_OBJ.cache_on_arguments()
63
+ def get_oidc_client(request: pyramid.request.Request, host: str) -> simple_openid_connect.client.OpenidClient:
64
+ """
65
+ Get the OpenID Connect client from the request settings.
66
+ """
67
+
68
+ del host # used for cache key
69
+
70
+ authentication_settings = request.registry.settings.get("authentication", {})
71
+ openid_connect = authentication_settings.get("openid_connect", {})
72
+ if openid_connect.get("enabled", False) is not True:
73
+ raise HTTPBadRequest("OpenID Connect not enabled")
74
+
75
+ return simple_openid_connect.client.OpenidClient.from_issuer_url(
76
+ url=openid_connect["url"],
77
+ authentication_redirect_uri=request.route_url("oidc_callback"),
78
+ client_id=openid_connect["client_id"],
79
+ client_secret=openid_connect.get("client-secret"),
80
+ scope=" ".join(openid_connect.get("scopes", ["openid", "profile", "email"])),
81
+ )
82
+
83
+
84
+ class OidcRememberObject(TypedDict):
85
+ """
86
+ The JSON object that is stored in a cookie to remember the user.
87
+ """
88
+
89
+ access_token: str
90
+ access_token_expires: str
91
+ refresh_token: str | None
92
+ refresh_token_expires: str | None
93
+ username: str | None
94
+ display_name: str | None
95
+ email: str | None
96
+ settings_role: str | None
97
+ roles: list[str]
98
+
99
+
100
+ def get_remember_from_user_info(
101
+ request: pyramid.request.Request, user_info: dict[str, Any], remember_object: OidcRememberObject
102
+ ) -> None:
103
+ """
104
+ Fill the remember object from the user info.
105
+
106
+ The remember object will be stored in a cookie to remember the user.
107
+
108
+ :param user_info: The user info from the ID token or from the user info view according to the `query_user_info` configuration.
109
+ :param remember_object: The object to fill, by default with the `username`, `email`, `settings_role` and `roles`,
110
+ the corresponding field from `user_info` can be configured in `user_info_fields`.
111
+ :param settings: The OpenID Connect configuration.
112
+ """
113
+ settings_fields = (
114
+ request.registry.settings.get("authentication", {})
115
+ .get("openid_connect", {})
116
+ .get("user_info_fields", {})
117
+ )
118
+
119
+ for field_, default_field in (
120
+ ("username", "sub"),
121
+ ("display_name", "name"),
122
+ ("email", "email"),
123
+ ("settings_role", None),
124
+ ("roles", None),
125
+ ):
126
+ user_info_field = settings_fields.get(field_, default_field)
127
+ if user_info_field is not None:
128
+ if user_info_field not in user_info:
129
+ _LOG.error(
130
+ "Field '%s' not found in user info, available: %s.",
131
+ user_info_field,
132
+ ", ".join(user_info.keys()),
133
+ )
134
+ raise HTTPInternalServerError(f"Field '{user_info_field}' not found in user info.")
135
+ remember_object[field_] = user_info[user_info_field] # type: ignore[literal-required]
136
+
137
+
138
+ def get_user_from_remember(
139
+ request: pyramid.request.Request, remember_object: OidcRememberObject, update_create_user: bool = False
140
+ ) -> Union["static.User", DynamicUser] | None:
141
+ """
142
+ Create a user from the remember object filled from `get_remember_from_user_info`.
143
+
144
+ :param remember_object: The object to fill, by default with the `username`, `email`, `settings_role` and `roles`.
145
+ :param settings: The OpenID Connect configuration.
146
+ :param update_create_user: If the user should be updated or created if it does not exist.
147
+ """
148
+
149
+ # Those imports are here to avoid initializing the models module before the database schema are
150
+ # correctly initialized.
151
+ from c2cgeoportal_commons import models # pylint: disable=import-outside-toplevel
152
+ from c2cgeoportal_commons.models import main, static # pylint: disable=import-outside-toplevel
153
+
154
+ assert models.DBSession is not None
155
+
156
+ user: static.User | DynamicUser | None
157
+ username = remember_object["username"]
158
+ assert username is not None
159
+ email = remember_object["email"]
160
+ assert email is not None
161
+ display_name = remember_object["display_name"] or email
162
+
163
+ openid_connect_configuration = request.registry.settings.get("authentication", {}).get(
164
+ "openid_connect", {}
165
+ )
166
+ provide_roles = openid_connect_configuration.get("provide_roles", False)
167
+ if provide_roles is False:
168
+ user_query = models.DBSession.query(static.User)
169
+ match_field = openid_connect_configuration.get("match_field", "username")
170
+ if match_field == "username":
171
+ user_query = user_query.filter_by(username=username)
172
+ elif match_field == "email":
173
+ user_query = user_query.filter_by(email=email)
174
+ else:
175
+ raise HTTPInternalServerError(
176
+ f"Unknown match_field: '{match_field}', allowed values are 'username' and 'email'"
177
+ )
178
+ user = user_query.one_or_none()
179
+ if update_create_user is True:
180
+ if user is not None:
181
+ for field in openid_connect_configuration.get("update_fields", []):
182
+ if field == "username":
183
+ user.username = username
184
+ elif field == "display_name":
185
+ user.display_name = display_name
186
+ elif field == "email":
187
+ user.email = email
188
+ else:
189
+ raise HTTPInternalServerError(
190
+ f"Unknown update_field: '{field}', allowed values are 'username', 'display_name' and 'email'"
191
+ )
192
+ elif openid_connect_configuration.get("create_user", False) is True:
193
+ user = static.User(username=username, email=email, display_name=display_name)
194
+ models.DBSession.add(user)
195
+ else:
196
+ user = DynamicUser(
197
+ username=username,
198
+ display_name=display_name,
199
+ email=email,
200
+ settings_role=(
201
+ models.DBSession.query(main.Role).filter_by(name=remember_object["settings_role"]).first()
202
+ if remember_object.get("settings_role") is not None
203
+ else None
204
+ ),
205
+ roles=[
206
+ models.DBSession.query(main.Role).filter_by(name=role).one()
207
+ for role in remember_object.get("roles", [])
208
+ ],
209
+ )
210
+ return user
211
+
212
+
213
+ class OidcRemember:
214
+ """
215
+ Build the abject that we want to remember in the cookie.
216
+ """
217
+
218
+ def __init__(self, request: pyramid.request.Request):
219
+ self.request = request
220
+ self.authentication_settings = request.registry.settings.get("authentication", {})
221
+
222
+ @_CACHE_REGION_OBJ.cache_on_arguments()
223
+ def remember(
224
+ self,
225
+ token_response: (
226
+ simple_openid_connect.data.TokenSuccessResponse | simple_openid_connect.data.TokenErrorResponse
227
+ ),
228
+ host: str,
229
+ ) -> OidcRememberObject:
230
+ """
231
+ Remember the user in the cookie.
232
+ """
233
+
234
+ del host # Used for cache key
235
+
236
+ if isinstance(token_response, simple_openid_connect.data.TokenErrorResponse):
237
+ _LOG.warning(
238
+ "OpenID connect connection error: %s [%s]",
239
+ token_response.error_description,
240
+ token_response.error_uri,
241
+ )
242
+ raise HTTPUnauthorized("See server logs for details")
243
+
244
+ if not isinstance(token_response, simple_openid_connect.data.TokenSuccessResponse):
245
+ _LOG.warning("OpenID connect connection error: %s", token_response)
246
+ raise HTTPUnauthorized("See server logs for details")
247
+
248
+ openid_connect = self.authentication_settings.get("openid_connect", {})
249
+ remember_object: OidcRememberObject = {
250
+ "access_token": token_response.access_token,
251
+ "access_token_expires": (
252
+ datetime.datetime.now() + datetime.timedelta(seconds=token_response.expires_in)
253
+ ).isoformat(),
254
+ "refresh_token": token_response.refresh_token,
255
+ "refresh_token_expires": (
256
+ None
257
+ if token_response.refresh_expires_in is None
258
+ else (
259
+ datetime.datetime.now() + datetime.timedelta(seconds=token_response.refresh_expires_in)
260
+ ).isoformat()
261
+ ),
262
+ "username": None,
263
+ "display_name": None,
264
+ "email": None,
265
+ "settings_role": None,
266
+ "roles": [],
267
+ }
268
+ client = get_oidc_client(self.request, self.request.host)
269
+
270
+ if openid_connect.get("query_user_info", False) is True:
271
+ user_info = client.fetch_userinfo(token_response.access_token)
272
+ else:
273
+ un_validated_user_info = simple_openid_connect.data.IdToken.parse_jwt(
274
+ token_response.id_token, client.provider_keys
275
+ )
276
+ _LOG.info(
277
+ "Receive audiences: %s",
278
+ (
279
+ un_validated_user_info.aud
280
+ if isinstance(un_validated_user_info.aud, str)
281
+ else ", ".join(un_validated_user_info.aud)
282
+ ),
283
+ )
284
+ user_info = client.decode_id_token(
285
+ token_response.id_token,
286
+ extra_trusted_audiences=openid_connect.get(
287
+ "trusted_audiences", [openid_connect.get("client_id")]
288
+ ),
289
+ )
290
+
291
+ self.request.get_remember_from_user_info(user_info.dict(), remember_object)
292
+ self.request.response.headers.extend(remember(self.request, json.dumps(remember_object)))
293
+
294
+ return remember_object
295
+
296
+
297
+ def includeme(config: pyramid.config.Configurator) -> None:
298
+ """
299
+ Pyramid includeme function.
300
+ """
301
+ config.add_request_method(get_remember_from_user_info, name="get_remember_from_user_info")
302
+ config.add_request_method(get_user_from_remember, name="get_user_from_remember")
@@ -0,0 +1,353 @@
1
+ # Copyright (c) 2013-2024, Camptocamp SA
2
+ # All rights reserved.
3
+
4
+ # Redistribution and use in source and binary forms, with or without
5
+ # modification, are permitted provided that the following conditions are met:
6
+
7
+ # 1. Redistributions of source code must retain the above copyright notice, this
8
+ # list of conditions and the following disclaimer.
9
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ # this list of conditions and the following disclaimer in the documentation
11
+ # and/or other materials provided with the distribution.
12
+
13
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
14
+ # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
15
+ # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
16
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
17
+ # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
18
+ # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
19
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
20
+ # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
21
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
22
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+ # The views and conclusions contained in the software and documentation are those
25
+ # of the authors and should not be interpreted as representing official policies,
26
+ # either expressed or implied, of the FreeBSD Project.
27
+
28
+
29
+ import datetime
30
+ from typing import Any, Union
31
+
32
+ import isodate
33
+
34
+ TimeExtent = Union["TimeExtentValue", "TimeExtentInterval"]
35
+
36
+
37
+ def min_none(a: datetime.datetime | None, b: datetime.datetime | None) -> datetime.datetime | None:
38
+ """Return the min value, support non in input."""
39
+ if a is None:
40
+ return b
41
+ if b is None:
42
+ return a
43
+ return min(a, b)
44
+
45
+
46
+ def max_none(a: datetime.datetime | None, b: datetime.datetime | None) -> datetime.datetime | None:
47
+ """Return the max value, support non in input."""
48
+ if a is None:
49
+ return b
50
+ if b is None:
51
+ return a
52
+ return max(a, b)
53
+
54
+
55
+ class TimeInformation:
56
+ """
57
+ Collect the WMS time information.
58
+
59
+ Arguments:
60
+
61
+ extent: A time extent instance (``TimeExtentValue`` or ``TimeExtentInterval``)
62
+ mode: The layer mode ("single", "range" or "disabled")
63
+ widget: The layer mode ("slider" (default) or "datepicker")
64
+ """
65
+
66
+ def __init__(self) -> None:
67
+ self.extent: TimeExtent | None = None
68
+ self.mode: str | None = None
69
+ self.widget: str | None = None
70
+ self.layer: dict[str, Any] | None = None
71
+
72
+ def merge(self, layer: dict[str, Any], extent: TimeExtent, mode: str, widget: str) -> None:
73
+ layer_apply = self.layer == layer or (not self.has_time() and extent is not None)
74
+
75
+ self.merge_extent(extent)
76
+ self.merge_mode(mode)
77
+ self.merge_widget(widget)
78
+
79
+ if layer_apply:
80
+ layer["time"] = self.to_dict()
81
+ self.layer = layer
82
+ elif self.layer is not None:
83
+ del self.layer["time"]
84
+ self.layer = None
85
+
86
+ def merge_extent(self, extent: TimeExtent) -> None:
87
+ if self.extent is not None:
88
+ self.extent.merge(extent)
89
+ else:
90
+ self.extent = extent
91
+
92
+ def merge_mode(self, mode: str) -> None:
93
+ if mode != "disabled":
94
+ if self.mode is not None:
95
+ if self.mode != mode:
96
+ raise ValueError(f"Could not mix time mode '{mode!s}' and '{self.mode!s}'")
97
+ else:
98
+ self.mode = mode
99
+
100
+ def merge_widget(self, widget: str | None) -> None:
101
+ widget = "slider" if not widget else widget
102
+ assert widget is not None
103
+
104
+ if self.widget is not None:
105
+ if self.widget != widget:
106
+ raise ValueError(f"Could not mix time widget '{widget!s}' and '{self.widget!s}'")
107
+ else:
108
+ self.widget = widget
109
+
110
+ def has_time(self) -> bool:
111
+ return self.extent is not None
112
+
113
+ def to_dict(self) -> dict[str, Any] | None:
114
+ if self.has_time():
115
+ assert self.extent is not None
116
+ time = self.extent.to_dict()
117
+ time["mode"] = self.mode
118
+ time["widget"] = self.widget
119
+ return time
120
+ return None
121
+
122
+
123
+ class TimeExtentValue:
124
+ """Represents time as a list of values."""
125
+
126
+ def __init__(
127
+ self,
128
+ values: set[datetime.datetime],
129
+ resolution: str,
130
+ min_def_value: datetime.datetime | None,
131
+ max_def_value: datetime.datetime | None,
132
+ ):
133
+ """
134
+ Initialize.
135
+
136
+ Arguments:
137
+
138
+ values: A set() of datetime
139
+ resolution: The resolution from the mapfile time definition
140
+ min_def_value: the minimum default value as a datetime
141
+ max_def_value: the maximum default value as a datetime
142
+ """
143
+ self.values = values
144
+ self.resolution = resolution
145
+ self.min_def_value = min_def_value
146
+ self.max_def_value = max_def_value
147
+
148
+ def merge(self, extent: TimeExtent) -> None:
149
+ if not isinstance(extent, TimeExtentValue):
150
+ raise ValueError("Could not mix time defined as a list of values with other type of definition")
151
+ self.values.update(extent.values)
152
+ self.min_def_value = min_none(self.min_def_value, extent.min_def_value)
153
+ self.max_def_value = max_none(self.max_def_value, extent.max_def_value)
154
+
155
+ def to_dict(self) -> dict[str, Any]:
156
+ values = sorted(self.values)
157
+ min_def_value = _format_date(self.min_def_value) if self.min_def_value else None
158
+ max_def_value = _format_date(self.max_def_value) if self.max_def_value else None
159
+
160
+ return {
161
+ "minValue": _format_date(values[0]),
162
+ "maxValue": _format_date(values[-1]),
163
+ "values": list(map(_format_date, values)),
164
+ "resolution": self.resolution,
165
+ "minDefValue": min_def_value,
166
+ "maxDefValue": max_def_value,
167
+ }
168
+
169
+
170
+ class TimeExtentInterval:
171
+ """Represents time with the help of a start, an end and an interval."""
172
+
173
+ def __init__(
174
+ self,
175
+ start: datetime.datetime,
176
+ end: datetime.datetime,
177
+ interval: tuple[int, int, int, int],
178
+ resolution: str,
179
+ min_def_value: datetime.datetime | None,
180
+ max_def_value: datetime.datetime | None,
181
+ ):
182
+ """
183
+ Initialize.
184
+
185
+ Arguments:
186
+
187
+ start: The start value as a datetime
188
+ end: The end value as a datetime
189
+ interval: The interval as a tuple (years, months, days, seconds)
190
+ resolution: The resolution from the mapfile time definition
191
+ min_def_value: the minimum default value as a datetime
192
+ max_def_value: the maximum default value as a datetime
193
+ """
194
+ self.start = start
195
+ self.end = end
196
+ self.interval = interval
197
+ self.resolution = resolution
198
+ self.min_def_value = min_def_value
199
+ self.max_def_value = max_def_value
200
+
201
+ def merge(self, extent: TimeExtent) -> None:
202
+ if not isinstance(extent, TimeExtentInterval):
203
+ raise ValueError("Could not merge time defined as with an interval with other type of definition")
204
+ if self.interval != extent.interval:
205
+ raise ValueError("Could not merge times defined with a different interval")
206
+ start = min_none(self.start, extent.start)
207
+ assert start is not None
208
+ self.start = start
209
+ end = max_none(self.end, extent.end)
210
+ assert end is not None
211
+ self.end = end
212
+ self.min_def_value = (
213
+ self.min_def_value
214
+ if extent.min_def_value is None
215
+ else (
216
+ extent.min_def_value
217
+ if self.min_def_value is None
218
+ else min_none(self.min_def_value, extent.min_def_value)
219
+ )
220
+ )
221
+ self.max_def_value = (
222
+ self.max_def_value
223
+ if extent.max_def_value is None
224
+ else (
225
+ extent.max_def_value
226
+ if self.max_def_value is None
227
+ else max_none(self.max_def_value, extent.max_def_value)
228
+ )
229
+ )
230
+
231
+ def to_dict(self) -> dict[str, Any]:
232
+ min_def_value = _format_date(self.min_def_value) if self.min_def_value is not None else None
233
+ max_def_value = _format_date(self.max_def_value) if self.max_def_value is not None else None
234
+
235
+ return {
236
+ "minValue": _format_date(self.start),
237
+ "maxValue": _format_date(self.end),
238
+ "interval": self.interval,
239
+ "resolution": self.resolution,
240
+ "minDefValue": min_def_value,
241
+ "maxDefValue": max_def_value,
242
+ }
243
+
244
+
245
+ def parse_extent(extent: list[str], default_values: str) -> TimeExtent:
246
+ """
247
+ Parse a time extend from OWSLib to a `̀ TimeExtentValue`` or a ``TimeExtentInterval``.
248
+
249
+ Two formats are supported:
250
+ * ['start/end/interval']
251
+ * ['date1', 'date2', ..., 'date N']
252
+
253
+ default_values must be a slash separated String from OWSLib's a
254
+ defaulttimeposition
255
+ """
256
+ if extent:
257
+ min_def_value, max_def_value = _parse_default_values(default_values)
258
+ if extent[0].count("/") > 0:
259
+ # case "start/end/interval"
260
+ if len(extent) > 1 or extent[0].count("/") != 2:
261
+ raise ValueError(f"Unsupported time definition '{extent!s}'")
262
+ s, e, i = extent[0].split("/")
263
+ start = _parse_date(s)
264
+ end = _parse_date(e)
265
+ interval = _parse_duration(i)
266
+
267
+ return TimeExtentInterval(start[1], end[1], interval, start[0], min_def_value, max_def_value)
268
+ # case "value1, value2, ..., valueN"
269
+ dates = [_parse_date(d) for d in extent]
270
+ resolution = dates[0][0]
271
+ values = {d[1] for d in dates}
272
+
273
+ return TimeExtentValue(values, resolution, min_def_value, max_def_value)
274
+ raise ValueError(f"Invalid time extent format '{extent}'")
275
+
276
+
277
+ def _parse_default_values(default_values: str) -> tuple[datetime.datetime, datetime.datetime | None]:
278
+ """
279
+ Parse the 'default' value from OWSLib's defaulttimeposition and return a maximum of two dates.
280
+
281
+ default value must be a slash separated String. return None on the seconde value if it does not exist.
282
+ """
283
+ if default_values is None:
284
+ return None, None
285
+
286
+ def_value = default_values.split("/")
287
+
288
+ _, min_def_value = _parse_date(def_value[0])
289
+ max_def_value = None
290
+
291
+ if len(def_value) > 1:
292
+ _, max_def_value = _parse_date(def_value[1])
293
+
294
+ return min_def_value, max_def_value
295
+
296
+
297
+ def _parse_date(date: str) -> tuple[str, datetime.datetime]:
298
+ """
299
+ Parse a date string.
300
+
301
+ Return a tuple containing:
302
+
303
+ * the resolution: "year", "month", "day" or "second"
304
+ * the date as a datetime
305
+
306
+ The returned datetime always has a timezone (default is UTC)
307
+ """
308
+ resolutions = {"year": "%Y", "month": "%Y-%m", "day": "%Y-%m-%d"}
309
+
310
+ for resolution, pattern in list(resolutions.items()):
311
+ try:
312
+ dt = datetime.datetime.strptime(date, pattern)
313
+ return resolution, dt.replace(tzinfo=isodate.UTC)
314
+ except Exception: # pylint: disable=broad-exception-caught # nosec
315
+ pass
316
+
317
+ try:
318
+ dt = isodate.parse_datetime(date)
319
+ if dt.tzinfo is None:
320
+ dt = dt.replace(tzinfo=isodate.UTC)
321
+ return "second", dt
322
+ except Exception as e:
323
+ raise ValueError(f"Invalid date format '{date}'") from e
324
+
325
+
326
+ def _format_date(date: datetime.datetime) -> str:
327
+ str_ = isodate.datetime_isoformat(date)
328
+ assert isinstance(str_, str)
329
+ if date.tzinfo is None:
330
+ str_ += "Z"
331
+ return str_
332
+
333
+
334
+ def _parse_duration(duration: str) -> tuple[int, int, int, int]:
335
+ """
336
+ Parse an ISO 8601 duration (i.e. "P2DT5S").
337
+
338
+ Return a tuple containing:
339
+
340
+ * years
341
+ * months
342
+ * days
343
+ * seconds
344
+ """
345
+ parsed_duration = isodate.parse_duration(duration)
346
+
347
+ # casting years and months to int as isodate might return a float
348
+ return (
349
+ int(parsed_duration.years) if hasattr(parsed_duration, "years") else 0,
350
+ int(parsed_duration.months) if hasattr(parsed_duration, "months") else 0,
351
+ parsed_duration.days,
352
+ parsed_duration.seconds,
353
+ )