viur-core 3.9.0.dev5__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 (133) hide show
  1. {viur_core-3.9.0.dev5/src/viur_core.egg-info → viur_core-3.9.0.dev6}/PKG-INFO +8 -8
  2. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/pyproject.toml +7 -7
  3. {viur_core-3.9.0.dev5 → 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.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/numeric.py +102 -22
  6. viur_core-3.9.0.dev6/src/viur/core/data/zip_patterns.py +257 -0
  7. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/query.py +97 -52
  8. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/transport.py +36 -8
  9. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/types.py +5 -0
  10. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/utils.py +14 -1
  11. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/module.py +3 -1
  12. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/history.py +42 -13
  13. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/skelmodule.py +4 -1
  14. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/json/default.py +5 -1
  15. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/instance.py +11 -2
  16. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/tasks.py +3 -1
  17. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/version.py +1 -1
  18. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6/src/viur_core.egg-info}/PKG-INFO +8 -8
  19. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/SOURCES.txt +3 -0
  20. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/requires.txt +6 -7
  21. viur_core-3.9.0.dev6/tests/test_db.py +433 -0
  22. viur_core-3.9.0.dev6/tests/test_json_encoder.py +146 -0
  23. viur_core-3.9.0.dev5/tests/test_db.py +0 -81
  24. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/LICENSE +0 -0
  25. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/README.md +0 -0
  26. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/setup.cfg +0 -0
  27. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/__init__.py +0 -0
  28. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/base.py +0 -0
  29. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/boolean.py +0 -0
  30. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/captcha.py +0 -0
  31. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/code.py +0 -0
  32. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/color.py +0 -0
  33. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/credential.py +0 -0
  34. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/date.py +0 -0
  35. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/email.py +0 -0
  36. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/file.py +0 -0
  37. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/image.py +0 -0
  38. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/json.py +0 -0
  39. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/key.py +0 -0
  40. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/password.py +0 -0
  41. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/phone.py +0 -0
  42. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/randomslice.py +0 -0
  43. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/raw.py +0 -0
  44. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/record.py +0 -0
  45. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/relational.py +0 -0
  46. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/select.py +0 -0
  47. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/selectcountry.py +0 -0
  48. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/sortindex.py +0 -0
  49. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/spam.py +0 -0
  50. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/spatial.py +0 -0
  51. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/string.py +0 -0
  52. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/text.py +0 -0
  53. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/treeleaf.py +0 -0
  54. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/treenode.py +0 -0
  55. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/uid.py +0 -0
  56. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/uri.py +0 -0
  57. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/bones/user.py +0 -0
  58. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/cache.py +0 -0
  59. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/config.py +0 -0
  60. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/contrib/__init__.py +0 -0
  61. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/contrib/loginkey.py +0 -0
  62. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/contrib/ratelimit.py +0 -0
  63. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/current.py +0 -0
  64. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/__init__.py +0 -0
  65. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/cache.py +0 -0
  66. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/config.py +0 -0
  67. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/db/overrides.py +0 -0
  68. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/decorators.py +0 -0
  69. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/email.py +0 -0
  70. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/errors.py +0 -0
  71. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/i18n.py +0 -0
  72. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/languages/__init__.py +0 -0
  73. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/languages/de.py +0 -0
  74. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/languages/en.py +0 -0
  75. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/logging.py +0 -0
  76. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/__init__.py +0 -0
  77. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/email.py +0 -0
  78. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/file.py +0 -0
  79. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/formmailer.py +0 -0
  80. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/moduleconf.py +0 -0
  81. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/page.py +0 -0
  82. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/script.py +0 -0
  83. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/site.py +0 -0
  84. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/translation.py +0 -0
  85. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/modules/user.py +0 -0
  86. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/pagination.py +0 -0
  87. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/__init__.py +0 -0
  88. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/instanced_module.py +0 -0
  89. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/list.py +0 -0
  90. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/singleton.py +0 -0
  91. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/prototypes/tree.py +0 -0
  92. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/ratelimit.py +0 -0
  93. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/__init__.py +0 -0
  94. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/abstract.py +0 -0
  95. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/__init__.py +0 -0
  96. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/default.py +0 -0
  97. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/__init__.py +0 -0
  98. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/date.py +0 -0
  99. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/debug.py +0 -0
  100. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/regex.py +0 -0
  101. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/session.py +0 -0
  102. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/strings.py +0 -0
  103. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/tests.py +0 -0
  104. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/env/viur.py +0 -0
  105. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/html/utils.py +0 -0
  106. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/json/__init__.py +0 -0
  107. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/render/vi/__init__.py +0 -0
  108. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/request.py +0 -0
  109. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/scripts/viur_migrate.py +0 -0
  110. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/secret.py +0 -0
  111. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/securityheaders.py +0 -0
  112. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/securitykey.py +0 -0
  113. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/session.py +0 -0
  114. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/__init__.py +0 -0
  115. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/adapter.py +0 -0
  116. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/meta.py +0 -0
  117. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/relskel.py +0 -0
  118. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/skeleton.py +0 -0
  119. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/tasks.py +0 -0
  120. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/skeleton/utils.py +0 -0
  121. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/template/error.html +0 -0
  122. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/template/vi_user_google_login.html +0 -0
  123. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/utils/__init__.py +0 -0
  124. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/utils/json.py +0 -0
  125. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/utils/parse.py +0 -0
  126. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur/core/utils/string.py +0 -0
  127. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/dependency_links.txt +0 -0
  128. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/entry_points.txt +0 -0
  129. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/src/viur_core.egg-info/top_level.txt +0 -0
  130. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/tests/test_config.py +0 -0
  131. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/tests/test_decorators.py +0 -0
  132. {viur_core-3.9.0.dev5 → viur_core-3.9.0.dev6}/tests/test_errors.py +0 -0
  133. {viur_core-3.9.0.dev5 → 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.dev5
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
@@ -50,22 +51,21 @@ Requires-Dist: google-cloud-iam~=2.0
50
51
  Requires-Dist: google-cloud-logging~=3.0
51
52
  Requires-Dist: google-cloud-recaptcha-enterprise~=1.0
52
53
  Requires-Dist: google-cloud-secret-manager~=2.0
53
- Requires-Dist: google-cloud-storage~=2.0
54
+ Requires-Dist: google-cloud-storage<4,>=2
54
55
  Requires-Dist: google-cloud-tasks~=2.0
55
56
  Requires-Dist: google-resumable-media~=2.0
56
57
  Requires-Dist: googleapis-common-protos[grpc]~=1.0
57
- Requires-Dist: gunicorn~=23.0
58
+ Requires-Dist: gunicorn<27,>=23
58
59
  Requires-Dist: jinja2~=3.0
59
60
  Requires-Dist: jsonschema~=4.0
60
61
  Requires-Dist: logics-py==0.0.12
61
- Requires-Dist: pillow~=11.0
62
+ Requires-Dist: pillow<13,>=11
62
63
  Requires-Dist: pyotp~=2.0
63
- Requires-Dist: pytz~=2023.0
64
+ Requires-Dist: pytz>=2023
64
65
  Requires-Dist: pyyaml~=6.0
65
- Requires-Dist: qrcode~=7.0
66
+ Requires-Dist: qrcode<9,>=7
66
67
  Requires-Dist: requests~=2.0
67
68
  Requires-Dist: tzlocal~=5.0
68
- Requires-Dist: urllib3==1.26.20
69
69
  Requires-Dist: user-agents~=2.0
70
70
  Requires-Dist: webob~=1.0
71
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",
@@ -16,22 +16,21 @@ dependencies = [
16
16
  "google-cloud-logging~=3.0",
17
17
  "google-cloud-recaptcha-enterprise~=1.0",
18
18
  "google-cloud-secret-manager~=2.0",
19
- "google-cloud-storage~=2.0",
19
+ "google-cloud-storage>=2,<4",
20
20
  "google-cloud-tasks~=2.0",
21
21
  "google-resumable-media~=2.0",
22
22
  "googleapis-common-protos[grpc]~=1.0",
23
- "gunicorn~=23.0",
23
+ "gunicorn>=23,<27",
24
24
  "jinja2~=3.0",
25
25
  "jsonschema~=4.0",
26
26
  "logics-py==0.0.12",
27
- "pillow~=11.0",
27
+ "pillow>=11,<13",
28
28
  "pyotp~=2.0",
29
- "pytz~=2023.0",
29
+ "pytz>=2023",
30
30
  "pyyaml~=6.0",
31
- "qrcode~=7.0",
31
+ "qrcode>=7,<9",
32
32
  "requests~=2.0",
33
33
  "tzlocal~=5.0",
34
- "urllib3==1.26.20", # for appengine-python-standard (https://github.com/GoogleCloudPlatform/appengine-python-standard/blob/main/setup.py#L28)
35
34
  "user-agents~=2.0",
36
35
  "webob~=1.0",
37
36
  ]
@@ -53,6 +52,7 @@ classifiers = [
53
52
  "Operating System :: OS Independent",
54
53
  "Programming Language :: Python :: 3.12",
55
54
  "Programming Language :: Python :: 3.13",
55
+ "Programming Language :: Python :: 3.14",
56
56
  "Topic :: Software Development :: Libraries :: Application Frameworks",
57
57
  ]
58
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
@@ -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
  }