amsdal 0.3.3__cp311-cp311-macosx_10_9_universal2.whl → 0.5.29__cp311-cp311-macosx_10_9_universal2.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 (244) hide show
  1. amsdal/Third-Party Materials - AMSDAL Dependencies - License Notices.md +56 -2
  2. amsdal/__about__.py +1 -1
  3. amsdal/__init__.py +20 -0
  4. amsdal/__init__.pyi +9 -0
  5. amsdal/__migrations__/0000_initial.py +23 -190
  6. amsdal/__migrations__/0001_create_class_file.py +61 -0
  7. amsdal/__migrations__/0002_create_class_file.py +109 -0
  8. amsdal/__migrations__/0003_update_class_file.py +91 -0
  9. amsdal/__migrations__/0004_update_class_file.py +45 -0
  10. amsdal/cloud/__init__.cpython-311-darwin.so +0 -0
  11. amsdal/cloud/client.cpython-311-darwin.so +0 -0
  12. amsdal/cloud/constants.cpython-311-darwin.so +0 -0
  13. amsdal/cloud/enums.cpython-311-darwin.so +0 -0
  14. amsdal/cloud/models/__init__.cpython-311-darwin.so +0 -0
  15. amsdal/cloud/models/base.cpython-311-darwin.so +0 -0
  16. amsdal/cloud/services/__init__.cpython-311-darwin.so +0 -0
  17. amsdal/cloud/services/actions/__init__.cpython-311-darwin.so +0 -0
  18. amsdal/cloud/services/actions/add_allowlist_ip.cpython-311-darwin.so +0 -0
  19. amsdal/cloud/services/actions/add_basic_auth.cpython-311-darwin.so +0 -0
  20. amsdal/cloud/services/actions/add_dependency.cpython-311-darwin.so +0 -0
  21. amsdal/cloud/services/actions/add_secret.cpython-311-darwin.so +0 -0
  22. amsdal/cloud/services/actions/base.cpython-311-darwin.so +0 -0
  23. amsdal/cloud/services/actions/create_deploy.cpython-311-darwin.so +0 -0
  24. amsdal/cloud/services/actions/create_env.cpython-311-darwin.so +0 -0
  25. amsdal/cloud/services/actions/create_session.cpython-311-darwin.so +0 -0
  26. amsdal/cloud/services/actions/delete_allowlist_ip.cpython-311-darwin.so +0 -0
  27. amsdal/cloud/services/actions/delete_basic_auth.cpython-311-darwin.so +0 -0
  28. amsdal/cloud/services/actions/delete_dependency.cpython-311-darwin.so +0 -0
  29. amsdal/cloud/services/actions/delete_env.cpython-311-darwin.so +0 -0
  30. amsdal/cloud/services/actions/delete_secret.cpython-311-darwin.so +0 -0
  31. amsdal/cloud/services/actions/destroy_deploy.cpython-311-darwin.so +0 -0
  32. amsdal/cloud/services/actions/expose_db.cpython-311-darwin.so +0 -0
  33. amsdal/cloud/services/actions/get_basic_auth_credentials.cpython-311-darwin.so +0 -0
  34. amsdal/cloud/services/actions/get_monitoring_info.cpython-311-darwin.so +0 -0
  35. amsdal/cloud/services/actions/list_dependencies.cpython-311-darwin.so +0 -0
  36. amsdal/cloud/services/actions/list_deploys.cpython-311-darwin.so +0 -0
  37. amsdal/cloud/services/actions/list_envs.cpython-311-darwin.so +0 -0
  38. amsdal/cloud/services/actions/list_secrets.cpython-311-darwin.so +0 -0
  39. amsdal/cloud/services/actions/manager.cpython-311-darwin.so +0 -0
  40. amsdal/cloud/services/actions/signup_action.cpython-311-darwin.so +0 -0
  41. amsdal/cloud/services/actions/update_deploy.cpython-311-darwin.so +0 -0
  42. amsdal/cloud/services/auth/__init__.cpython-311-darwin.so +0 -0
  43. amsdal/cloud/services/auth/base.cpython-311-darwin.so +0 -0
  44. amsdal/cloud/services/auth/credentials.cpython-311-darwin.so +0 -0
  45. amsdal/cloud/services/auth/credentials.pyi +0 -1
  46. amsdal/cloud/services/auth/manager.cpython-311-darwin.so +0 -0
  47. amsdal/cloud/services/auth/signup_service.cpython-311-darwin.so +0 -0
  48. amsdal/cloud/services/auth/token.cpython-311-darwin.so +0 -0
  49. amsdal/cloud/services/auth/token.pyi +0 -1
  50. amsdal/configs/main.py +40 -20
  51. amsdal/configs/main.pyi +19 -18
  52. amsdal/contrib/__init__.cpython-311-darwin.so +0 -0
  53. amsdal/contrib/auth/errors.py +36 -0
  54. amsdal/contrib/auth/errors.pyi +12 -0
  55. amsdal/contrib/auth/fixtures/basic_permissions.json +64 -0
  56. amsdal/contrib/auth/lifecycle/consumer.py +13 -13
  57. amsdal/contrib/auth/lifecycle/consumer.pyi +3 -0
  58. amsdal/contrib/auth/migrations/0000_initial.py +69 -31
  59. amsdal/contrib/auth/migrations/0001_add_mfa_support.py +188 -0
  60. amsdal/contrib/auth/models/__init__.py +1 -0
  61. amsdal/contrib/auth/models/backup_code.py +85 -0
  62. amsdal/contrib/auth/models/email_mfa_device.py +108 -0
  63. amsdal/contrib/auth/models/login_session.py +235 -0
  64. amsdal/contrib/auth/models/mfa_device.py +86 -0
  65. amsdal/contrib/auth/models/permission.py +23 -0
  66. amsdal/contrib/auth/models/sms_device.py +113 -0
  67. amsdal/contrib/auth/models/totp_device.py +58 -0
  68. amsdal/contrib/auth/models/user.py +156 -0
  69. amsdal/contrib/auth/services/__init__.py +1 -0
  70. amsdal/contrib/auth/services/mfa_device_service.py +544 -0
  71. amsdal/contrib/auth/services/mfa_device_service.pyi +216 -0
  72. amsdal/contrib/auth/services/totp_service.py +358 -0
  73. amsdal/contrib/auth/services/totp_service.pyi +158 -0
  74. amsdal/contrib/auth/settings.py +8 -0
  75. amsdal/contrib/auth/settings.pyi +8 -0
  76. amsdal/contrib/auth/transactions/__init__.py +1 -0
  77. amsdal/contrib/auth/transactions/mfa_device_transactions.py +463 -0
  78. amsdal/contrib/auth/transactions/mfa_device_transactions.pyi +226 -0
  79. amsdal/contrib/auth/transactions/totp_transactions.py +206 -0
  80. amsdal/contrib/auth/transactions/totp_transactions.pyi +113 -0
  81. amsdal/contrib/auth/utils/__init__.py +0 -0
  82. amsdal/contrib/auth/utils/__init__.pyi +0 -0
  83. amsdal/contrib/auth/utils/mfa.py +257 -0
  84. amsdal/contrib/auth/utils/mfa.pyi +119 -0
  85. amsdal/contrib/frontend_configs/conversion/convert.py +85 -25
  86. amsdal/contrib/frontend_configs/conversion/convert.pyi +0 -1
  87. amsdal/contrib/frontend_configs/lifecycle/consumer.py +32 -13
  88. amsdal/contrib/frontend_configs/lifecycle/consumer.pyi +1 -1
  89. amsdal/contrib/frontend_configs/migrations/0000_initial.py +167 -195
  90. amsdal/contrib/frontend_configs/migrations/0001_update_frontend_control_config.py +245 -0
  91. amsdal/contrib/frontend_configs/migrations/0002_add_button_and_invoke_actions.py +352 -0
  92. amsdal/contrib/frontend_configs/migrations/0003_create_class_frontendconfigdashboardelement.py +145 -0
  93. amsdal/contrib/frontend_configs/models/__init__.py +0 -0
  94. amsdal/contrib/frontend_configs/models/frontend_activator_config.py +22 -0
  95. amsdal/contrib/frontend_configs/models/frontend_config_async_validator.py +11 -0
  96. amsdal/contrib/frontend_configs/models/frontend_config_control_action.py +110 -0
  97. amsdal/contrib/frontend_configs/models/frontend_config_dashboard.py +51 -0
  98. amsdal/contrib/frontend_configs/models/frontend_config_group_validator.py +21 -0
  99. amsdal/contrib/frontend_configs/models/frontend_config_option.py +12 -0
  100. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base.py +17 -0
  101. amsdal/contrib/frontend_configs/models/frontend_config_slider_option.py +13 -0
  102. amsdal/contrib/frontend_configs/models/frontend_config_text_mask.py +14 -0
  103. amsdal/contrib/frontend_configs/models/frontend_config_validator.py +28 -0
  104. amsdal/contrib/frontend_configs/models/frontend_control_config.py +110 -0
  105. amsdal/contrib/frontend_configs/models/frontend_model_config.py +14 -0
  106. amsdal/errors.py +0 -3
  107. amsdal/errors.pyi +0 -1
  108. amsdal/fixtures/__init__.cpython-311-darwin.so +0 -0
  109. amsdal/fixtures/manager.cpython-311-darwin.so +0 -0
  110. amsdal/fixtures/manager.pyi +73 -123
  111. amsdal/fixtures/utils.cpython-311-darwin.so +0 -0
  112. amsdal/fixtures/utils.pyi +9 -0
  113. amsdal/manager.cpython-311-darwin.so +0 -0
  114. amsdal/manager.pyi +9 -96
  115. amsdal/mixins/__init__.cpython-311-darwin.so +0 -0
  116. amsdal/mixins/class_versions_mixin.cpython-311-darwin.so +0 -0
  117. amsdal/models/__init__.py +19 -0
  118. amsdal/models/core/__init__.py +0 -0
  119. amsdal/models/core/class_object.py +38 -0
  120. amsdal/models/core/class_property.py +26 -0
  121. amsdal/models/core/file.py +243 -0
  122. amsdal/models/core/fixture.py +25 -0
  123. amsdal/models/core/option.py +11 -0
  124. amsdal/models/core/storage_metadata.py +15 -0
  125. amsdal/models/core/validator.py +12 -0
  126. amsdal/models/mixins.py +31 -0
  127. amsdal/models/types/__init__.py +0 -0
  128. amsdal/models/types/object.py +26 -0
  129. amsdal/queryset/__init__.py +21 -0
  130. amsdal/queryset/__init__.pyi +6 -0
  131. amsdal/schemas/core/class_object/model.json +20 -0
  132. amsdal/schemas/core/class_property/model.json +19 -0
  133. amsdal/schemas/core/file/properties/from_file.py +1 -1
  134. amsdal/schemas/core/file/properties/validate_data.py +3 -4
  135. amsdal/schemas/core/storage_metadata/model.json +52 -0
  136. amsdal/schemas/interfaces.py +25 -0
  137. amsdal/schemas/interfaces.pyi +20 -0
  138. amsdal/schemas/manager.cpython-311-darwin.so +0 -0
  139. amsdal/schemas/manager.py +0 -116
  140. amsdal/schemas/manager.pyi +0 -65
  141. amsdal/schemas/mixins/__init__.py +0 -0
  142. amsdal/schemas/mixins/__init__.pyi +0 -0
  143. amsdal/schemas/mixins/check_dependencies_mixin.py +130 -0
  144. amsdal/schemas/mixins/check_dependencies_mixin.pyi +45 -0
  145. amsdal/schemas/mixins/verify_schemas_mixin.py +96 -0
  146. amsdal/schemas/mixins/verify_schemas_mixin.pyi +33 -0
  147. amsdal/schemas/repository.py +84 -0
  148. amsdal/schemas/repository.pyi +22 -0
  149. amsdal/schemas/utils.py +16 -0
  150. amsdal/schemas/utils.pyi +10 -0
  151. amsdal/services/__init__.py +11 -0
  152. amsdal/services/__init__.pyi +4 -0
  153. amsdal/services/external_connections.py +262 -0
  154. amsdal/services/external_connections.pyi +190 -0
  155. amsdal/services/external_model_generator.py +350 -0
  156. amsdal/services/external_model_generator.pyi +134 -0
  157. amsdal/services/transaction_execution.cpython-311-darwin.so +0 -0
  158. amsdal/services/transaction_execution.pyi +2 -1
  159. amsdal/storages/__init__.py +20 -0
  160. amsdal/storages/__init__.pyi +8 -0
  161. amsdal/storages/file_system.py +214 -0
  162. amsdal/storages/file_system.pyi +36 -0
  163. amsdal/transactions/__init__.py +13 -0
  164. amsdal/transactions/__init__.pyi +4 -0
  165. amsdal/utils/rollback/__init__.py +99 -54
  166. amsdal/utils/rollback/__init__.pyi +6 -0
  167. amsdal/utils/tests/enums.py +0 -2
  168. amsdal/utils/tests/helpers.py +253 -231
  169. amsdal/utils/tests/migrations.py +157 -0
  170. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/METADATA +17 -10
  171. amsdal-0.5.29.dist-info/RECORD +276 -0
  172. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/WHEEL +1 -1
  173. amsdal/__migrations__/0001_datetime_type.py +0 -18
  174. amsdal/__migrations__/0002_fixture_order.py +0 -40
  175. amsdal/__migrations__/0003_schema_type_in_class_meta.py +0 -56
  176. amsdal/contrib/auth/models/login_session/hooks/pre_init.py +0 -68
  177. amsdal/contrib/auth/models/login_session/model.json +0 -23
  178. amsdal/contrib/auth/models/login_session/modifiers/display_name.py +0 -11
  179. amsdal/contrib/auth/models/permission/fixtures/basic_permissions.json +0 -62
  180. amsdal/contrib/auth/models/permission/model.json +0 -18
  181. amsdal/contrib/auth/models/permission/modifiers/display_name.py +0 -11
  182. amsdal/contrib/auth/models/user/hooks/post_init.py +0 -76
  183. amsdal/contrib/auth/models/user/hooks/pre_create.py +0 -8
  184. amsdal/contrib/auth/models/user/model.json +0 -25
  185. amsdal/contrib/auth/models/user/modifiers/display_name.py +0 -19
  186. amsdal/contrib/frontend_configs/models/frontend_activator_config/model.json +0 -11
  187. amsdal/contrib/frontend_configs/models/frontend_config_async_validator/model.json +0 -11
  188. amsdal/contrib/frontend_configs/models/frontend_config_group_validator/model.json +0 -52
  189. amsdal/contrib/frontend_configs/models/frontend_config_option/model.json +0 -15
  190. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/model.json +0 -6
  191. amsdal/contrib/frontend_configs/models/frontend_config_skip_none_base/properties/model_dump.py +0 -13
  192. amsdal/contrib/frontend_configs/models/frontend_config_slider_option/model.json +0 -19
  193. amsdal/contrib/frontend_configs/models/frontend_config_text_mask/model.json +0 -26
  194. amsdal/contrib/frontend_configs/models/frontend_config_validator/model.json +0 -41
  195. amsdal/contrib/frontend_configs/models/frontend_control_config/model.json +0 -250
  196. amsdal/contrib/frontend_configs/models/frontend_model_config/fixtures/permissions.json +0 -24
  197. amsdal/contrib/frontend_configs/models/frontend_model_config/model.json +0 -17
  198. amsdal/contrib/frontend_configs/models/frontent_config_control_action/model.json +0 -54
  199. amsdal/contrib/frontend_configs/models/frontent_config_control_action/properties/action_validate.py +0 -33
  200. amsdal/migration/__init__.cpython-311-darwin.so +0 -0
  201. amsdal/migration/base_migration_schemas.cpython-311-darwin.so +0 -0
  202. amsdal/migration/base_migration_schemas.pyi +0 -120
  203. amsdal/migration/data_classes.cpython-311-darwin.so +0 -0
  204. amsdal/migration/data_classes.pyi +0 -172
  205. amsdal/migration/executors/__init__.cpython-311-darwin.so +0 -0
  206. amsdal/migration/executors/base.cpython-311-darwin.so +0 -0
  207. amsdal/migration/executors/base.pyi +0 -118
  208. amsdal/migration/executors/default_executor.cpython-311-darwin.so +0 -0
  209. amsdal/migration/executors/default_executor.pyi +0 -184
  210. amsdal/migration/executors/state_executor.cpython-311-darwin.so +0 -0
  211. amsdal/migration/executors/state_executor.pyi +0 -78
  212. amsdal/migration/file_migration_executor.cpython-311-darwin.so +0 -0
  213. amsdal/migration/file_migration_executor.pyi +0 -68
  214. amsdal/migration/file_migration_generator.cpython-311-darwin.so +0 -0
  215. amsdal/migration/file_migration_generator.pyi +0 -139
  216. amsdal/migration/file_migration_store.cpython-311-darwin.so +0 -0
  217. amsdal/migration/file_migration_store.pyi +0 -61
  218. amsdal/migration/file_migration_writer.cpython-311-darwin.so +0 -0
  219. amsdal/migration/file_migration_writer.pyi +0 -73
  220. amsdal/migration/migrations.cpython-311-darwin.so +0 -0
  221. amsdal/migration/migrations.pyi +0 -166
  222. amsdal/migration/migrations_loader.cpython-311-darwin.so +0 -0
  223. amsdal/migration/migrations_loader.pyi +0 -32
  224. amsdal/migration/schemas_loaders.cpython-311-darwin.so +0 -0
  225. amsdal/migration/schemas_loaders.pyi +0 -37
  226. amsdal/migration/templates/data_migration.tmpl +0 -18
  227. amsdal/migration/templates/dict_validator.tmpl +0 -4
  228. amsdal/migration/templates/migration.tmpl +0 -6
  229. amsdal/migration/templates/model_class.tmpl +0 -8
  230. amsdal/migration/templates/model_class_layout.tmpl +0 -24
  231. amsdal/migration/templates/options_validator.tmpl +0 -4
  232. amsdal/migration/utils.cpython-311-darwin.so +0 -0
  233. amsdal/migration/utils.pyi +0 -58
  234. amsdal/mixins/build_mixin.cpython-311-darwin.so +0 -0
  235. amsdal/mixins/build_mixin.pyi +0 -78
  236. amsdal/schemas/core/class_object_meta/model.json +0 -59
  237. amsdal/schemas/core/class_property_meta/model.json +0 -23
  238. amsdal/services/__init__.cpython-311-darwin.so +0 -0
  239. amsdal-0.3.3.dist-info/RECORD +0 -257
  240. amsdal-0.3.3.dist-info/licenses/LICENSE.txt +0 -107
  241. /amsdal/{migration → contrib/auth/services}/__init__.pyi +0 -0
  242. /amsdal/{migration/executors → contrib/auth/transactions}/__init__.pyi +0 -0
  243. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info/licenses}/LICENSE.txt +0 -0
  244. {amsdal-0.3.3.dist-info → amsdal-0.5.29.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,257 @@
1
+ """
2
+ Utilities for Multi-Factor Authentication (MFA).
3
+
4
+ This module provides helper functions for generating and verifying MFA codes
5
+ for different authentication methods (TOTP, backup codes, email codes).
6
+ """
7
+
8
+ import secrets
9
+ import string
10
+ import typing as t
11
+ from datetime import UTC
12
+ from datetime import datetime
13
+ from datetime import timedelta
14
+ from enum import StrEnum
15
+
16
+ import pyotp
17
+ from amsdal_utils.models.enums import Versions
18
+
19
+ from amsdal.contrib.auth.settings import auth_settings
20
+
21
+ if t.TYPE_CHECKING:
22
+ from amsdal.contrib.auth.models.mfa_device import MFADevice
23
+ from amsdal.contrib.auth.models.user import User
24
+
25
+
26
+ class DeviceType(StrEnum):
27
+ TOTP = 'totp'
28
+ BACKUP_CODE = 'backup_code'
29
+ EMAIL = 'email'
30
+ SMS = 'sms'
31
+
32
+
33
+ def get_active_user_devices(user: 'User') -> dict[DeviceType, list['MFADevice']]:
34
+ from amsdal.contrib.auth.models.backup_code import BackupCode
35
+ from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice
36
+ from amsdal.contrib.auth.models.sms_device import SMSDevice
37
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
38
+
39
+ _result: dict[DeviceType, list[MFADevice]] = {}
40
+ for device_class, device_type in [
41
+ (TOTPDevice, DeviceType.TOTP),
42
+ (BackupCode, DeviceType.BACKUP_CODE),
43
+ (EmailMFADevice, DeviceType.EMAIL),
44
+ (SMSDevice, DeviceType.SMS),
45
+ ]:
46
+ devices = device_class.objects.filter( # type: ignore[attr-defined]
47
+ user_email=user.email,
48
+ is_active=True,
49
+ confirmed=True,
50
+ _address__object_version=Versions.LATEST,
51
+ ).execute()
52
+ _result[device_type] = devices
53
+
54
+ return _result
55
+
56
+
57
+ async def aget_active_user_devices(user: 'User') -> dict[DeviceType, list['MFADevice']]:
58
+ from amsdal.contrib.auth.models.backup_code import BackupCode
59
+ from amsdal.contrib.auth.models.email_mfa_device import EmailMFADevice
60
+ from amsdal.contrib.auth.models.sms_device import SMSDevice
61
+ from amsdal.contrib.auth.models.totp_device import TOTPDevice
62
+
63
+ _result: dict[DeviceType, list[MFADevice]] = {}
64
+ for device_class, device_type in [
65
+ (TOTPDevice, DeviceType.TOTP),
66
+ (BackupCode, DeviceType.BACKUP_CODE),
67
+ (EmailMFADevice, DeviceType.EMAIL),
68
+ (SMSDevice, DeviceType.SMS),
69
+ ]:
70
+ devices = await device_class.objects.filter( # type: ignore[attr-defined]
71
+ user_email=user.email,
72
+ is_active=True,
73
+ confirmed=True,
74
+ _address__object_version=Versions.LATEST,
75
+ ).aexecute()
76
+ _result[device_type] = devices
77
+
78
+ return _result
79
+
80
+
81
+ def generate_totp_secret() -> str:
82
+ """
83
+ Generate a new TOTP secret key.
84
+
85
+ Returns:
86
+ str: A base32-encoded random secret key.
87
+ """
88
+ return pyotp.random_base32()
89
+
90
+
91
+ def generate_qr_code_url(secret: str, email: str, issuer: str | None = None) -> str:
92
+ """
93
+ Generate a QR code URL for TOTP device setup.
94
+
95
+ This creates an otpauth:// URL that can be scanned by authenticator apps
96
+ like Google Authenticator, Authy, etc.
97
+
98
+ Args:
99
+ secret (str): The TOTP secret key.
100
+ email (str): The user's email address.
101
+ issuer (str | None): The issuer name to display in the app. If None, uses the
102
+ MFA_TOTP_ISSUER setting.
103
+
104
+ Returns:
105
+ str: The otpauth:// URL for QR code generation.
106
+ """
107
+ if issuer is None:
108
+ issuer = auth_settings.MFA_TOTP_ISSUER
109
+
110
+ # Create TOTP object
111
+ totp = pyotp.TOTP(secret)
112
+
113
+ # Generate provisioning URI
114
+ uri = totp.provisioning_uri(
115
+ name=email,
116
+ issuer_name=issuer,
117
+ )
118
+
119
+ return uri
120
+
121
+
122
+ def verify_totp_code(
123
+ secret: str,
124
+ code: str,
125
+ digits: int = 6,
126
+ step: int = 30,
127
+ valid_window: int = 1,
128
+ ) -> bool:
129
+ """
130
+ Verify a TOTP code against a secret.
131
+
132
+ This function validates the provided code against the TOTP secret, allowing
133
+ for a time window to account for clock drift between the server and the device.
134
+
135
+ Args:
136
+ secret (str): The TOTP secret key.
137
+ code (str): The code to verify.
138
+ digits (int): Number of digits in the code (default: 6).
139
+ step (int): Time step in seconds (default: 30).
140
+ valid_window (int): Number of time steps to check before and after the current time
141
+ (default: 1, which means ±30 seconds with default step).
142
+
143
+ Returns:
144
+ bool: True if the code is valid, False otherwise.
145
+ """
146
+ try:
147
+ totp = pyotp.TOTP(secret, digits=digits, interval=step)
148
+ return totp.verify(code, valid_window=valid_window)
149
+ except Exception:
150
+ return False
151
+
152
+
153
+ def generate_backup_codes(count: int | None = None) -> list[str]:
154
+ """
155
+ Generate a set of backup recovery codes.
156
+
157
+ Each code is a random alphanumeric string that can be used once for authentication
158
+ when the primary MFA device is unavailable.
159
+
160
+ Args:
161
+ count (int | None): Number of codes to generate. If None, uses the
162
+ MFA_BACKUP_CODES_COUNT setting.
163
+
164
+ Returns:
165
+ list[str]: List of generated backup codes.
166
+ """
167
+ if count is None:
168
+ count = auth_settings.MFA_BACKUP_CODES_COUNT
169
+
170
+ codes = []
171
+ for _ in range(count):
172
+ # Generate 8-character alphanumeric code (without ambiguous characters)
173
+ alphabet = string.ascii_uppercase + string.digits
174
+ alphabet = alphabet.replace('O', '').replace('0', '').replace('I', '').replace('1', '')
175
+ code = ''.join(secrets.choice(alphabet) for _ in range(8))
176
+ # Format as XXXX-XXXX for readability
177
+ formatted_code = f'{code[:4]}-{code[4:]}'
178
+ codes.append(formatted_code)
179
+
180
+ return codes
181
+
182
+
183
+ def hash_backup_code(code: str) -> bytes:
184
+ """
185
+ Hash a backup code for secure storage.
186
+
187
+ Uses bcrypt for hashing, similar to password hashing.
188
+
189
+ Args:
190
+ code (str): The backup code to hash.
191
+
192
+ Returns:
193
+ bytes: The hashed code.
194
+ """
195
+ import bcrypt
196
+
197
+ # Remove formatting (dashes) before hashing
198
+ clean_code = code.replace('-', '')
199
+ return bcrypt.hashpw(clean_code.encode('utf-8'), bcrypt.gensalt())
200
+
201
+
202
+ def verify_backup_code(hashed_code: bytes, code: str) -> bool:
203
+ """
204
+ Verify a backup code against its hash.
205
+
206
+ Args:
207
+ hashed_code (bytes): The stored hashed code.
208
+ code (str): The code to verify.
209
+
210
+ Returns:
211
+ bool: True if the code matches, False otherwise.
212
+ """
213
+ import bcrypt
214
+
215
+ try:
216
+ # Remove formatting (dashes) before verification
217
+ clean_code = code.replace('-', '')
218
+ return bcrypt.checkpw(clean_code.encode('utf-8'), hashed_code)
219
+ except Exception:
220
+ return False
221
+
222
+
223
+ def generate_email_mfa_code(length: int = 6) -> str:
224
+ """
225
+ Generate a random numeric code for email-based MFA.
226
+
227
+ Args:
228
+ length (int): Length of the code (default: 6).
229
+
230
+ Returns:
231
+ str: A random numeric code.
232
+ """
233
+ return ''.join(secrets.choice(string.digits) for _ in range(length))
234
+
235
+
236
+ def get_email_code_expiration() -> datetime:
237
+ """
238
+ Calculate the expiration time for an email MFA code.
239
+
240
+ Returns:
241
+ datetime: The expiration timestamp.
242
+ """
243
+ expiration_seconds = auth_settings.MFA_EMAIL_CODE_EXPIRATION
244
+ return datetime.now(tz=UTC) + timedelta(seconds=expiration_seconds)
245
+
246
+
247
+ def is_email_code_valid(code_expires_at: datetime) -> bool:
248
+ """
249
+ Check if an email MFA code is still valid (not expired).
250
+
251
+ Args:
252
+ code_expires_at (datetime): The expiration timestamp of the code.
253
+
254
+ Returns:
255
+ bool: True if the code is still valid, False if expired.
256
+ """
257
+ return datetime.now(tz=UTC) < code_expires_at
@@ -0,0 +1,119 @@
1
+ from amsdal.contrib.auth.models.mfa_device import MFADevice as MFADevice
2
+ from amsdal.contrib.auth.models.user import User as User
3
+ from amsdal.contrib.auth.settings import auth_settings as auth_settings
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+
7
+ class DeviceType(StrEnum):
8
+ TOTP = 'totp'
9
+ BACKUP_CODE = 'backup_code'
10
+ EMAIL = 'email'
11
+ SMS = 'sms'
12
+
13
+ def get_active_user_devices(user: User) -> dict[DeviceType, list['MFADevice']]: ...
14
+ async def aget_active_user_devices(user: User) -> dict[DeviceType, list['MFADevice']]: ...
15
+ def generate_totp_secret() -> str:
16
+ """
17
+ Generate a new TOTP secret key.
18
+
19
+ Returns:
20
+ str: A base32-encoded random secret key.
21
+ """
22
+ def generate_qr_code_url(secret: str, email: str, issuer: str | None = None) -> str:
23
+ """
24
+ Generate a QR code URL for TOTP device setup.
25
+
26
+ This creates an otpauth:// URL that can be scanned by authenticator apps
27
+ like Google Authenticator, Authy, etc.
28
+
29
+ Args:
30
+ secret (str): The TOTP secret key.
31
+ email (str): The user's email address.
32
+ issuer (str | None): The issuer name to display in the app. If None, uses the
33
+ MFA_TOTP_ISSUER setting.
34
+
35
+ Returns:
36
+ str: The otpauth:// URL for QR code generation.
37
+ """
38
+ def verify_totp_code(secret: str, code: str, digits: int = 6, step: int = 30, valid_window: int = 1) -> bool:
39
+ """
40
+ Verify a TOTP code against a secret.
41
+
42
+ This function validates the provided code against the TOTP secret, allowing
43
+ for a time window to account for clock drift between the server and the device.
44
+
45
+ Args:
46
+ secret (str): The TOTP secret key.
47
+ code (str): The code to verify.
48
+ digits (int): Number of digits in the code (default: 6).
49
+ step (int): Time step in seconds (default: 30).
50
+ valid_window (int): Number of time steps to check before and after the current time
51
+ (default: 1, which means ±30 seconds with default step).
52
+
53
+ Returns:
54
+ bool: True if the code is valid, False otherwise.
55
+ """
56
+ def generate_backup_codes(count: int | None = None) -> list[str]:
57
+ """
58
+ Generate a set of backup recovery codes.
59
+
60
+ Each code is a random alphanumeric string that can be used once for authentication
61
+ when the primary MFA device is unavailable.
62
+
63
+ Args:
64
+ count (int | None): Number of codes to generate. If None, uses the
65
+ MFA_BACKUP_CODES_COUNT setting.
66
+
67
+ Returns:
68
+ list[str]: List of generated backup codes.
69
+ """
70
+ def hash_backup_code(code: str) -> bytes:
71
+ """
72
+ Hash a backup code for secure storage.
73
+
74
+ Uses bcrypt for hashing, similar to password hashing.
75
+
76
+ Args:
77
+ code (str): The backup code to hash.
78
+
79
+ Returns:
80
+ bytes: The hashed code.
81
+ """
82
+ def verify_backup_code(hashed_code: bytes, code: str) -> bool:
83
+ """
84
+ Verify a backup code against its hash.
85
+
86
+ Args:
87
+ hashed_code (bytes): The stored hashed code.
88
+ code (str): The code to verify.
89
+
90
+ Returns:
91
+ bool: True if the code matches, False otherwise.
92
+ """
93
+ def generate_email_mfa_code(length: int = 6) -> str:
94
+ """
95
+ Generate a random numeric code for email-based MFA.
96
+
97
+ Args:
98
+ length (int): Length of the code (default: 6).
99
+
100
+ Returns:
101
+ str: A random numeric code.
102
+ """
103
+ def get_email_code_expiration() -> datetime:
104
+ """
105
+ Calculate the expiration time for an email MFA code.
106
+
107
+ Returns:
108
+ datetime: The expiration timestamp.
109
+ """
110
+ def is_email_code_valid(code_expires_at: datetime) -> bool:
111
+ """
112
+ Check if an email MFA code is still valid (not expired).
113
+
114
+ Args:
115
+ code_expires_at (datetime): The expiration timestamp of the code.
116
+
117
+ Returns:
118
+ bool: True if the code is still valid, False if expired.
119
+ """
@@ -1,3 +1,4 @@
1
+ from contextlib import suppress
1
2
  from datetime import date
2
3
  from datetime import datetime
3
4
  from enum import Enum
@@ -9,16 +10,19 @@ from typing import Any
9
10
  from typing import ClassVar
10
11
  from typing import ForwardRef
11
12
  from typing import Union
13
+ from typing import get_args
14
+ from typing import get_origin
12
15
 
13
- from amsdal_models.classes.manager import ClassManager
16
+ from amsdal_models.classes.class_manager import ClassManager
14
17
  from amsdal_models.classes.model import LegacyModel
15
18
  from amsdal_models.classes.model import Model
19
+ from amsdal_models.classes.model import TypeModel
20
+ from amsdal_models.classes.relationships.constants import MANY_TO_MANY_FIELDS
21
+ from amsdal_models.schemas.object_schema import model_to_object_schema
16
22
  from amsdal_utils.models.data_models.reference import Reference
17
23
  from pydantic import BaseModel
18
24
  from pydantic_core import PydanticUndefined
19
25
 
20
- from amsdal.schemas.manager import SchemaManager
21
-
22
26
  default_types_map = {
23
27
  int: 'number',
24
28
  float: 'number',
@@ -32,11 +36,19 @@ default_types_map = {
32
36
 
33
37
  def _process_union(value: UnionType, *, is_transaction: bool = False) -> dict[str, Any]:
34
38
  arg_type = {'required': True}
35
- for arg in value.__args__:
39
+
40
+ for arg in get_args(value):
36
41
  if arg is type(None):
37
42
  arg_type['required'] = False
38
43
  continue
39
44
 
45
+ if not is_transaction:
46
+ with suppress(TypeError):
47
+ if issubclass(arg, Model):
48
+ arg_type['type'] = 'object_latest' # type: ignore[assignment]
49
+ arg_type['entityType'] = arg.__name__
50
+ continue
51
+
40
52
  control = convert_to_frontend_config(arg, is_transaction=is_transaction)
41
53
  if control:
42
54
  arg_type.update(control)
@@ -59,9 +71,10 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
59
71
  Returns:
60
72
  dict[str, Any]: A dictionary representing the frontend configuration for the given value.
61
73
  """
62
- if hasattr(value, '__origin__'):
63
- origin_class = value.__origin__
74
+ schema = None
75
+ origin_class = get_origin(value)
64
76
 
77
+ if origin_class:
65
78
  if origin_class in [ClassVar]:
66
79
  return {}
67
80
 
@@ -100,7 +113,7 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
100
113
 
101
114
  if isinstance(value, ForwardRef):
102
115
  class_name = value.__forward_arg__
103
- _class = ClassManager().import_class(class_name, ClassManager().resolve_schema_type(class_name))
116
+ _class = ClassManager().import_class(class_name)
104
117
 
105
118
  if issubclass(_class, Model):
106
119
  return {
@@ -127,6 +140,19 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
127
140
  return {
128
141
  'type': 'text',
129
142
  }
143
+ if value.__class__.__name__ == '_LiteralGenericAlias' and hasattr(value, '__origin__'):
144
+ options = get_args(value)
145
+ return {
146
+ 'type': 'select',
147
+ 'options': [{'label': str(option), 'value': option} for option in options],
148
+ }
149
+
150
+ if value.__class__.__name__ == '_AnnotatedAlias' and hasattr(value, '__origin__'):
151
+ # Handle Annotated types
152
+ options = get_args(value)
153
+ if options:
154
+ val = convert_to_frontend_config(options[0], is_transaction=is_transaction)
155
+ return val
130
156
 
131
157
  if isinstance(value, FunctionType):
132
158
  function_controls = []
@@ -163,7 +189,8 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
163
189
  control['value'] = _param.default
164
190
  control['required'] = False
165
191
 
166
- function_controls.append(control)
192
+ if not control['name'].startswith('_'):
193
+ function_controls.append(control)
167
194
 
168
195
  return {
169
196
  'type': 'group',
@@ -172,10 +199,13 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
172
199
  'controls': function_controls,
173
200
  }
174
201
 
175
- if issubclass(value, Reference):
176
- return {
177
- 'type': 'object_latest',
178
- }
202
+ try:
203
+ if issubclass(value, Reference):
204
+ return {
205
+ 'type': 'object_latest',
206
+ }
207
+ except TypeError:
208
+ return {}
179
209
 
180
210
  if is_transaction and issubclass(value, Model):
181
211
  if value.__name__ == 'File':
@@ -190,27 +220,38 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
190
220
  if issubclass(value, LegacyModel):
191
221
  return {}
192
222
 
223
+ is_timestamp_mixin = False
193
224
  if issubclass(value, BaseModel):
194
225
  model_controls = []
195
226
 
196
227
  try:
197
- schema = SchemaManager().get_schema_by_name(value.__name__)
228
+ if issubclass(value, Model | TypeModel):
229
+ schema = model_to_object_schema(value)
230
+
231
+ _mro = value.mro()
232
+
233
+ # remove updated_at and created_at fields
234
+ if any(cls.__name__ == 'TimestampMixin' for cls in _mro):
235
+ is_timestamp_mixin = True
236
+
198
237
  except FileNotFoundError:
199
238
  schema = None
200
239
 
201
- for field_name, field in value.__annotations__.items():
202
- control = convert_to_frontend_config(field, is_transaction=is_transaction)
240
+ if value.__name__ == 'File':
241
+ return {
242
+ 'type': 'file',
243
+ }
244
+
245
+ for field_name, field in value.model_fields.items():
246
+ control = convert_to_frontend_config(field.annotation, is_transaction=is_transaction)
203
247
 
204
248
  if not control:
205
249
  continue
206
250
 
207
251
  control.setdefault('required', True)
208
252
 
209
- if field_name in value.model_fields:
210
- _field = value.model_fields[field_name]
211
-
212
- if _field.default is not PydanticUndefined:
213
- control['value'] = _field.default
253
+ if field.default is not PydanticUndefined:
254
+ control['value'] = field.default
214
255
 
215
256
  control['name'] = field_name
216
257
  control['label'] = field_name
@@ -232,8 +273,24 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
232
273
  if schema_property.title:
233
274
  control['label'] = schema_property.title
234
275
 
276
+ if not control['name'].startswith('_'):
277
+ model_controls.append(control)
278
+
279
+ for m2m, (m2m_ref, _, _, field_info) in (getattr(value, MANY_TO_MANY_FIELDS, None) or {}).items():
280
+ pass
281
+ control = convert_to_frontend_config(list[Reference | m2m_ref], is_transaction=is_transaction) # type: ignore[valid-type]
282
+
283
+ if getattr(field_info, 'default', PydanticUndefined) is not PydanticUndefined:
284
+ control['value'] = field_info.default
285
+
286
+ control['name'] = m2m
287
+ control['label'] = m2m
288
+ control['required'] = False
235
289
  model_controls.append(control)
236
290
 
291
+ if is_timestamp_mixin:
292
+ model_controls = [c for c in model_controls if c['name'] not in ('created_at', 'updated_at')]
293
+
237
294
  return {
238
295
  'type': 'group',
239
296
  'name': value.__name__,
@@ -241,10 +298,13 @@ def convert_to_frontend_config(value: Any, *, is_transaction: bool = False) -> d
241
298
  'controls': model_controls,
242
299
  }
243
300
 
244
- if issubclass(value, Enum):
245
- return {
246
- 'type': 'select',
247
- 'options': [{'label': option.name, 'value': option.value} for option in value],
248
- }
301
+ try:
302
+ if issubclass(value, Enum):
303
+ return {
304
+ 'type': 'select',
305
+ 'options': [{'label': option.name, 'value': option.value} for option in value],
306
+ }
307
+ except TypeError:
308
+ pass
249
309
 
250
310
  return {}
@@ -1,5 +1,4 @@
1
1
  from _typeshed import Incomplete
2
- from amsdal.schemas.manager import SchemaManager as SchemaManager
3
2
  from types import UnionType
4
3
  from typing import Any
5
4
 
@@ -1,3 +1,4 @@
1
+ # mypy: disable-error-code="arg-type"
1
2
  import contextlib
2
3
  import logging
3
4
  from typing import Any
@@ -7,14 +8,14 @@ from amsdal_utils.lifecycle.consumer import LifecycleConsumer
7
8
  from amsdal_utils.models.data_models.address import Address
8
9
  from amsdal_utils.models.data_models.core import LegacyDictSchema
9
10
  from amsdal_utils.models.data_models.enums import CoreTypes
10
- from amsdal_utils.models.data_models.schema import PropertyData
11
- from amsdal_utils.models.enums import SchemaTypes
12
11
  from amsdal_utils.models.enums import Versions
12
+ from amsdal_utils.schemas.schema import PropertyData
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
16
16
  core_to_frontend_types = {
17
17
  CoreTypes.NUMBER.value: 'number',
18
+ CoreTypes.INTEGER.value: 'integer',
18
19
  CoreTypes.BOOLEAN.value: 'checkbox',
19
20
  CoreTypes.STRING.value: 'text',
20
21
  CoreTypes.ANYTHING.value: 'text',
@@ -126,6 +127,7 @@ def populate_frontend_config_with_values(config: dict[str, Any], values: dict[st
126
127
 
127
128
  if config.get('name') in values:
128
129
  config['value'] = values[config['name']]
130
+
129
131
  return config
130
132
 
131
133
 
@@ -167,23 +169,36 @@ def get_default_control(class_name: str) -> dict[str, Any]:
167
169
  Returns:
168
170
  dict[str, Any]: A dictionary representing the frontend control configuration for the given class.
169
171
  """
170
- from amsdal_models.classes.manager import ClassManager
172
+ from amsdal_models.classes.class_manager import ClassManager
171
173
 
172
174
  from amsdal.contrib.frontend_configs.conversion import convert_to_frontend_config
173
- from models.contrib.frontend_control_config import FrontendControlConfig # type: ignore[import-not-found]
175
+ from amsdal.contrib.frontend_configs.models.frontend_control_config import (
176
+ FrontendControlConfig, # type: ignore[import-not-found]
177
+ )
178
+ from amsdal.models.core.file import File
174
179
 
175
180
  target_class = None
176
- for schema_type in [SchemaTypes.USER, SchemaTypes.CONTRIB, SchemaTypes.CORE]:
177
- with contextlib.suppress(AmsdalClassNotFoundError):
178
- target_class = ClassManager().import_class(class_name, schema_type)
179
-
180
- if target_class:
181
- break
181
+ with contextlib.suppress(AmsdalClassNotFoundError):
182
+ target_class = ClassManager().import_class(class_name)
182
183
 
183
184
  if not target_class:
184
185
  return {}
185
186
 
186
- return FrontendControlConfig(**convert_to_frontend_config(target_class)).model_dump(
187
+ if target_class is File:
188
+ config = {
189
+ 'type': 'group',
190
+ 'name': 'File',
191
+ 'label': 'File',
192
+ 'controls': [
193
+ {'label': 'Filename', 'name': 'filename', 'type': 'text', 'required': True},
194
+ {'label': 'Data', 'name': 'data', 'type': 'Bytes', 'required': True},
195
+ {'label': 'Size', 'name': 'size', 'type': 'number', 'required': False},
196
+ ],
197
+ }
198
+ else:
199
+ config = convert_to_frontend_config(target_class, is_transaction=False)
200
+
201
+ return FrontendControlConfig(**config).model_dump(
187
202
  exclude_none=True,
188
203
  )
189
204
 
@@ -212,7 +227,9 @@ class ProcessResponseConsumer(LifecycleConsumer):
212
227
  Returns:
213
228
  None
214
229
  """
215
- from models.contrib.frontend_model_config import FrontendModelConfig # type: ignore[import-not-found]
230
+ from amsdal.contrib.frontend_configs.models.frontend_model_config import (
231
+ FrontendModelConfig, # type: ignore[import-not-found]
232
+ )
216
233
 
217
234
  class_name = None
218
235
  values = {}
@@ -257,7 +274,9 @@ class ProcessResponseConsumer(LifecycleConsumer):
257
274
  Returns:
258
275
  None
259
276
  """
260
- from models.contrib.frontend_model_config import FrontendModelConfig # type: ignore[import-not-found]
277
+ from amsdal.contrib.frontend_configs.models.frontend_model_config import (
278
+ FrontendModelConfig, # type: ignore[import-not-found]
279
+ )
261
280
 
262
281
  class_name = None
263
282
  values = {}
@@ -1,6 +1,6 @@
1
1
  from _typeshed import Incomplete
2
2
  from amsdal_utils.lifecycle.consumer import LifecycleConsumer
3
- from amsdal_utils.models.data_models.schema import PropertyData
3
+ from amsdal_utils.schemas.schema import PropertyData
4
4
  from typing import Any
5
5
 
6
6
  logger: Incomplete