viur-core 3.9.0.dev4__tar.gz → 3.9.0.dev6__tar.gz

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 (134) hide show
  1. {viur_core-3.9.0.dev4/src/viur_core.egg-info → viur_core-3.9.0.dev6}/PKG-INFO +9 -8
  2. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/pyproject.toml +8 -7
  3. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/__init__.py +3 -0
  4. viur_core-3.9.0.dev6/src/viur/core/bones/address.py +105 -0
  5. viur_core-3.9.0.dev6/src/viur/core/bones/captcha.py +177 -0
  6. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/numeric.py +102 -22
  7. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/randomslice.py +2 -2
  8. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/relational.py +16 -9
  9. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/spatial.py +12 -6
  10. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/text.py +1 -1
  11. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/config.py +1 -7
  12. viur_core-3.9.0.dev6/src/viur/core/data/zip_patterns.py +257 -0
  13. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/__init__.py +2 -0
  14. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/query.py +131 -73
  15. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/transport.py +40 -14
  16. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/types.py +12 -1
  17. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/utils.py +14 -1
  18. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/module.py +3 -1
  19. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/file.py +2 -1
  20. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/history.py +42 -13
  21. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/script.py +7 -2
  22. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/translation.py +2 -2
  23. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/user.py +3 -3
  24. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/skelmodule.py +12 -11
  25. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/json/default.py +5 -1
  26. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/securityheaders.py +14 -3
  27. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/__init__.py +2 -1
  28. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/instance.py +70 -9
  29. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/meta.py +36 -1
  30. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/skeleton.py +14 -5
  31. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/utils.py +20 -10
  32. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/tasks.py +4 -2
  33. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/version.py +1 -1
  34. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6/src/viur_core.egg-info}/PKG-INFO +9 -8
  35. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/SOURCES.txt +3 -0
  36. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/requires.txt +7 -7
  37. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/tests/test_config.py +0 -1
  38. viur_core-3.9.0.dev6/tests/test_db.py +433 -0
  39. viur_core-3.9.0.dev6/tests/test_json_encoder.py +146 -0
  40. viur_core-3.9.0.dev4/src/viur/core/bones/captcha.py +0 -130
  41. viur_core-3.9.0.dev4/tests/test_db.py +0 -29
  42. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/LICENSE +0 -0
  43. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/README.md +0 -0
  44. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/setup.cfg +0 -0
  45. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/__init__.py +0 -0
  46. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/base.py +0 -0
  47. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/boolean.py +0 -0
  48. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/code.py +0 -0
  49. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/color.py +0 -0
  50. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/credential.py +0 -0
  51. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/date.py +0 -0
  52. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/email.py +0 -0
  53. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/file.py +0 -0
  54. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/image.py +0 -0
  55. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/json.py +0 -0
  56. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/key.py +0 -0
  57. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/password.py +0 -0
  58. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/phone.py +0 -0
  59. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/raw.py +0 -0
  60. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/record.py +0 -0
  61. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/select.py +0 -0
  62. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/selectcountry.py +0 -0
  63. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/sortindex.py +0 -0
  64. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/spam.py +0 -0
  65. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/string.py +0 -0
  66. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/treeleaf.py +0 -0
  67. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/treenode.py +0 -0
  68. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/uid.py +0 -0
  69. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/uri.py +0 -0
  70. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/bones/user.py +0 -0
  71. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/cache.py +0 -0
  72. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/contrib/__init__.py +0 -0
  73. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/contrib/loginkey.py +0 -0
  74. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/contrib/ratelimit.py +0 -0
  75. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/current.py +0 -0
  76. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/cache.py +0 -0
  77. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/config.py +0 -0
  78. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/db/overrides.py +0 -0
  79. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/decorators.py +0 -0
  80. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/email.py +0 -0
  81. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/errors.py +0 -0
  82. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/i18n.py +0 -0
  83. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/languages/__init__.py +0 -0
  84. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/languages/de.py +0 -0
  85. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/languages/en.py +0 -0
  86. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/logging.py +0 -0
  87. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/__init__.py +0 -0
  88. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/email.py +0 -0
  89. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/formmailer.py +0 -0
  90. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/moduleconf.py +0 -0
  91. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/page.py +0 -0
  92. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/modules/site.py +0 -0
  93. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/pagination.py +0 -0
  94. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/__init__.py +0 -0
  95. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/instanced_module.py +0 -0
  96. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/list.py +0 -0
  97. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/singleton.py +0 -0
  98. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/tree.py +0 -0
  99. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/ratelimit.py +0 -0
  100. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/__init__.py +0 -0
  101. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/abstract.py +0 -0
  102. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/__init__.py +0 -0
  103. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/default.py +0 -0
  104. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/__init__.py +0 -0
  105. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/date.py +0 -0
  106. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/debug.py +0 -0
  107. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/regex.py +0 -0
  108. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/session.py +0 -0
  109. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/strings.py +0 -0
  110. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/tests.py +0 -0
  111. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/viur.py +0 -0
  112. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/html/utils.py +0 -0
  113. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/json/__init__.py +0 -0
  114. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/render/vi/__init__.py +0 -0
  115. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/request.py +0 -0
  116. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/scripts/viur_migrate.py +0 -0
  117. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/secret.py +0 -0
  118. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/securitykey.py +0 -0
  119. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/session.py +0 -0
  120. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/adapter.py +0 -0
  121. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/relskel.py +0 -0
  122. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/tasks.py +0 -0
  123. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/template/error.html +0 -0
  124. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/template/vi_user_google_login.html +0 -0
  125. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/utils/__init__.py +0 -0
  126. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/utils/json.py +0 -0
  127. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/utils/parse.py +0 -0
  128. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur/core/utils/string.py +0 -0
  129. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/dependency_links.txt +0 -0
  130. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/entry_points.txt +0 -0
  131. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/top_level.txt +0 -0
  132. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/tests/test_decorators.py +0 -0
  133. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/tests/test_errors.py +0 -0
  134. {viur_core-3.9.0.dev4 → viur_core-3.9.0.dev6}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: viur-core
3
- Version: 3.9.0.dev4
3
+ Version: 3.9.0.dev6
4
4
  Summary: The core component of ViUR, a development framework for Google App Engine
5
5
  Author-email: Mausbrand Informationssysteme GmbH <devs@viur.dev>
6
6
  Maintainer-email: Jan Max Meyer <jm@mausbrand.de>
@@ -36,11 +36,12 @@ Classifier: License :: OSI Approved :: MIT License
36
36
  Classifier: Operating System :: OS Independent
37
37
  Classifier: Programming Language :: Python :: 3.12
38
38
  Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Programming Language :: Python :: 3.14
39
40
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
40
41
  Requires-Python: >=3.12
41
42
  Description-Content-Type: text/markdown
42
43
  License-File: LICENSE
43
- Requires-Dist: appengine-python-standard~=1.0
44
+ Requires-Dist: appengine-python-standard~=2.0
44
45
  Requires-Dist: Deprecated~=1.2
45
46
  Requires-Dist: google-api-core[grpc]~=2.0
46
47
  Requires-Dist: google-auth~=2.0
@@ -48,23 +49,23 @@ Requires-Dist: google-cloud-bigquery~=3.0
48
49
  Requires-Dist: google-cloud-datastore~=2.0
49
50
  Requires-Dist: google-cloud-iam~=2.0
50
51
  Requires-Dist: google-cloud-logging~=3.0
52
+ Requires-Dist: google-cloud-recaptcha-enterprise~=1.0
51
53
  Requires-Dist: google-cloud-secret-manager~=2.0
52
- Requires-Dist: google-cloud-storage~=2.0
54
+ Requires-Dist: google-cloud-storage<4,>=2
53
55
  Requires-Dist: google-cloud-tasks~=2.0
54
56
  Requires-Dist: google-resumable-media~=2.0
55
57
  Requires-Dist: googleapis-common-protos[grpc]~=1.0
56
- Requires-Dist: gunicorn~=23.0
58
+ Requires-Dist: gunicorn<27,>=23
57
59
  Requires-Dist: jinja2~=3.0
58
60
  Requires-Dist: jsonschema~=4.0
59
61
  Requires-Dist: logics-py==0.0.12
60
- Requires-Dist: pillow~=11.0
62
+ Requires-Dist: pillow<13,>=11
61
63
  Requires-Dist: pyotp~=2.0
62
- Requires-Dist: pytz~=2023.0
64
+ Requires-Dist: pytz>=2023
63
65
  Requires-Dist: pyyaml~=6.0
64
- Requires-Dist: qrcode~=7.0
66
+ Requires-Dist: qrcode<9,>=7
65
67
  Requires-Dist: requests~=2.0
66
68
  Requires-Dist: tzlocal~=5.0
67
- Requires-Dist: urllib3==1.26.20
68
69
  Requires-Dist: user-agents~=2.0
69
70
  Requires-Dist: webob~=1.0
70
71
  Provides-Extra: mailjet
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
6
6
  name = "viur-core"
7
7
  dynamic = ["version"]
8
8
  dependencies = [
9
- "appengine-python-standard~=1.0",
9
+ "appengine-python-standard~=2.0",
10
10
  "Deprecated~=1.2",
11
11
  "google-api-core[grpc]~=2.0",
12
12
  "google-auth~=2.0",
@@ -14,23 +14,23 @@ dependencies = [
14
14
  "google-cloud-datastore~=2.0",
15
15
  "google-cloud-iam~=2.0",
16
16
  "google-cloud-logging~=3.0",
17
+ "google-cloud-recaptcha-enterprise~=1.0",
17
18
  "google-cloud-secret-manager~=2.0",
18
- "google-cloud-storage~=2.0",
19
+ "google-cloud-storage>=2,<4",
19
20
  "google-cloud-tasks~=2.0",
20
21
  "google-resumable-media~=2.0",
21
22
  "googleapis-common-protos[grpc]~=1.0",
22
- "gunicorn~=23.0",
23
+ "gunicorn>=23,<27",
23
24
  "jinja2~=3.0",
24
25
  "jsonschema~=4.0",
25
26
  "logics-py==0.0.12",
26
- "pillow~=11.0",
27
+ "pillow>=11,<13",
27
28
  "pyotp~=2.0",
28
- "pytz~=2023.0",
29
+ "pytz>=2023",
29
30
  "pyyaml~=6.0",
30
- "qrcode~=7.0",
31
+ "qrcode>=7,<9",
31
32
  "requests~=2.0",
32
33
  "tzlocal~=5.0",
33
- "urllib3==1.26.20", # for appengine-python-standard (https://github.com/GoogleCloudPlatform/appengine-python-standard/blob/main/setup.py#L28)
34
34
  "user-agents~=2.0",
35
35
  "webob~=1.0",
36
36
  ]
@@ -52,6 +52,7 @@ classifiers = [
52
52
  "Operating System :: OS Independent",
53
53
  "Programming Language :: Python :: 3.12",
54
54
  "Programming Language :: Python :: 3.13",
55
+ "Programming Language :: Python :: 3.14",
55
56
  "Topic :: Software Development :: Libraries :: Application Frameworks",
56
57
  ]
57
58
 
@@ -29,6 +29,7 @@ from .phone import PhoneBone
29
29
  from .randomslice import RandomSliceBone
30
30
  from .raw import RawBone
31
31
  from .record import RecordBone
32
+ from .address import AddressBone, AddressRelSkel
32
33
  from .relational import RelationalBone, RelationalConsistency, RelationalUpdateLevel
33
34
  from .selectcountry import SelectCountryBone
34
35
  from .select import (
@@ -49,6 +50,8 @@ from .user import UserBone
49
50
 
50
51
  # Expose only specific names
51
52
  __all = [
53
+ "AddressBone",
54
+ "AddressRelSkel",
52
55
  "BaseBone",
53
56
  "BooleanBone",
54
57
  "CaptchaBone",
@@ -0,0 +1,105 @@
1
+ import hashlib
2
+ import logging
3
+ import typing as t
4
+ import urllib.parse
5
+
6
+ import requests
7
+
8
+ from .record import RecordBone
9
+ from .string import StringBone
10
+ from .selectcountry import SelectCountryBone
11
+ from .spatial import SpatialBone
12
+ from ..skeleton.relskel import RelSkel
13
+ from .. import db, i18n
14
+ from ..data.zip_patterns import ZIP_CODE_PATTERNS
15
+
16
+ CACHE_KIND = "viur-addressbone-geocache"
17
+
18
+
19
+ class AddressRelSkel(RelSkel):
20
+ street_name = StringBone(descr="Street", required=True)
21
+ street_number = StringBone(descr="House number")
22
+ address_addition = StringBone(descr="Address addition")
23
+ zip_code = StringBone(
24
+ descr="ZIP / Postal code",
25
+ params={
26
+ "pattern": ZIP_CODE_PATTERNS,
27
+ "pattern-error": i18n.translate(
28
+ "viur.core.bones.address.zip_code.invalid",
29
+ defaultText="Invalid ZIP code",
30
+ ),
31
+ },
32
+ )
33
+ city = StringBone(descr="City", required=True)
34
+ country = SelectCountryBone(descr="Country")
35
+ coordinates = SpatialBone(
36
+ descr="Coordinates",
37
+ boundsLat=(-90, 90),
38
+ boundsLng=(-180, 180),
39
+ gridDimensions=(10, 10),
40
+ )
41
+
42
+
43
+ class AddressBone(RecordBone):
44
+ type = RecordBone.type + ".address"
45
+
46
+ def __init__(
47
+ self,
48
+ *,
49
+ using: t.Type[RelSkel] = AddressRelSkel,
50
+ format: str = "$(street_name) $(street_number), $(zip_code) $(city)",
51
+ **kwargs,
52
+ ):
53
+ super().__init__(using=using, format=format, **kwargs)
54
+
55
+ def after_from_client(self, skel, name, errors):
56
+ value = skel[name]
57
+ if value is not None:
58
+ coords = self.geocode(value)
59
+ if coords:
60
+ value["coordinates"] = coords
61
+
62
+ @staticmethod
63
+ def _cache_key(params: str) -> db.Key:
64
+ digest = hashlib.sha256(params.encode()).hexdigest()
65
+ return db.Key(CACHE_KIND, digest)
66
+
67
+ @staticmethod
68
+ def geocode(skel: RelSkel) -> tuple[float, float] | None:
69
+ street = f"{skel['street_name'] or ''} {skel['street_number'] or ''}".strip()
70
+ params = {
71
+ "street": street,
72
+ "postalcode": skel["zip_code"] or "",
73
+ "city": skel["city"] or "",
74
+ "country": skel["country"] or "",
75
+ "format": "json",
76
+ "limit": 1,
77
+ }
78
+ cache_key = AddressBone._cache_key(urllib.parse.urlencode(params))
79
+ try:
80
+ cached = db.get(cache_key)
81
+ if cached is not None:
82
+ return cached["lat"], cached["lng"]
83
+ response = requests.get(
84
+ "https://nominatim.openstreetmap.org/search",
85
+ params=params,
86
+ headers={"User-Agent": "viur-addressbone/1.0 (viur.dev)"},
87
+ timeout=5,
88
+ )
89
+ if response.status_code != 200:
90
+ logging.error(
91
+ f"AddressBone: Nominatim returned {response.status_code=}"
92
+ )
93
+ return None
94
+ data = response.json()
95
+ if not data:
96
+ return None
97
+ lat, lng = float(data[0]["lat"]), float(data[0]["lon"])
98
+ entity = db.Entity(cache_key)
99
+ entity["lat"] = lat
100
+ entity["lng"] = lng
101
+ db.put(entity)
102
+ return lat, lng
103
+ except Exception as e:
104
+ logging.error(f"AddressBone: Nominatim geocoding failed with {e=}")
105
+ return None
@@ -0,0 +1,177 @@
1
+ import logging
2
+ import typing as t
3
+ import warnings
4
+
5
+ from viur.core import conf, current
6
+ from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
7
+
8
+ from google.cloud import recaptchaenterprise_v1
9
+ from google.cloud.recaptchaenterprise_v1 import Assessment
10
+
11
+ if t.TYPE_CHECKING:
12
+ from viur.core.skeleton import SkeletonInstance
13
+
14
+
15
+ class CaptchaBone(BaseBone):
16
+ r"""
17
+ The CaptchaBone validates reCAPTCHA Enterprise tokens to protect forms from bots.
18
+
19
+ It uses the Google reCAPTCHA Enterprise API and supports both invisible (v3-style score-based)
20
+ and visible (checkbox widget) challenges via the ``render_challenge`` parameter.
21
+
22
+ The token is submitted by the client as the bone's field value and verified server-side
23
+ against the configured site key. A configurable score threshold determines whether
24
+ invisible challenges pass.
25
+
26
+ .. seealso::
27
+
28
+ `Google reCAPTCHA Enterprise setup
29
+ <https://cloud.google.com/recaptcha/docs/set-up-non-google-cloud-environments-api-keys>`
30
+ for creating a site key and enabling the API.
31
+
32
+ Option :attr:`core.config.Security.captcha_default_public_key`
33
+ for global security settings.
34
+
35
+ Option :attr:`core.config.Security.captcha_enforce_always`
36
+ to enforce validation even on development servers.
37
+ """
38
+
39
+ type = "captcha"
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ public_key: str = None,
45
+ score_threshold: float = 0.5,
46
+ render_challenge: bool = False,
47
+ recaptcha_action: str = "",
48
+ **kwargs: t.Any
49
+ ):
50
+ """
51
+ Initializes a new CaptchaBone.
52
+
53
+ :param public_key: The reCAPTCHA Enterprise site key shown to the client.
54
+ Can be omitted if set globally via :attr:`core.config.Security.captcha_default_public_key`.
55
+ :param score_threshold: Minimum score (0–1) required for invisible challenges to pass.
56
+ Ignored when ``render_challenge`` is ``True``.
57
+ :param render_challenge: If ``True``, renders a visible checkbox widget instead of
58
+ running an invisible background check.
59
+ :param recaptcha_action: The action name passed to reCAPTCHA for analytics and scoring.
60
+ Should match the action used on the client side.
61
+ """
62
+ if "publicKey" in kwargs:
63
+ warnings.warn("publicKey parameter is deprecated, please use public_key",
64
+ DeprecationWarning, stacklevel=2)
65
+ public_key = kwargs.pop("publicKey")
66
+ super().__init__(**kwargs)
67
+ if not public_key and conf.security.captcha_default_public_key:
68
+ public_key = conf.security.captcha_default_public_key
69
+ if not public_key:
70
+ raise ValueError("CaptchaBone requires either a public_key or conf.security.captcha_default_public_key")
71
+
72
+ self.public_key = public_key
73
+
74
+ if not (0 < score_threshold <= 1):
75
+ raise ValueError("score_threshold must be between 0 and 1.")
76
+ self.render_challenge = render_challenge
77
+ self.recaptcha_action = recaptcha_action
78
+ self.score_threshold = score_threshold
79
+ self.required = True
80
+
81
+ def serialize(self, skel: "SkeletonInstance", name: str, parentIndexed: bool) -> bool:
82
+ """
83
+ Serializing the Captcha bone is not possible so it return False
84
+ """
85
+ return False
86
+
87
+ def unserialize(self, skel: "SkeletonInstance", name) -> t.Literal[True]:
88
+ """
89
+ Stores the public_key in the SkeletonInstance
90
+
91
+ :param skel: The target :class:`SkeletonInstance`.
92
+ :param name: The name of the CaptchaBone in the :class:`SkeletonInstance`.
93
+
94
+ :returns: boolean, that is true, as the Captcha bone is always unserialized successfully.
95
+ """
96
+ skel.accessedValues[name] = self.public_key
97
+ return True
98
+
99
+ def fromClient(self, skel: "SkeletonInstance", name: str, data: dict) -> None | list[ReadFromClientError]:
100
+ """
101
+ Load the reCAPTCHA token from the provided data and validate it with the help of the API.
102
+
103
+ reCAPTCHA provides the token via callback usually as "g-recaptcha-response",
104
+ but to fit into the skeleton logic, we support both names.
105
+ So the token can be provided as "g-recaptcha-response" or the name of the CaptchaBone in the Skeleton.
106
+ While the latter one is the preferred name.
107
+ """
108
+
109
+ if not conf.security.captcha_enforce_always and conf.instance.is_dev_server:
110
+ logging.info("Skipping captcha validation on development server")
111
+ return None
112
+ if not conf.security.captcha_enforce_always and (user := current.user.get()) and "root" in user["access"]:
113
+ logging.info("Skipping captcha validation for root user")
114
+ return None # Don't bother trusted users with this (not supported by admin/vi anyway)
115
+
116
+ client = recaptchaenterprise_v1.RecaptchaEnterpriseServiceClient()
117
+
118
+ # Set the attributes of the event to be tracked.
119
+ event = recaptchaenterprise_v1.Event()
120
+ event.site_key = self.public_key
121
+ if name in data:
122
+ event.token = data[name]
123
+ else:
124
+ return [ReadFromClientError(
125
+ ReadFromClientErrorSeverity.NotSet,
126
+ "Token not set"
127
+ )]
128
+ assessment = recaptchaenterprise_v1.Assessment()
129
+ assessment.event = event
130
+
131
+ project_name = f"projects/{conf.instance.project_id}"
132
+
133
+ # Create the assessment request.
134
+ request = recaptchaenterprise_v1.CreateAssessmentRequest()
135
+ request.assessment = assessment
136
+ request.parent = project_name
137
+
138
+ response = client.create_assessment(request)
139
+
140
+ if not response.token_properties.valid:
141
+ logging.info(
142
+ "The CreateAssessment call failed because the token was "
143
+ + "invalid for the following reasons: "
144
+ + str(response.token_properties.invalid_reason)
145
+ )
146
+ return [ReadFromClientError(
147
+ ReadFromClientErrorSeverity.Invalid,
148
+ "Invalid Token"
149
+ )]
150
+
151
+ # Check if the expected action was executed.
152
+ if response.token_properties.action != self.recaptcha_action:
153
+ logging.info(
154
+ "The action attribute in your reCAPTCHA tag does not match the action you are expecting to score"
155
+ )
156
+ return [ReadFromClientError(
157
+ ReadFromClientErrorSeverity.Invalid,
158
+ f"Invalid Action: {self.recaptcha_action}"
159
+ )]
160
+ else:
161
+ # Retrieve the risk score and reasons.
162
+ # For more information on interpreting the assessment, see:
163
+ # https://cloud.google.com/recaptcha/docs/interpret-assessment
164
+ if response.risk_analysis.score < self.score_threshold:
165
+ return [ReadFromClientError(
166
+ ReadFromClientErrorSeverity.Invalid,
167
+ f"Invalid Captcha: {response.risk_analysis.score}"
168
+ )]
169
+
170
+ return None
171
+
172
+ def structure(self) -> dict:
173
+ return super().structure() | {
174
+ "public_key": self.public_key,
175
+ "render_challenge": self.render_challenge,
176
+ "action": self.recaptcha_action
177
+ }
@@ -3,6 +3,7 @@ import numbers
3
3
  import sys
4
4
  import typing as t
5
5
  import warnings
6
+ import decimal as deci
6
7
 
7
8
  from viur.core import db, i18n
8
9
  from viur.core.bones.base import BaseBone, ReadFromClientError, ReadFromClientErrorSeverity
@@ -33,6 +34,7 @@ class NumericBone(BaseBone):
33
34
  min: int | float = MIN,
34
35
  max: int | float = MAX,
35
36
  precision: int = 0,
37
+ decimal: bool = False,
36
38
  mode=None, # deprecated!
37
39
  **kwargs
38
40
  ):
@@ -42,6 +44,7 @@ class NumericBone(BaseBone):
42
44
  :param min: Minimum accepted value (including).
43
45
  :param max: Maximum accepted value (including).
44
46
  :param precision: How may decimal places should be saved. Zero casts the value to int instead of float.
47
+ :param decimal: If True, use deci.Decimal internally for exact arithmetic.
45
48
  """
46
49
  super().__init__(**kwargs)
47
50
 
@@ -61,6 +64,9 @@ class NumericBone(BaseBone):
61
64
  self.precision = precision
62
65
  self.min = min
63
66
  self.max = max
67
+ self.decimal = decimal
68
+ if decimal:
69
+ self._quantize_exp = deci.Decimal(10) ** -precision
64
70
 
65
71
  def __setattr__(self, key, value):
66
72
  """
@@ -80,16 +86,42 @@ class NumericBone(BaseBone):
80
86
 
81
87
  return super().__setattr__(key, value)
82
88
 
89
+ def _convert_to_decimal(self, value) -> deci.Decimal | None:
90
+ """Convert *value* to a quantized Decimal. Uses str() roundtrip for floats.
91
+ Accepts comma as decimal separator in strings."""
92
+ if value is None:
93
+ return None
94
+ with deci.localcontext() as ctx:
95
+ # +20 as buffer for integer digits to avoid InvalidOperation on quantize
96
+ ctx.prec = self.precision + 20
97
+ if isinstance(value, deci.Decimal):
98
+ return value.quantize(self._quantize_exp)
99
+ if isinstance(value, str):
100
+ value = value.replace(",", ".", 1)
101
+ return deci.Decimal(value).quantize(self._quantize_exp)
102
+ if isinstance(value, (int, float)):
103
+ return deci.Decimal(str(value)).quantize(self._quantize_exp)
104
+ raise ValueError(f"Cannot convert {type(value).__name__} to Decimal")
105
+
83
106
  def singleValueUnserialize(self, val):
84
107
  if val is not None:
85
108
  try:
109
+ if self.decimal:
110
+ if isinstance(val, dict) and "decimal" in val:
111
+ val = val["decimal"]
112
+ return self._convert_to_decimal(val)
86
113
  return self._convert_to_numeric(val)
87
- except (ValueError, TypeError):
114
+ except (ValueError, TypeError, deci.InvalidOperation):
88
115
  return self.getDefaultValue(None) # FIXME: callable needs the skeleton instance
89
116
 
90
117
  return val
91
118
 
92
119
  def singleValueSerialize(self, value, skel: 'SkeletonInstance', name: str, parentIndexed: bool):
120
+ if self.decimal and value is not None:
121
+ return {
122
+ "val": self._convert_to_numeric(value),
123
+ "decimal": str(self._convert_to_decimal(value)),
124
+ }
93
125
  return self.singleValueUnserialize(value) # same logic for unserialize here!
94
126
 
95
127
  def isInvalid(self, value):
@@ -110,6 +142,8 @@ class NumericBone(BaseBone):
110
142
  :return: Returns 0 for integers (when precision is 0) or 0.0 for floating-point numbers (when
111
143
  precision is non-zero).
112
144
  """
145
+ if self.decimal:
146
+ return deci.Decimal(0).quantize(self._quantize_exp)
113
147
  if self.precision:
114
148
  return 0.0
115
149
  else:
@@ -124,33 +158,53 @@ class NumericBone(BaseBone):
124
158
  :param value: The raw value to be checked for emptiness.
125
159
  :return: Returns True if the raw value is considered empty, otherwise False.
126
160
  """
161
+ if value is None:
162
+ return True
127
163
  if isinstance(value, str) and not value:
128
164
  return True
129
165
  try:
130
- value = self._convert_to_numeric(value)
131
- except (ValueError, TypeError):
166
+ if self.decimal:
167
+ value = self._convert_to_decimal(value)
168
+ else:
169
+ value = self._convert_to_numeric(value)
170
+ except (ValueError, TypeError, deci.InvalidOperation):
132
171
  return True
133
172
  return value == self.getEmptyValue()
134
173
 
135
174
  def singleValueFromClient(self, value, skel, bone_name, client_data):
136
- if not isinstance(value, (int, float)):
137
- # Replace , with .
138
- try:
139
- value = str(value).replace(",", ".", 1)
140
- except TypeError:
141
- return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
142
-
143
- # Convert to float or int -- depending on the precision
144
- # Since we convert direct to int if precision=0, a float value isn't valid
175
+ if self.decimal:
176
+ if value is None or (isinstance(value, str) and not value.strip()):
177
+ return self.getEmptyValue(), [
178
+ ReadFromClientError(ReadFromClientErrorSeverity.Empty, "No value entered")
179
+ ]
145
180
  try:
146
- value = float(value) if self.precision else int(value)
147
- except ValueError:
148
- return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
181
+ if isinstance(value, str):
182
+ value = value.replace(",", ".", 1)
183
+ value = self._convert_to_decimal(value)
184
+ except (deci.InvalidOperation, ValueError, TypeError):
185
+ return self.getEmptyValue(), [
186
+ ReadFromClientError(ReadFromClientErrorSeverity.Invalid, "Invalid decimal value")
187
+ ]
149
188
 
150
- if self.precision:
151
- value = round(float(value), self.precision)
152
189
  else:
153
- value = int(value)
190
+ if not isinstance(value, (int, float)):
191
+ # Replace , with .
192
+ try:
193
+ value = str(value).replace(",", ".", 1)
194
+ except TypeError:
195
+ return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
196
+
197
+ # Convert to float or int -- depending on the precision
198
+ # Since we convert direct to int if precision=0, a float value isn't valid
199
+ try:
200
+ value = float(value) if self.precision else int(value)
201
+ except ValueError:
202
+ return self.getEmptyValue(), [ReadFromClientError(ReadFromClientErrorSeverity.Invalid)]
203
+
204
+ if self.precision:
205
+ value = round(float(value), self.precision)
206
+ else:
207
+ value = int(value)
154
208
 
155
209
  # Check the limits after rounding, as the rounding may change the value.
156
210
  if not (self.min <= value <= self.max):
@@ -182,23 +236,44 @@ class NumericBone(BaseBone):
182
236
  prefix: t.Optional[str] = None
183
237
  ) -> db.Query:
184
238
  updatedFilter = {}
185
-
186
239
  for parmKey, paramValue in rawFilter.items():
187
240
  if parmKey.startswith(name):
188
241
  if parmKey != name and not parmKey.startswith(name + "$"):
189
242
  # It's just another bone which name start's with our's
190
243
  continue
191
244
  try:
192
- if not self.precision:
245
+ if self.decimal:
246
+ paramValue = float(str(paramValue).replace(",", ".", 1))
247
+ elif not self.precision:
193
248
  paramValue = int(paramValue)
194
249
  else:
195
250
  paramValue = float(paramValue)
196
- except ValueError:
251
+ except (ValueError, deci.InvalidOperation):
197
252
  # The value we should filter by is garbage, cancel this query
198
253
  logging.warning(f"Invalid filtering! Unparsable int/float supplied to NumericBone {name}")
199
254
  raise RuntimeError()
200
255
  updatedFilter[parmKey] = paramValue
201
256
 
257
+ if self.decimal:
258
+ # Values are stored as {"val": float, "decimal": str} — filter on the .val sub-property
259
+ prop = (prefix or "") + name + ".val"
260
+ for key, value in updatedFilter.items():
261
+ if key == name:
262
+ dbFilter.filter(prop + " =", value)
263
+ else:
264
+ op = key[len(name) + 1:] # the part after "$"
265
+ if op == "lt":
266
+ dbFilter.filter(prop + " <", value)
267
+ elif op == "le":
268
+ dbFilter.filter(prop + " <=", value)
269
+ elif op == "gt":
270
+ dbFilter.filter(prop + " >", value)
271
+ elif op == "ge":
272
+ dbFilter.filter(prop + " >=", value)
273
+ else:
274
+ dbFilter.filter(prop + " =", value)
275
+ return dbFilter
276
+
202
277
  return super().buildDBFilter(name, skel, dbFilter, updatedFilter, prefix)
203
278
 
204
279
  def getSearchTags(self, skel: "SkeletonInstance", name: str) -> set[str]:
@@ -240,9 +315,12 @@ class NumericBone(BaseBone):
240
315
  """
241
316
  super().refresh(skel, boneName)
242
317
 
243
- def refresh_single_value(value: t.Any) -> float | int:
318
+ def refresh_single_value(value: t.Any) -> float | int | deci.Decimal:
244
319
  if value == "":
245
320
  return self.getEmptyValue()
321
+ elif self.decimal:
322
+ if not isinstance(value, (deci.Decimal, type(None))):
323
+ return self._convert_to_decimal(value)
246
324
  elif not isinstance(value, (int, float, type(None))):
247
325
  return self._convert_to_numeric(value)
248
326
  return value
@@ -276,4 +354,6 @@ class NumericBone(BaseBone):
276
354
  "min": self.min,
277
355
  "max": self.max,
278
356
  "precision": self.precision,
357
+ "decimal": self.decimal,
358
+
279
359
  }
@@ -112,13 +112,13 @@ class RandomSliceBone(BaseBone):
112
112
  q = db.QueryDefinition(origKind, {}, [])
113
113
  property, value = applyFilterHook(dbFilter, f"{name} <=", rndVal)
114
114
  q.filters[property] = value
115
- q.orders = [(name, db.SortOrder.Descending)]
115
+ q.orders = [db.QueryOrder(name, db.SortOrder.Descending)]
116
116
  queries.append(q)
117
117
  # Left Side
118
118
  q = db.QueryDefinition(origKind, {}, [])
119
119
  property, value = applyFilterHook(dbFilter, f"{name} >", rndVal)
120
120
  q.filters[property] = value
121
- q.orders = [(name, db.SortOrder.Ascending)]
121
+ q.orders = [db.QueryOrder(name)]
122
122
  queries.append(q)
123
123
  dbFilter.queries = queries
124
124
  # Map the original filter back in