dmart 1.4.40.post8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (489) hide show
  1. dmart/__init__.py +7 -0
  2. dmart/alembic/README +1 -0
  3. dmart/alembic/__init__.py +0 -0
  4. dmart/alembic/env.py +91 -0
  5. dmart/alembic/notes.txt +11 -0
  6. dmart/alembic/script.py.mako +28 -0
  7. dmart/alembic/scripts/__init__.py +0 -0
  8. dmart/alembic/scripts/calculate_checksums.py +77 -0
  9. dmart/alembic/scripts/migration_f7a4949eed19.py +28 -0
  10. dmart/alembic/versions/0f3d2b1a7c21_add_authz_materialized_views.py +87 -0
  11. dmart/alembic/versions/10d2041b94d4_last_checksum_history.py +62 -0
  12. dmart/alembic/versions/1cf4e1ee3cb8_ext_permission_with_filter_fields_values.py +33 -0
  13. dmart/alembic/versions/26bfe19b49d4_rm_failedloginattempts.py +42 -0
  14. dmart/alembic/versions/3c8bca2219cc_add_otp_table.py +38 -0
  15. dmart/alembic/versions/6675fd9dfe42_remove_unique_from_sessions_table.py +36 -0
  16. dmart/alembic/versions/71bc1df82e6a_adding_user_last_login_at.py +43 -0
  17. dmart/alembic/versions/74288ccbd3b5_initial.py +264 -0
  18. dmart/alembic/versions/7520a89a8467_rm_activesession_table.py +39 -0
  19. dmart/alembic/versions/848b623755a4_make_created_nd_updated_at_required.py +138 -0
  20. dmart/alembic/versions/8640dcbebf85_add_notes_to_users.py +32 -0
  21. dmart/alembic/versions/91c94250232a_adding_fk_on_owner_shortname.py +104 -0
  22. dmart/alembic/versions/98ecd6f56f9a_ext_meta_with_owner_group_shortname.py +66 -0
  23. dmart/alembic/versions/9aae9138c4ef_indexing_created_at_updated_at.py +80 -0
  24. dmart/alembic/versions/__init__.py +0 -0
  25. dmart/alembic/versions/b53f916b3f6d_json_to_jsonb.py +492 -0
  26. dmart/alembic/versions/eb5f1ec65156_adding_user_locked_to_device.py +36 -0
  27. dmart/alembic/versions/f7a4949eed19_adding_query_policies_to_meta.py +60 -0
  28. dmart/alembic.ini +117 -0
  29. dmart/api/__init__.py +0 -0
  30. dmart/api/info/__init__.py +0 -0
  31. dmart/api/info/router.py +109 -0
  32. dmart/api/managed/__init__.py +0 -0
  33. dmart/api/managed/router.py +1541 -0
  34. dmart/api/managed/utils.py +1879 -0
  35. dmart/api/public/__init__.py +0 -0
  36. dmart/api/public/router.py +758 -0
  37. dmart/api/qr/__init__.py +0 -0
  38. dmart/api/qr/router.py +108 -0
  39. dmart/api/user/__init__.py +0 -0
  40. dmart/api/user/model/__init__.py +0 -0
  41. dmart/api/user/model/errors.py +14 -0
  42. dmart/api/user/model/requests.py +165 -0
  43. dmart/api/user/model/responses.py +11 -0
  44. dmart/api/user/router.py +1413 -0
  45. dmart/api/user/service.py +270 -0
  46. dmart/bundler.py +52 -0
  47. dmart/cli.py +1133 -0
  48. dmart/config/__init__.py +0 -0
  49. dmart/config/channels.json +11 -0
  50. dmart/config/notification.json +17 -0
  51. dmart/config.env.sample +27 -0
  52. dmart/config.ini.sample +7 -0
  53. dmart/conftest.py +13 -0
  54. dmart/curl.sh +196 -0
  55. dmart/cxb/__init__.py +0 -0
  56. dmart/cxb/assets/@codemirror-Rn7_6DkE.js +10 -0
  57. dmart/cxb/assets/@edraj-CS4NwVbD.js +1 -0
  58. dmart/cxb/assets/@floating-ui-BwwcF-xh.js +1 -0
  59. dmart/cxb/assets/@formatjs-yKEsAtjs.js +1 -0
  60. dmart/cxb/assets/@fortawesome-DRW1UCdr.js +9 -0
  61. dmart/cxb/assets/@jsonquerylang-laKNoFFq.js +12 -0
  62. dmart/cxb/assets/@lezer-za4Q-8Ew.js +1 -0
  63. dmart/cxb/assets/@marijn-DXwl3gUT.js +1 -0
  64. dmart/cxb/assets/@popperjs-l0sNRNKZ.js +1 -0
  65. dmart/cxb/assets/@replit--ERk53eB.js +1 -0
  66. dmart/cxb/assets/@roxi-CGMFK4i8.js +6 -0
  67. dmart/cxb/assets/@typewriter-cCzskkIv.js +17 -0
  68. dmart/cxb/assets/@zerodevx-BlBZjKxu.js +1 -0
  69. dmart/cxb/assets/@zerodevx-CVEpe6WZ.css +1 -0
  70. dmart/cxb/assets/BreadCrumbLite-DAhOx38v.js +1 -0
  71. dmart/cxb/assets/EntryRenderer-CCqV8Rkg.js +32 -0
  72. dmart/cxb/assets/EntryRenderer-DXytdFp9.css +1 -0
  73. dmart/cxb/assets/ListView-BQelo7vZ.js +16 -0
  74. dmart/cxb/assets/ListView-U8of-_c-.css +1 -0
  75. dmart/cxb/assets/Prism--hMplq-p.js +3 -0
  76. dmart/cxb/assets/Prism-Uh6uStUw.css +1 -0
  77. dmart/cxb/assets/Table2Cols-BsbwicQm.js +1 -0
  78. dmart/cxb/assets/_..-BvT6vdHa.css +1 -0
  79. dmart/cxb/assets/_...404_-fuLH_rX9.js +2 -0
  80. dmart/cxb/assets/_...fallback_-Ba_NLmAE.js +1 -0
  81. dmart/cxb/assets/_module-3HrtKAWo.js +3 -0
  82. dmart/cxb/assets/_module-DFKFq0AM.js +4 -0
  83. dmart/cxb/assets/_module-Dgq0ZVtz.js +1 -0
  84. dmart/cxb/assets/ajv-Cpj98o6Y.js +1 -0
  85. dmart/cxb/assets/axios-CG2WSiiR.js +6 -0
  86. dmart/cxb/assets/clsx-B-dksMZM.js +1 -0
  87. dmart/cxb/assets/codemirror-wrapped-line-indent-DPhKvljI.js +1 -0
  88. dmart/cxb/assets/compare-C3AjiGFR.js +1 -0
  89. dmart/cxb/assets/compute-scroll-into-view-Bl8rNFhg.js +1 -0
  90. dmart/cxb/assets/consolite-DlCuI0F9.js +1 -0
  91. dmart/cxb/assets/crelt-C8TCjufn.js +1 -0
  92. dmart/cxb/assets/date-fns-l0sNRNKZ.js +1 -0
  93. dmart/cxb/assets/deepmerge-rn4rBaHU.js +1 -0
  94. dmart/cxb/assets/dmart_services-AL6-IdDE.js +1 -0
  95. dmart/cxb/assets/downloadFile-D08i0YDh.js +1 -0
  96. dmart/cxb/assets/easy-signal-BiPFIK3O.js +1 -0
  97. dmart/cxb/assets/esm-env-rsSWfq8L.js +1 -0
  98. dmart/cxb/assets/export-OF_rTiXu.js +1 -0
  99. dmart/cxb/assets/fast-deep-equal-l0sNRNKZ.js +1 -0
  100. dmart/cxb/assets/fast-diff-C-IidNf4.js +1 -0
  101. dmart/cxb/assets/fast-uri-l0sNRNKZ.js +1 -0
  102. dmart/cxb/assets/flowbite-svelte-BLvjb-sa.js +1 -0
  103. dmart/cxb/assets/flowbite-svelte-CD54FDqW.css +1 -0
  104. dmart/cxb/assets/flowbite-svelte-icons-BI8GVhw_.js +1 -0
  105. dmart/cxb/assets/github-slugger-CQ4oX9Ud.js +1 -0
  106. dmart/cxb/assets/global-igKv-1g9.js +1 -0
  107. dmart/cxb/assets/hookar-BMRD9G9H.js +1 -0
  108. dmart/cxb/assets/immutable-json-patch-DtRO2E_S.js +1 -0
  109. dmart/cxb/assets/import-1vE3gBat.js +1 -0
  110. dmart/cxb/assets/index-B-eTh-ZX.js +1 -0
  111. dmart/cxb/assets/index-BSsK-X71.js +1 -0
  112. dmart/cxb/assets/index-BVyxzKtH.js +1 -0
  113. dmart/cxb/assets/index-BdeNM69f.js +1 -0
  114. dmart/cxb/assets/index-CC-A1ipE.js +1 -0
  115. dmart/cxb/assets/index-CQohGiYB.js +1 -0
  116. dmart/cxb/assets/index-ChjnkpdZ.js +4 -0
  117. dmart/cxb/assets/index-DLP7csA4.js +1 -0
  118. dmart/cxb/assets/index-DTfhnhwd.js +1 -0
  119. dmart/cxb/assets/index-DdXRK7n9.js +2 -0
  120. dmart/cxb/assets/index-DtiCmB4o.js +1 -0
  121. dmart/cxb/assets/index-NBrXBlLA.css +2 -0
  122. dmart/cxb/assets/index-X1uNehO7.js +1 -0
  123. dmart/cxb/assets/index-nrQW6Nrr.js +1 -0
  124. dmart/cxb/assets/info-B986lRiM.js +1 -0
  125. dmart/cxb/assets/intl-messageformat-Dc5UU-HB.js +3 -0
  126. dmart/cxb/assets/jmespath-l0sNRNKZ.js +1 -0
  127. dmart/cxb/assets/json-schema-traverse-l0sNRNKZ.js +1 -0
  128. dmart/cxb/assets/json-source-map-DRgZidqy.js +5 -0
  129. dmart/cxb/assets/jsonpath-plus-l0sNRNKZ.js +1 -0
  130. dmart/cxb/assets/jsonrepair-B30Dx381.js +8 -0
  131. dmart/cxb/assets/lodash-es-DZVAA2ox.js +1 -0
  132. dmart/cxb/assets/marked-DKjyhwJX.js +56 -0
  133. dmart/cxb/assets/marked-gfm-heading-id-U5zO829x.js +2 -0
  134. dmart/cxb/assets/marked-mangle-CDMeiHC6.js +1 -0
  135. dmart/cxb/assets/memoize-one-BdPwpGay.js +1 -0
  136. dmart/cxb/assets/natural-compare-lite-Bg2Xcf-o.js +7 -0
  137. dmart/cxb/assets/pagination-svelte-D5CyoiE_.js +13 -0
  138. dmart/cxb/assets/pagination-svelte-v10nAbbM.css +1 -0
  139. dmart/cxb/assets/plantuml-encoder-C47mzt9T.js +1 -0
  140. dmart/cxb/assets/prismjs-DTUiLGJu.js +9 -0
  141. dmart/cxb/assets/profile-BUf-tKMe.js +1 -0
  142. dmart/cxb/assets/query-CNmXTsgf.js +1 -0
  143. dmart/cxb/assets/queryHelpers-C9iBWwqe.js +1 -0
  144. dmart/cxb/assets/scroll-into-view-if-needed-KR58zyjF.js +1 -0
  145. dmart/cxb/assets/spaces-0oyGvpii.js +1 -0
  146. dmart/cxb/assets/style-mod-Bs6eFhZE.js +3 -0
  147. dmart/cxb/assets/svelte-B2XmcTi_.js +4 -0
  148. dmart/cxb/assets/svelte-awesome-COLlx0DN.css +1 -0
  149. dmart/cxb/assets/svelte-awesome-DhnMA6Q_.js +1 -0
  150. dmart/cxb/assets/svelte-datatables-net-CY7LBj6I.js +1 -0
  151. dmart/cxb/assets/svelte-floating-ui-BlS3sOAQ.js +1 -0
  152. dmart/cxb/assets/svelte-i18n-CT2KkQaN.js +3 -0
  153. dmart/cxb/assets/svelte-jsoneditor-BzfX6Usi.css +1 -0
  154. dmart/cxb/assets/svelte-jsoneditor-CUGSvWId.js +25 -0
  155. dmart/cxb/assets/svelte-select-CegQKzqH.css +1 -0
  156. dmart/cxb/assets/svelte-select-CjHAt_85.js +6 -0
  157. dmart/cxb/assets/tailwind-merge-CJvxXMcu.js +1 -0
  158. dmart/cxb/assets/tailwind-variants-Cj20BoQ3.js +1 -0
  159. dmart/cxb/assets/toast-B9WDyfyI.js +1 -0
  160. dmart/cxb/assets/tslib-pJfR_DrR.js +1 -0
  161. dmart/cxb/assets/typewriter-editor-DkTVIJdm.js +25 -0
  162. dmart/cxb/assets/user-DeK_NB5v.js +1 -0
  163. dmart/cxb/assets/vanilla-picker-l5rcX3cq.js +8 -0
  164. dmart/cxb/assets/w3c-keyname-Vcq4gwWv.js +1 -0
  165. dmart/cxb/config.json +11 -0
  166. dmart/cxb/config.sample.json +11 -0
  167. dmart/cxb/favicon.ico +0 -0
  168. dmart/cxb/favicon.png +0 -0
  169. dmart/cxb/index.html +28 -0
  170. dmart/data_adapters/__init__.py +0 -0
  171. dmart/data_adapters/adapter.py +16 -0
  172. dmart/data_adapters/base_data_adapter.py +467 -0
  173. dmart/data_adapters/file/__init__.py +0 -0
  174. dmart/data_adapters/file/adapter.py +2043 -0
  175. dmart/data_adapters/file/adapter_helpers.py +1013 -0
  176. dmart/data_adapters/file/archive.py +150 -0
  177. dmart/data_adapters/file/create_index.py +331 -0
  178. dmart/data_adapters/file/create_users_folders.py +52 -0
  179. dmart/data_adapters/file/custom_validations.py +68 -0
  180. dmart/data_adapters/file/drop_index.py +40 -0
  181. dmart/data_adapters/file/health_check.py +560 -0
  182. dmart/data_adapters/file/redis_services.py +1110 -0
  183. dmart/data_adapters/helpers.py +27 -0
  184. dmart/data_adapters/sql/__init__.py +0 -0
  185. dmart/data_adapters/sql/adapter.py +3218 -0
  186. dmart/data_adapters/sql/adapter_helpers.py +491 -0
  187. dmart/data_adapters/sql/create_tables.py +451 -0
  188. dmart/data_adapters/sql/create_users_folders.py +53 -0
  189. dmart/data_adapters/sql/db_to_json_migration.py +485 -0
  190. dmart/data_adapters/sql/health_check_sql.py +232 -0
  191. dmart/data_adapters/sql/json_to_db_migration.py +454 -0
  192. dmart/data_adapters/sql/update_query_policies.py +101 -0
  193. dmart/data_generator.py +81 -0
  194. dmart/dmart.py +761 -0
  195. dmart/get_settings.py +7 -0
  196. dmart/hypercorn_config.toml +3 -0
  197. dmart/info.json +1 -0
  198. dmart/languages/__init__.py +0 -0
  199. dmart/languages/arabic.json +15 -0
  200. dmart/languages/english.json +16 -0
  201. dmart/languages/kurdish.json +14 -0
  202. dmart/languages/loader.py +12 -0
  203. dmart/login_creds.sh +7 -0
  204. dmart/login_creds.sh.sample +7 -0
  205. dmart/main.py +563 -0
  206. dmart/manifest.sh +12 -0
  207. dmart/migrate.py +24 -0
  208. dmart/models/__init__.py +0 -0
  209. dmart/models/api.py +203 -0
  210. dmart/models/core.py +597 -0
  211. dmart/models/enums.py +255 -0
  212. dmart/password_gen.py +8 -0
  213. dmart/plugins/__init__.py +0 -0
  214. dmart/plugins/action_log/__init__.py +0 -0
  215. dmart/plugins/action_log/config.json +13 -0
  216. dmart/plugins/action_log/plugin.py +121 -0
  217. dmart/plugins/admin_notification_sender/__init__.py +0 -0
  218. dmart/plugins/admin_notification_sender/config.json +13 -0
  219. dmart/plugins/admin_notification_sender/plugin.py +124 -0
  220. dmart/plugins/ldap_manager/__init__.py +0 -0
  221. dmart/plugins/ldap_manager/config.json +12 -0
  222. dmart/plugins/ldap_manager/dmart.schema +146 -0
  223. dmart/plugins/ldap_manager/plugin.py +100 -0
  224. dmart/plugins/ldap_manager/slapd.conf +53 -0
  225. dmart/plugins/local_notification/__init__.py +0 -0
  226. dmart/plugins/local_notification/config.json +13 -0
  227. dmart/plugins/local_notification/plugin.py +123 -0
  228. dmart/plugins/realtime_updates_notifier/__init__.py +0 -0
  229. dmart/plugins/realtime_updates_notifier/config.json +12 -0
  230. dmart/plugins/realtime_updates_notifier/plugin.py +58 -0
  231. dmart/plugins/redis_db_update/__init__.py +0 -0
  232. dmart/plugins/redis_db_update/config.json +13 -0
  233. dmart/plugins/redis_db_update/plugin.py +188 -0
  234. dmart/plugins/resource_folders_creation/__init__.py +0 -0
  235. dmart/plugins/resource_folders_creation/config.json +12 -0
  236. dmart/plugins/resource_folders_creation/plugin.py +81 -0
  237. dmart/plugins/system_notification_sender/__init__.py +0 -0
  238. dmart/plugins/system_notification_sender/config.json +13 -0
  239. dmart/plugins/system_notification_sender/plugin.py +188 -0
  240. dmart/plugins/update_access_controls/__init__.py +0 -0
  241. dmart/plugins/update_access_controls/config.json +12 -0
  242. dmart/plugins/update_access_controls/plugin.py +9 -0
  243. dmart/publish.sh +57 -0
  244. dmart/pylint.sh +16 -0
  245. dmart/pyrightconfig.json +7 -0
  246. dmart/redis_connections.sh +13 -0
  247. dmart/reload.sh +56 -0
  248. dmart/run.sh +3 -0
  249. dmart/run_notification_campaign.py +85 -0
  250. dmart/sample/spaces/applications/.dm/meta.space.json +30 -0
  251. dmart/sample/spaces/applications/api/.dm/meta.folder.json +1 -0
  252. dmart/sample/spaces/applications/api/.dm/query_all_applications/meta.content.json +1 -0
  253. dmart/sample/spaces/applications/api/.dm/test_by_saad/attachments.media/meta.warframe.json +1 -0
  254. dmart/sample/spaces/applications/api/.dm/test_by_saad/attachments.media/warframe.png +0 -0
  255. dmart/sample/spaces/applications/api/.dm/test_by_saad/meta.content.json +1 -0
  256. dmart/sample/spaces/applications/api/.dm/user_profile/meta.content.json +1 -0
  257. dmart/sample/spaces/applications/api/applications/.dm/create_log/meta.content.json +1 -0
  258. dmart/sample/spaces/applications/api/applications/.dm/create_public_logs/meta.content.json +1 -0
  259. dmart/sample/spaces/applications/api/applications/.dm/meta.folder.json +1 -0
  260. dmart/sample/spaces/applications/api/applications/.dm/query_all_translated_data/meta.content.json +1 -0
  261. dmart/sample/spaces/applications/api/applications/.dm/query_logs/meta.content.json +1 -0
  262. dmart/sample/spaces/applications/api/applications/.dm/query_translated_enums/meta.content.json +1 -0
  263. dmart/sample/spaces/applications/api/applications/.dm/query_translated_others/meta.content.json +1 -0
  264. dmart/sample/spaces/applications/api/applications/.dm/query_translated_resolution/meta.content.json +1 -0
  265. dmart/sample/spaces/applications/api/applications/create_log.json +1 -0
  266. dmart/sample/spaces/applications/api/applications/create_public_logs.json +1 -0
  267. dmart/sample/spaces/applications/api/applications/query_all_translated_data.json +1 -0
  268. dmart/sample/spaces/applications/api/applications/query_logs.json +1 -0
  269. dmart/sample/spaces/applications/api/applications/query_translated_enums.json +1 -0
  270. dmart/sample/spaces/applications/api/applications/query_translated_others.json +1 -0
  271. dmart/sample/spaces/applications/api/applications/query_translated_resolution.json +1 -0
  272. dmart/sample/spaces/applications/api/applications.json +1 -0
  273. dmart/sample/spaces/applications/api/management/.dm/create_subaccount/meta.content.json +1 -0
  274. dmart/sample/spaces/applications/api/management/.dm/meta.folder.json +1 -0
  275. dmart/sample/spaces/applications/api/management/.dm/update_password/meta.content.json +1 -0
  276. dmart/sample/spaces/applications/api/management/create_subaccount.json +53 -0
  277. dmart/sample/spaces/applications/api/management/update_password.json +1 -0
  278. dmart/sample/spaces/applications/api/management.json +1 -0
  279. dmart/sample/spaces/applications/api/query_all_applications.json +15 -0
  280. dmart/sample/spaces/applications/api/test_by_saad.json +1 -0
  281. dmart/sample/spaces/applications/api/user/.dm/meta.folder.json +1 -0
  282. dmart/sample/spaces/applications/api/user/.dm/test_by_saad/meta.content.json +1 -0
  283. dmart/sample/spaces/applications/api/user/.dm/user_profile/meta.content.json +1 -0
  284. dmart/sample/spaces/applications/api/user/test_by_saad.json +1 -0
  285. dmart/sample/spaces/applications/api/user/user_profile.json +1 -0
  286. dmart/sample/spaces/applications/api/user_profile.json +1 -0
  287. dmart/sample/spaces/applications/api.json +1 -0
  288. dmart/sample/spaces/applications/collections/.dm/meta.folder.json +19 -0
  289. dmart/sample/spaces/applications/collections.json +1 -0
  290. dmart/sample/spaces/applications/configurations/.dm/meta.folder.json +1 -0
  291. dmart/sample/spaces/applications/configurations/time_out.json +1 -0
  292. dmart/sample/spaces/applications/configurations.json +19 -0
  293. dmart/sample/spaces/applications/errors.json +1 -0
  294. dmart/sample/spaces/applications/logs/.dm/meta.folder.json +1 -0
  295. dmart/sample/spaces/applications/logs.json +1 -0
  296. dmart/sample/spaces/applications/queries/.dm/meta.folder.json +1 -0
  297. dmart/sample/spaces/applications/queries/.dm/order/meta.content.json +1 -0
  298. dmart/sample/spaces/applications/queries/order.json +1 -0
  299. dmart/sample/spaces/applications/queries.json +1 -0
  300. dmart/sample/spaces/applications/schema/.dm/api/meta.schema.json +1 -0
  301. dmart/sample/spaces/applications/schema/.dm/configuration/meta.schema.json +1 -0
  302. dmart/sample/spaces/applications/schema/.dm/error/meta.schema.json +1 -0
  303. dmart/sample/spaces/applications/schema/.dm/log/meta.schema.json +1 -0
  304. dmart/sample/spaces/applications/schema/.dm/meta.folder.json +1 -0
  305. dmart/sample/spaces/applications/schema/.dm/query/meta.schema.json +16 -0
  306. dmart/sample/spaces/applications/schema/.dm/translation/meta.schema.json +1 -0
  307. dmart/sample/spaces/applications/schema/api.json +28 -0
  308. dmart/sample/spaces/applications/schema/configuration.json +1 -0
  309. dmart/sample/spaces/applications/schema/error.json +43 -0
  310. dmart/sample/spaces/applications/schema/log.json +1 -0
  311. dmart/sample/spaces/applications/schema/query.json +118 -0
  312. dmart/sample/spaces/applications/schema/translation.json +26 -0
  313. dmart/sample/spaces/applications/schema.json +1 -0
  314. dmart/sample/spaces/applications/translations/.dm/meta.folder.json +1 -0
  315. dmart/sample/spaces/applications/translations.json +1 -0
  316. dmart/sample/spaces/archive/.dm/meta.space.json +27 -0
  317. dmart/sample/spaces/custom_plugins/dummy/__pycache__/plugin.cpython-314.pyc +0 -0
  318. dmart/sample/spaces/custom_plugins/dummy/config.json +28 -0
  319. dmart/sample/spaces/custom_plugins/dummy/plugin.py +6 -0
  320. dmart/sample/spaces/custom_plugins/missed_entry/config.json +12 -0
  321. dmart/sample/spaces/custom_plugins/missed_entry/plugin.py +119 -0
  322. dmart/sample/spaces/custom_plugins/own_changed_notification/__pycache__/plugin.cpython-314.pyc +0 -0
  323. dmart/sample/spaces/custom_plugins/own_changed_notification/config.json +12 -0
  324. dmart/sample/spaces/custom_plugins/own_changed_notification/plugin.py +65 -0
  325. dmart/sample/spaces/custom_plugins/reports_stats/config.json +14 -0
  326. dmart/sample/spaces/custom_plugins/reports_stats/plugin.py +82 -0
  327. dmart/sample/spaces/custom_plugins/system_notification_sender/config.json +22 -0
  328. dmart/sample/spaces/custom_plugins/system_notification_sender/notification.py +268 -0
  329. dmart/sample/spaces/custom_plugins/system_notification_sender/plugin.py +98 -0
  330. dmart/sample/spaces/management/.dm/events.jsonl +32 -0
  331. dmart/sample/spaces/management/.dm/meta.space.json +48 -0
  332. dmart/sample/spaces/management/.dm/notifications/attachments.view.json/admin.json +36 -0
  333. dmart/sample/spaces/management/.dm/notifications/attachments.view.json/meta.admin.json +1 -0
  334. dmart/sample/spaces/management/.dm/notifications/attachments.view.json/meta.system.json +1 -0
  335. dmart/sample/spaces/management/.dm/notifications/attachments.view.json/system.json +32 -0
  336. dmart/sample/spaces/management/collections/.dm/meta.folder.json +1 -0
  337. dmart/sample/spaces/management/collections.json +1 -0
  338. dmart/sample/spaces/management/groups/.dm/meta.folder.json +1 -0
  339. dmart/sample/spaces/management/groups.json +1 -0
  340. dmart/sample/spaces/management/health_check/.dm/meta.folder.json +1 -0
  341. dmart/sample/spaces/management/health_check.json +1 -0
  342. dmart/sample/spaces/management/notifications/.dm/meta.folder.json +1 -0
  343. dmart/sample/spaces/management/notifications/admin/.dm/meta.folder.json +9 -0
  344. dmart/sample/spaces/management/notifications/system/.dm/meta.folder.json +9 -0
  345. dmart/sample/spaces/management/notifications.json +1 -0
  346. dmart/sample/spaces/management/permissions/.dm/access_applications/meta.permission.json +31 -0
  347. dmart/sample/spaces/management/permissions/.dm/access_applications_world/meta.permission.json +31 -0
  348. dmart/sample/spaces/management/permissions/.dm/access_messages/meta.permission.json +23 -0
  349. dmart/sample/spaces/management/permissions/.dm/access_personal/meta.permission.json +40 -0
  350. dmart/sample/spaces/management/permissions/.dm/access_protected/meta.permission.json +33 -0
  351. dmart/sample/spaces/management/permissions/.dm/access_public/meta.permission.json +24 -0
  352. dmart/sample/spaces/management/permissions/.dm/browse_all_folders/meta.permission.json +23 -0
  353. dmart/sample/spaces/management/permissions/.dm/create_log/meta.permission.json +24 -0
  354. dmart/sample/spaces/management/permissions/.dm/interviewer/meta.permission.json +1 -0
  355. dmart/sample/spaces/management/permissions/.dm/manage_applications/meta.permission.json +1 -0
  356. dmart/sample/spaces/management/permissions/.dm/manage_debug/meta.permission.json +25 -0
  357. dmart/sample/spaces/management/permissions/.dm/manage_spaces/meta.permission.json +24 -0
  358. dmart/sample/spaces/management/permissions/.dm/meta.folder.json +1 -0
  359. dmart/sample/spaces/management/permissions/.dm/rules_management_default/meta.permission.json +32 -0
  360. dmart/sample/spaces/management/permissions/.dm/super_manager/meta.permission.json +52 -0
  361. dmart/sample/spaces/management/permissions/.dm/view_activity_log/meta.permission.json +26 -0
  362. dmart/sample/spaces/management/permissions/.dm/view_collections/meta.permission.json +29 -0
  363. dmart/sample/spaces/management/permissions/.dm/view_logs/meta.permission.json +30 -0
  364. dmart/sample/spaces/management/permissions/.dm/view_roles/meta.permission.json +29 -0
  365. dmart/sample/spaces/management/permissions/.dm/view_users/meta.permission.json +25 -0
  366. dmart/sample/spaces/management/permissions/.dm/view_world/meta.permission.json +31 -0
  367. dmart/sample/spaces/management/permissions/.dm/world/meta.permission.json +35 -0
  368. dmart/sample/spaces/management/permissions.json +1 -0
  369. dmart/sample/spaces/management/requests.json +1 -0
  370. dmart/sample/spaces/management/roles/.dm/dummy/meta.role.json +12 -0
  371. dmart/sample/spaces/management/roles/.dm/logged_in/meta.role.json +18 -0
  372. dmart/sample/spaces/management/roles/.dm/manager/meta.role.json +13 -0
  373. dmart/sample/spaces/management/roles/.dm/meta.folder.json +1 -0
  374. dmart/sample/spaces/management/roles/.dm/moderator/meta.role.json +13 -0
  375. dmart/sample/spaces/management/roles/.dm/super_admin/meta.role.json +14 -0
  376. dmart/sample/spaces/management/roles/.dm/test_role/meta.role.json +13 -0
  377. dmart/sample/spaces/management/roles/.dm/world/meta.role.json +15 -0
  378. dmart/sample/spaces/management/roles.json +1 -0
  379. dmart/sample/spaces/management/schema/.dm/admin_notification_request/attachments.media/meta.ui_schema.json +10 -0
  380. dmart/sample/spaces/management/schema/.dm/admin_notification_request/attachments.media/ui_schema.json +32 -0
  381. dmart/sample/spaces/management/schema/.dm/admin_notification_request/meta.schema.json +1 -0
  382. dmart/sample/spaces/management/schema/.dm/api/meta.schema.json +1 -0
  383. dmart/sample/spaces/management/schema/.dm/folder_rendering/meta.schema.json +1 -0
  384. dmart/sample/spaces/management/schema/.dm/health_check/meta.schema.json +17 -0
  385. dmart/sample/spaces/management/schema/.dm/meta.folder.json +1 -0
  386. dmart/sample/spaces/management/schema/.dm/meta_schema/meta.schema.json +1 -0
  387. dmart/sample/spaces/management/schema/.dm/metafile/meta.schema.json +14 -0
  388. dmart/sample/spaces/management/schema/.dm/notification/meta.schema.json +1 -0
  389. dmart/sample/spaces/management/schema/.dm/system_notification_request/attachments.media/meta.ui_schema.json +10 -0
  390. dmart/sample/spaces/management/schema/.dm/system_notification_request/attachments.media/ui_schema.json +32 -0
  391. dmart/sample/spaces/management/schema/.dm/system_notification_request/meta.schema.json +1 -0
  392. dmart/sample/spaces/management/schema/.dm/view/meta.schema.json +1 -0
  393. dmart/sample/spaces/management/schema/.dm/workflow/meta.schema.json +1 -0
  394. dmart/sample/spaces/management/schema/admin_notification_request.json +89 -0
  395. dmart/sample/spaces/management/schema/api.json +1 -0
  396. dmart/sample/spaces/management/schema/folder_rendering.json +238 -0
  397. dmart/sample/spaces/management/schema/health_check.json +8 -0
  398. dmart/sample/spaces/management/schema/meta_schema.json +74 -0
  399. dmart/sample/spaces/management/schema/metafile.json +153 -0
  400. dmart/sample/spaces/management/schema/notification.json +28 -0
  401. dmart/sample/spaces/management/schema/system_notification_request.json +57 -0
  402. dmart/sample/spaces/management/schema/view.json +23 -0
  403. dmart/sample/spaces/management/schema/workflow.json +87 -0
  404. dmart/sample/spaces/management/schema.json +1 -0
  405. dmart/sample/spaces/management/users/.dm/alibaba/meta.user.json +23 -0
  406. dmart/sample/spaces/management/users/.dm/anonymous/meta.user.json +18 -0
  407. dmart/sample/spaces/management/users/.dm/dmart/meta.user.json +26 -0
  408. dmart/sample/spaces/management/users/.dm/meta.folder.json +14 -0
  409. dmart/sample/spaces/management/workflows/.dm/channel/meta.content.json +1 -0
  410. dmart/sample/spaces/management/workflows/.dm/meta.folder.json +1 -0
  411. dmart/sample/spaces/management/workflows/channel.json +148 -0
  412. dmart/sample/spaces/management/workflows.json +1 -0
  413. dmart/sample/spaces/maqola/.dm/meta.space.json +33 -0
  414. dmart/sample/spaces/personal/.dm/meta.space.json +24 -0
  415. dmart/sample/spaces/personal/people/.dm/meta.folder.json +1 -0
  416. dmart/sample/spaces/personal/people/dmart/.dm/meta.folder.json +1 -0
  417. dmart/sample/spaces/personal/people/dmart/messages/.dm/0b5f7e7f/meta.content.json +1 -0
  418. dmart/sample/spaces/personal/people/dmart/messages/.dm/meta.folder.json +1 -0
  419. dmart/sample/spaces/personal/people/dmart/messages/.dm/mytest/meta.content.json +1 -0
  420. dmart/sample/spaces/personal/people/dmart/messages/0b5f7e7f.json +1 -0
  421. dmart/sample/spaces/personal/people/dmart/messages/mytest.json +1 -0
  422. dmart/sample/spaces/personal/people/dmart/notifications/.dm/meta.folder.json +1 -0
  423. dmart/sample/spaces/personal/people/dmart/private/.dm/inner/meta.content.json +1 -0
  424. dmart/sample/spaces/personal/people/dmart/private/.dm/meta.folder.json +1 -0
  425. dmart/sample/spaces/personal/people/dmart/private/inner.json +1 -0
  426. dmart/sample/spaces/personal/people/dmart/protected/.dm/avatar/meta.content.json +1 -0
  427. dmart/sample/spaces/personal/people/dmart/protected/.dm/meta.folder.json +1 -0
  428. dmart/sample/spaces/personal/people/dmart/protected/avatar.png +0 -0
  429. dmart/sample/spaces/personal/people/dmart/public/.dm/meta.folder.json +1 -0
  430. dmart/sample/test/.gitignore +2 -0
  431. dmart/sample/test/createcontent.json +9 -0
  432. dmart/sample/test/createmedia.json +9 -0
  433. dmart/sample/test/createmedia_entry.json +6 -0
  434. dmart/sample/test/createschema.json +8 -0
  435. dmart/sample/test/createschemawork.json +11 -0
  436. dmart/sample/test/createticket.json +13 -0
  437. dmart/sample/test/data.json +4 -0
  438. dmart/sample/test/deletecontent.json +12 -0
  439. dmart/sample/test/logo.jpeg +0 -0
  440. dmart/sample/test/my.jpg +0 -0
  441. dmart/sample/test/myticket.json +23 -0
  442. dmart/sample/test/resources.csv +12 -0
  443. dmart/sample/test/schema.json +16 -0
  444. dmart/sample/test/temp.json +1 -0
  445. dmart/sample/test/test.dmart +45 -0
  446. dmart/sample/test/ticket_schema.json +23 -0
  447. dmart/sample/test/ticket_workflow.json +85 -0
  448. dmart/sample/test/ticketbody.json +4 -0
  449. dmart/sample/test/ticketcontent.json +14 -0
  450. dmart/sample/test/updatecontent.json +20 -0
  451. dmart/sample/test/workflow_schema.json +68 -0
  452. dmart/scheduled_notification_handler.py +121 -0
  453. dmart/schema_migration.py +208 -0
  454. dmart/schema_modulate.py +192 -0
  455. dmart/set_admin_passwd.py +75 -0
  456. dmart/sync.py +202 -0
  457. dmart/test_utils.py +34 -0
  458. dmart/utils/__init__.py +0 -0
  459. dmart/utils/access_control.py +306 -0
  460. dmart/utils/async_request.py +8 -0
  461. dmart/utils/exporter.py +309 -0
  462. dmart/utils/firebase_notifier.py +57 -0
  463. dmart/utils/generate_email.py +37 -0
  464. dmart/utils/helpers.py +352 -0
  465. dmart/utils/hypercorn_config.py +12 -0
  466. dmart/utils/internal_error_code.py +60 -0
  467. dmart/utils/jwt.py +124 -0
  468. dmart/utils/logger.py +167 -0
  469. dmart/utils/middleware.py +99 -0
  470. dmart/utils/notification.py +75 -0
  471. dmart/utils/password_hashing.py +16 -0
  472. dmart/utils/plugin_manager.py +202 -0
  473. dmart/utils/query_policies_helper.py +128 -0
  474. dmart/utils/regex.py +44 -0
  475. dmart/utils/repository.py +529 -0
  476. dmart/utils/router_helper.py +19 -0
  477. dmart/utils/settings.py +212 -0
  478. dmart/utils/sms_notifier.py +21 -0
  479. dmart/utils/social_sso.py +67 -0
  480. dmart/utils/templates/activation.html.j2 +26 -0
  481. dmart/utils/templates/reminder.html.j2 +17 -0
  482. dmart/utils/ticket_sys_utils.py +203 -0
  483. dmart/utils/web_notifier.py +29 -0
  484. dmart/websocket.py +231 -0
  485. dmart-1.4.40.post8.dist-info/METADATA +75 -0
  486. dmart-1.4.40.post8.dist-info/RECORD +489 -0
  487. dmart-1.4.40.post8.dist-info/WHEEL +5 -0
  488. dmart-1.4.40.post8.dist-info/entry_points.txt +2 -0
  489. dmart-1.4.40.post8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1413 @@
1
+ """ Session Apis """
2
+ import json
3
+ import re
4
+ from pathlib import Path
5
+ import aiofiles
6
+ from utils.async_request import AsyncRequest
7
+ from utils.generate_email import generate_subject
8
+ from utils.generate_email import generate_email_from_template
9
+ from fastapi import APIRouter, Body, Query, Request, status, Depends, Response, Header
10
+ import models.api as api
11
+ import models.core as core
12
+ from models.enums import ActionType, RequestType, ResourceType, ContentType, UserType
13
+ from data_adapters.adapter import data_adapter as db
14
+ from utils.access_control import access_control
15
+ from utils.helpers import flatten_dict
16
+ from utils.internal_error_code import InternalErrorCode
17
+ from utils.jwt import JWTBearer, sign_jwt, decode_jwt
18
+ from typing import Any
19
+ from utils.settings import settings
20
+ import utils.repository as repository
21
+ from utils.plugin_manager import plugin_manager
22
+ import utils.password_hashing as password_hashing
23
+ from models.api import Error, Exception, Status
24
+ from utils.social_sso import get_apple_sso, get_facebook_sso, get_google_sso
25
+ from .service import (
26
+ gen_alphanumeric,
27
+ get_otp_key,
28
+ send_email,
29
+ send_sms,
30
+ send_otp,
31
+ email_send_otp,
32
+ get_shortname_from_identifier,
33
+ check_user_validation,
34
+ set_user_profile,
35
+ update_user_payload,
36
+ get_otp_confirmation_email_or_msisdn,
37
+ )
38
+ from .model.requests import (
39
+ ConfirmOTPRequest,
40
+ PasswordResetRequest,
41
+ SendOTPRequest,
42
+ UserLoginRequest,
43
+ )
44
+ import utils.regex as rgx
45
+ from languages.loader import languages
46
+ from fastapi_sso.sso.google import GoogleSSO
47
+ from fastapi_sso.sso.facebook import FacebookSSO
48
+ from fastapi_sso.sso.base import OpenID, SSOBase
49
+ from fastapi.logger import logger
50
+ from fastapi.responses import ORJSONResponse
51
+ from datetime import datetime
52
+
53
+ router = APIRouter(default_response_class=ORJSONResponse)
54
+
55
+ MANAGEMENT_SPACE: str = settings.management_space
56
+ USERS_SUBPATH: str = "users"
57
+
58
+
59
+ @router.get(
60
+ "/check-existing", response_model=api.Response, response_model_exclude_none=True
61
+ )
62
+ async def check_existing_user_fields(
63
+ shortname: str | None = Query(
64
+ default=None, pattern=rgx.SHORTNAME, examples=["john_doo"]),
65
+ msisdn: str | None = Query(
66
+ default=None, pattern=rgx.MSISDN, examples=["7777778110"]),
67
+ email: str | None = Query(default=None, pattern=rgx.EMAIL, examples=[
68
+ "john_doo@mail.com"]),
69
+ ):
70
+ unique_fields = {"shortname": shortname,
71
+ "msisdn": msisdn, "email_unescaped": email}
72
+
73
+ search_str = f"@subpath:{USERS_SUBPATH}"
74
+ redis_escape_chars = str.maketrans(
75
+ {".": r"\.", "@": r"\@", ":": r"\:", "/": r"\/", "-": r"\-", " ": r"\ "}
76
+ )
77
+
78
+ attributes = await db.check_uniqueness(unique_fields, search_str, redis_escape_chars)
79
+ return api.Response(status=api.Status.success, attributes=attributes)
80
+
81
+
82
+ @router.post("/create", response_model=api.Response, response_model_exclude_none=True)
83
+ async def create_user(response: Response, record: core.Record, http_request: Request) -> api.Response:
84
+ """Register a new user by invitation"""
85
+ if not settings.is_registrable:
86
+ raise api.Exception(
87
+ status_code=status.HTTP_400_BAD_REQUEST,
88
+ error=api.Error(type="create", code=50,
89
+ message="Register API is disabled"),
90
+ )
91
+
92
+ validation_message: str | None = None
93
+ if "email" not in record.attributes and "msisdn" not in record.attributes:
94
+ validation_message = "Email or MSISDN is required"
95
+
96
+ if record.attributes.get("email") and (settings.is_otp_for_create_required and not record.attributes.get("email_otp")):
97
+ validation_message = "Email OTP is required"
98
+
99
+ if record.attributes.get("msisdn") and (settings.is_otp_for_create_required and not record.attributes.get("msisdn_otp")):
100
+ validation_message = "MSISDN OTP is required"
101
+
102
+ if record.attributes.get("password") and not re.match(rgx.PASSWORD, record.attributes["password"]):
103
+ validation_message = "password dose not match required rules"
104
+
105
+ if validation_message:
106
+ raise api.Exception(
107
+ status_code=status.HTTP_400_BAD_REQUEST,
108
+ error=api.Error(type="create", code=50,
109
+ message=validation_message),
110
+ )
111
+ await db.validate_uniqueness(MANAGEMENT_SPACE, record, RequestType.create, "dmart")
112
+
113
+ await plugin_manager.before_action(
114
+ core.Event(
115
+ space_name=MANAGEMENT_SPACE,
116
+ subpath=USERS_SUBPATH,
117
+ shortname=record.shortname,
118
+ action_type=core.ActionType.create,
119
+ resource_type=ResourceType.user,
120
+ user_shortname=record.shortname,
121
+ )
122
+ )
123
+
124
+ record.resource_type = ResourceType.user
125
+ user = core.User.from_record(
126
+ record=record,
127
+ owner_shortname="dmart"
128
+ )
129
+ separate_payload_data: str | dict[str, Any] | None = {}
130
+ if record.attributes.get("payload", {}).get("body"):
131
+ schema_shortname = getattr(user.payload, "schema_shortname", None)
132
+ user.payload = core.Payload(
133
+ content_type=ContentType.json,
134
+ schema_shortname=schema_shortname,
135
+ body=record.attributes["payload"].get("body", ""),
136
+ )
137
+ if user.payload:
138
+ separate_payload_data = user.payload.body
139
+ user.payload.body = record.shortname + ".json"
140
+
141
+ if user.payload and separate_payload_data:
142
+ if not isinstance(separate_payload_data, str) and not isinstance(
143
+ separate_payload_data, Path
144
+ ):
145
+ if user.payload.schema_shortname:
146
+ await db.validate_payload_with_schema(
147
+ payload_data=separate_payload_data,
148
+ space_name=MANAGEMENT_SPACE,
149
+ schema_shortname=user.payload.schema_shortname,
150
+ )
151
+
152
+ if user.msisdn:
153
+ is_valid_otp = True if not settings.is_otp_for_create_required else await verify_user(ConfirmOTPRequest(
154
+ msisdn=record.attributes.get("msisdn"),
155
+ email=None,
156
+ shortname=None,
157
+ code=record.attributes.get("msisdn_otp", "")
158
+ ))
159
+ if not is_valid_otp:
160
+ raise api.Exception(
161
+ status_code=status.HTTP_400_BAD_REQUEST,
162
+ error=api.Error(type="create", code=50,
163
+ message="Invalid MSISDN OTP"),
164
+ )
165
+ user.is_msisdn_verified = True
166
+ if user.email:
167
+ is_valid_otp = True if not settings.is_otp_for_create_required else await verify_user(ConfirmOTPRequest(
168
+ email=record.attributes.get("email"),
169
+ msisdn=None,
170
+ shortname=None,
171
+ code=record.attributes.get("email_otp", "")
172
+ ))
173
+ if not is_valid_otp:
174
+ raise api.Exception(
175
+ status_code=status.HTTP_400_BAD_REQUEST,
176
+ error=api.Error(type="create", code=50,
177
+ message="Invalid Email OTP"),
178
+ )
179
+ user.is_email_verified = True
180
+
181
+ user.is_active = True
182
+
183
+ await db.create(MANAGEMENT_SPACE, USERS_SUBPATH, user)
184
+ if isinstance(separate_payload_data, dict) and separate_payload_data:
185
+ await db.update_payload(
186
+ MANAGEMENT_SPACE, USERS_SUBPATH, user, separate_payload_data, user.owner_shortname
187
+ )
188
+
189
+ response_record = await process_user_login(user, response, {}, None, http_request.headers)
190
+
191
+ await plugin_manager.after_action(
192
+ core.Event(
193
+ space_name=MANAGEMENT_SPACE,
194
+ subpath=USERS_SUBPATH,
195
+ shortname=record.shortname,
196
+ action_type=core.ActionType.create,
197
+ resource_type=ResourceType.user,
198
+ user_shortname=record.shortname,
199
+ )
200
+ )
201
+
202
+ return api.Response(
203
+ status=api.Status.success,
204
+ records=[
205
+ core.Record(
206
+ shortname=user.shortname,
207
+ subpath=USERS_SUBPATH,
208
+ resource_type=ResourceType.user,
209
+ attributes=response_record.attributes
210
+ )
211
+ ]
212
+ )
213
+
214
+ async def verify_user(user_request: ConfirmOTPRequest):
215
+ user_identifier = user_request.check_fields()
216
+ key = get_otp_key(user_identifier)
217
+ code = await db.get_otp(key)
218
+ if not code or code != user_request.code:
219
+ return False
220
+
221
+ return True
222
+
223
+ @router.post(
224
+ "/login",
225
+ response_model=api.Response,
226
+ response_model_exclude_none=True,
227
+ )
228
+ async def login(response: Response, request: UserLoginRequest, http_request: Request) -> api.Response:
229
+ """Login and generate refresh token"""
230
+ shortname: str | None = None
231
+ user = None
232
+ user_updates: dict[str, Any] = {}
233
+ identifier: dict[str, str] | None = request.check_fields()
234
+ try:
235
+ if request.invitation:
236
+ invitation_token = await db.get_invitation(request.invitation)
237
+ if invitation_token is None:
238
+ raise api.Exception(
239
+ status.HTTP_401_UNAUTHORIZED,
240
+ api.Error(
241
+ type="jwtauth",
242
+ code=InternalErrorCode.INVALID_INVITATION,
243
+ message="Expired or invalid invitation",
244
+ ),
245
+ )
246
+
247
+ data = decode_jwt(request.invitation)
248
+ shortname = data.get("shortname", None)
249
+ if (shortname is None) or (request.shortname is not None and request.shortname != shortname):
250
+ raise api.Exception(
251
+ status.HTTP_401_UNAUTHORIZED,
252
+ api.Error(
253
+ type="jwtauth",
254
+ code=InternalErrorCode.INVALID_INVITATION,
255
+ message="Invalid invitation or data provided",
256
+ ),
257
+ )
258
+
259
+ user = await db.load(
260
+ space_name=MANAGEMENT_SPACE,
261
+ subpath=USERS_SUBPATH,
262
+ shortname=shortname,
263
+ class_type=core.User,
264
+ user_shortname=shortname,
265
+ )
266
+ if (
267
+ request.shortname != user.shortname
268
+ and request.msisdn != user.msisdn
269
+ and request.email != user.email
270
+ ):
271
+ raise api.Exception(
272
+ status.HTTP_401_UNAUTHORIZED,
273
+ api.Error(
274
+ type="jwtauth",
275
+ code=InternalErrorCode.INVALID_INVITATION,
276
+ message="Invalid invitation or data provided",
277
+ ),
278
+ )
279
+
280
+ await db.delete_invitation(request.invitation)
281
+ await db.delete_url_shortner_by_token(request.invitation)
282
+ user_updates["force_password_change"] = True
283
+
284
+ user_updates = check_user_validation(user, data, user_updates, invitation_token)
285
+
286
+ elif request.otp is not None:
287
+ otp_code = request.otp
288
+
289
+ if bool(request.email) and bool(request.msisdn) and bool(request.shortname):
290
+ raise api.Exception(
291
+ status.HTTP_400_BAD_REQUEST,
292
+ api.Error(
293
+ type="auth",
294
+ code=InternalErrorCode.OTP_ISSUE,
295
+ message="Provide either msisdn, email or shortname, not both."
296
+ )
297
+ )
298
+
299
+ if not request.email and not request.msisdn and not request.shortname:
300
+ raise api.Exception(
301
+ status.HTTP_400_BAD_REQUEST,
302
+ api.Error(
303
+ type="auth",
304
+ code=InternalErrorCode.OTP_ISSUE,
305
+ message="Either msisdn, email or shortname must be provided."
306
+ )
307
+ )
308
+
309
+ key: str | None = None
310
+ if not shortname and identifier:
311
+ if isinstance(identifier, dict):
312
+ key, value = list(identifier.items())[0]
313
+ shortname = identifier.get("shortname") or await get_shortname_from_identifier(value=value, key=key)
314
+ else:
315
+ shortname = await get_shortname_from_identifier(value=identifier, key=key)
316
+ if not shortname:
317
+ raise api.Exception(
318
+ status.HTTP_401_UNAUTHORIZED,
319
+ api.Error(
320
+ type="auth",
321
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
322
+ message="Invalid username or password"
323
+ )
324
+ )
325
+
326
+ user = await db.load_or_none('management', '/users', shortname, core.User)
327
+ if user is None:
328
+ raise api.Exception(
329
+ status.HTTP_401_UNAUTHORIZED,
330
+ api.Error(
331
+ type="auth",
332
+ code=InternalErrorCode.SHORTNAME_DOES_NOT_EXIST,
333
+ message="User does not exist"
334
+ )
335
+ )
336
+ if user.type == UserType.mobile and user.locked_to_device and (not request.firebase_token or not user.firebase_token or request.firebase_token != user.firebase_token):
337
+ raise api.Exception(
338
+ status.HTTP_401_UNAUTHORIZED,
339
+ api.Error(type="auth", code=InternalErrorCode.USER_ACCOUNT_LOCKED, message="This account is locked to a unique device !"),
340
+ )
341
+
342
+ if user is None:
343
+ raise api.Exception(
344
+ status.HTTP_401_UNAUTHORIZED,
345
+ api.Error(
346
+ type="auth",
347
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
348
+ message="Invalid username or password"
349
+ )
350
+ )
351
+
352
+ key = ""
353
+ if request.shortname:
354
+ if user.msisdn:
355
+ key = f"users:otp:otps/{user.msisdn}"
356
+ else:
357
+ key = f"users:otp:otps/{request.msisdn or request.email or request.shortname}"
358
+ stored_otp = await db.get_otp(key)
359
+
360
+ if stored_otp is None or stored_otp != otp_code:
361
+ # await handle_failed_login_attempt(user)
362
+ raise api.Exception(
363
+ status.HTTP_401_UNAUTHORIZED,
364
+ api.Error(
365
+ type="auth",
366
+ code=InternalErrorCode.OTP_INVALID,
367
+ message="Wrong OTP"
368
+ ),
369
+ )
370
+
371
+ user = await db.load(
372
+ space_name=MANAGEMENT_SPACE,
373
+ subpath=USERS_SUBPATH,
374
+ shortname=shortname,
375
+ class_type=core.User,
376
+ user_shortname=shortname,
377
+ )
378
+
379
+ is_password_valid = None
380
+ if request.password:
381
+ is_password_valid = password_hashing.verify_password(
382
+ request.password or "", user.password or ""
383
+ )
384
+ if (
385
+ user
386
+ and user.is_active
387
+ and (is_password_valid is None or is_password_valid)
388
+ ):
389
+ await db.clear_failed_password_attempts(shortname)
390
+ await reset_failed_login_attempt(user)
391
+
392
+ if request.otp:
393
+ await db.delete_otp(key)
394
+
395
+ record = await process_user_login(user, response, {}, request.firebase_token, http_request.headers)
396
+
397
+ await plugin_manager.after_action(
398
+ core.Event(
399
+ space_name=MANAGEMENT_SPACE,
400
+ subpath=USERS_SUBPATH,
401
+ shortname=shortname,
402
+ action_type=core.ActionType.update,
403
+ resource_type=ResourceType.user,
404
+ user_shortname=shortname,
405
+ )
406
+ )
407
+ return api.Response(status=api.Status.success, records=[record])
408
+ else:
409
+ if is_password_valid is not None and not is_password_valid:
410
+ await handle_failed_login_attempt(user)
411
+ elif not user.is_active:
412
+ raise api.Exception(
413
+ status.HTTP_401_UNAUTHORIZED,
414
+ api.Error(type="auth", code=InternalErrorCode.USER_ACCOUNT_LOCKED,
415
+ message="Account has been locked."),
416
+ )
417
+
418
+ raise api.Exception(
419
+ status.HTTP_401_UNAUTHORIZED,
420
+ api.Error(
421
+ type="auth",
422
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
423
+ message="Invalid username or password"
424
+ ),
425
+ )
426
+ else:
427
+ if identifier is None:
428
+ raise api.Exception(
429
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
430
+ api.Error(
431
+ type="request", code=InternalErrorCode.INVALID_IDENTIFIER, message="Invalid identifier [2]"
432
+ ),
433
+ )
434
+
435
+ if "shortname" in identifier:
436
+ shortname = identifier["shortname"]
437
+ else:
438
+ _list = list(identifier.items())
439
+ if len(_list) != 1:
440
+ raise api.Exception(
441
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
442
+ api.Error(
443
+ type="request",
444
+ code=InternalErrorCode.INVALID_IDENTIFIER,
445
+ message="Only one valid identifier is allowed!",
446
+ ),
447
+ )
448
+
449
+ key, value = _list[0]
450
+ shortname = await get_shortname_from_identifier(key, value)
451
+ if shortname is None:
452
+ raise api.Exception(
453
+ status.HTTP_401_UNAUTHORIZED,
454
+ api.Error(
455
+ type="auth",
456
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
457
+ message="Invalid username or password [1]",
458
+ ),
459
+ )
460
+ user = await db.load(
461
+ space_name=MANAGEMENT_SPACE,
462
+ subpath=USERS_SUBPATH,
463
+ shortname=shortname,
464
+ class_type=core.User,
465
+ user_shortname=shortname,
466
+ )
467
+
468
+ is_password_valid = password_hashing.verify_password(
469
+ request.password or "", user.password or ""
470
+ )
471
+ if (
472
+ user
473
+ and user.is_active
474
+ and (
475
+ request.invitation
476
+ or is_password_valid
477
+ )
478
+ ):
479
+ if request.invitation is None and user.type == UserType.mobile and (not request.firebase_token or not user.firebase_token or request.firebase_token != user.firebase_token):
480
+ if user.locked_to_device:
481
+ raise api.Exception(
482
+ status.HTTP_401_UNAUTHORIZED,
483
+ api.Error(type="auth", code=InternalErrorCode.USER_ACCOUNT_LOCKED,
484
+ message="This account is locked to a unique device !"),
485
+ )
486
+ else:
487
+ raise api.Exception(
488
+ status.HTTP_401_UNAUTHORIZED,
489
+ api.Error(type="auth", code=InternalErrorCode.OTP_NEEDED, message="New device detected, login with otp"),
490
+ )
491
+
492
+ await db.clear_failed_password_attempts(shortname)
493
+ record = await process_user_login(user, response, user_updates, request.firebase_token, http_request.headers)
494
+ await reset_failed_login_attempt(user)
495
+
496
+ await plugin_manager.after_action(
497
+ core.Event(
498
+ space_name=MANAGEMENT_SPACE,
499
+ subpath=USERS_SUBPATH,
500
+ shortname=shortname,
501
+ action_type=core.ActionType.update,
502
+ resource_type=ResourceType.user,
503
+ user_shortname=shortname,
504
+ )
505
+ )
506
+ return api.Response(status=api.Status.success, records=[record])
507
+ # Check if user entered a wrong password
508
+ if not is_password_valid:
509
+ await handle_failed_login_attempt(user)
510
+ elif not user.is_active:
511
+ raise api.Exception(
512
+ status.HTTP_401_UNAUTHORIZED,
513
+ api.Error(type="auth", code=InternalErrorCode.USER_ACCOUNT_LOCKED,
514
+ message="Account has been locked."),
515
+ )
516
+
517
+ raise api.Exception(
518
+ status.HTTP_401_UNAUTHORIZED,
519
+ api.Error(
520
+ type="auth",
521
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
522
+ message="Invalid username or password"
523
+ ),
524
+ )
525
+ except api.Exception as e:
526
+ if e.error.type == "db":
527
+ raise api.Exception(
528
+ status.HTTP_401_UNAUTHORIZED,
529
+ api.Error(
530
+ type="auth",
531
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
532
+ message="Invalid username or password"
533
+ ),
534
+ )
535
+ else:
536
+ raise e
537
+
538
+
539
+
540
+ @router.get("/profile", response_model=api.Response, response_model_exclude_none=True)
541
+ async def get_profile(shortname=Depends(JWTBearer())) -> api.Response:
542
+
543
+ await plugin_manager.before_action(
544
+ core.Event(
545
+ space_name=MANAGEMENT_SPACE,
546
+ subpath=USERS_SUBPATH,
547
+ shortname=shortname,
548
+ action_type=core.ActionType.view,
549
+ resource_type=ResourceType.user,
550
+ user_shortname=shortname,
551
+ )
552
+ )
553
+
554
+ user = await db.load(
555
+ space_name=MANAGEMENT_SPACE,
556
+ subpath=USERS_SUBPATH,
557
+ shortname=shortname,
558
+ class_type=core.User,
559
+ user_shortname=shortname,
560
+ )
561
+ attributes: dict[str, Any] = {}
562
+ if user.email:
563
+ attributes["email"] = user.email
564
+
565
+ if user.displayname:
566
+ attributes["displayname"] = user.displayname
567
+
568
+ if user.description:
569
+ attributes["description"] = user.description
570
+
571
+ if user.msisdn:
572
+ attributes["msisdn"] = user.msisdn
573
+
574
+ if user.payload:
575
+ attributes["payload"] = user.payload
576
+ if settings.active_data_db == 'file':
577
+ path = settings.spaces_folder / MANAGEMENT_SPACE / USERS_SUBPATH
578
+ if (
579
+ user.payload
580
+ and user.payload.content_type
581
+ and user.payload.content_type == ContentType.json
582
+ and (path / str(user.payload.body)).is_file()
583
+ ):
584
+ async with aiofiles.open(
585
+ path / str(user.payload.body), "r"
586
+ ) as payload_file_content:
587
+ attributes["payload"].body = json.loads(
588
+ await payload_file_content.read()
589
+ )
590
+
591
+ attributes["type"] = user.type
592
+ attributes["language"] = user.language
593
+ attributes["is_email_verified"] = user.is_email_verified
594
+ attributes["is_msisdn_verified"] = user.is_msisdn_verified
595
+ attributes["force_password_change"] = user.force_password_change
596
+
597
+ attributes["permissions"] = await db.get_user_permissions(shortname)
598
+ attributes["roles"] = user.roles
599
+ attributes["groups"] = user.groups
600
+
601
+ attachments_path = (
602
+ settings.spaces_folder
603
+ / MANAGEMENT_SPACE
604
+ / USERS_SUBPATH
605
+ / ".dm"
606
+ / user.shortname
607
+ )
608
+ user_avatar = await db.get_entry_attachments(
609
+ subpath=f"{USERS_SUBPATH}/{user.shortname}",
610
+ attachments_path=attachments_path,
611
+ filter_shortnames=["avatar"],
612
+ )
613
+
614
+ record = core.Record(
615
+ subpath=USERS_SUBPATH,
616
+ shortname=user.shortname,
617
+ resource_type=core.ResourceType.user,
618
+ attributes=attributes,
619
+ attachments=user_avatar,
620
+ )
621
+
622
+ await plugin_manager.after_action(
623
+ core.Event(
624
+ space_name=MANAGEMENT_SPACE,
625
+ subpath=USERS_SUBPATH,
626
+ shortname=shortname,
627
+ action_type=core.ActionType.view,
628
+ resource_type=ResourceType.user,
629
+ user_shortname=shortname,
630
+ )
631
+ )
632
+
633
+ return api.Response(status=api.Status.success, records=[record])
634
+
635
+
636
+ @router.post("/profile", response_model=api.Response, response_model_exclude_none=True)
637
+ async def update_profile(
638
+ profile: core.Record, shortname=Depends(JWTBearer())
639
+ ) -> api.Response:
640
+ """Update user profile"""
641
+ profile_user = core.Meta.check_record(
642
+ record=profile, owner_shortname=profile.shortname
643
+ )
644
+
645
+ if profile_user.password and not re.match(rgx.PASSWORD, profile_user.password):
646
+ raise api.Exception(
647
+ status.HTTP_401_UNAUTHORIZED,
648
+ api.Error(
649
+ type="jwtauth",
650
+ code=InternalErrorCode.INVALID_USERNAME_AND_PASS,
651
+ message="Invalid username or password",
652
+ ),
653
+ )
654
+
655
+ await plugin_manager.before_action(
656
+ core.Event(
657
+ space_name=MANAGEMENT_SPACE,
658
+ subpath=USERS_SUBPATH,
659
+ shortname=shortname,
660
+ action_type=core.ActionType.update,
661
+ resource_type=ResourceType.user,
662
+ user_shortname=shortname,
663
+ )
664
+ )
665
+
666
+ user = await db.load(
667
+ space_name=MANAGEMENT_SPACE,
668
+ subpath=USERS_SUBPATH,
669
+ shortname=shortname,
670
+ class_type=core.User,
671
+ user_shortname=shortname,
672
+ )
673
+
674
+ old_version_flattened = flatten_dict(user.model_dump())
675
+
676
+ if profile_user.password:
677
+ if "old_password" not in profile.attributes:
678
+ raise Exception(
679
+ status.HTTP_403_FORBIDDEN,
680
+ Error(
681
+ type="auth",
682
+ code=InternalErrorCode.PASSWORD_RESET_ERROR,
683
+ message="Wrong password have been provided!",
684
+ ),
685
+ )
686
+ if not password_hashing.verify_password(
687
+ profile.attributes["old_password"], user.password or ""
688
+ ):
689
+ raise api.Exception(
690
+ status.HTTP_401_UNAUTHORIZED,
691
+ api.Error(type="request", code=InternalErrorCode.UNMATCHED_DATA,
692
+ message="mismatch with the information provided"),
693
+ )
694
+
695
+ # if "force_password_change" in profile.attributes:
696
+ # user.force_password_change = profile.attributes["force_password_change"]
697
+
698
+ user = await set_user_profile(profile, profile_user, user)
699
+
700
+ if profile.attributes.get("email") and user.email != profile.attributes.get("email") and not profile.attributes.get("email_otp"):
701
+ raise api.Exception(
702
+ status_code=status.HTTP_400_BAD_REQUEST,
703
+ error=api.Error(type="create", code=50, message="Email OTP is required to update your email"),
704
+ )
705
+
706
+ if profile.attributes.get("msisdn") and user.msisdn != profile.attributes.get("msisdn") and not profile.attributes.get("msisdn_otp"):
707
+ raise api.Exception(
708
+ status_code=status.HTTP_400_BAD_REQUEST,
709
+ error=api.Error(type="create", code=50, message="msisdn OTP is required to update your msisdn"),
710
+ )
711
+
712
+ if "confirmation" in profile.attributes:
713
+ result = await get_otp_confirmation_email_or_msisdn(profile_user)
714
+
715
+ if result is None or result != profile.attributes["confirmation"]:
716
+ raise api.Exception(
717
+ status.HTTP_422_UNPROCESSABLE_ENTITY,
718
+ api.Error(type="request", code=InternalErrorCode.INVALID_CONFIRMATION,
719
+ message="Invalid confirmation code [1]"),
720
+ )
721
+
722
+ if profile_user.email:
723
+ user.is_email_verified = True
724
+ elif profile_user.msisdn:
725
+ user.is_msisdn_verified = True
726
+ else:
727
+ await db.validate_uniqueness(MANAGEMENT_SPACE, profile, RequestType.update, shortname)
728
+ if "email" in profile.attributes and user.email != profile_user.email:
729
+ is_valid_otp = await verify_user(ConfirmOTPRequest(
730
+ email=profile.attributes.get("email"),
731
+ msisdn=None,
732
+ shortname=None,
733
+ code=profile.attributes.get("email_otp", "")
734
+ ))
735
+ if not is_valid_otp:
736
+ raise api.Exception(
737
+ status_code=status.HTTP_400_BAD_REQUEST,
738
+ error=api.Error(type="create", code=50,
739
+ message="Invalid Email OTP"),
740
+ )
741
+ user.email = profile_user.email
742
+ user.is_email_verified = True
743
+
744
+ if "msisdn" in profile.attributes and user.msisdn != profile_user.msisdn:
745
+ is_valid_otp = await verify_user(ConfirmOTPRequest(
746
+ msisdn=profile.attributes.get("msisdn"),
747
+ email=None,
748
+ shortname=None,
749
+ code=profile.attributes.get("msisdn_otp", "")
750
+ ))
751
+ if not is_valid_otp:
752
+ raise api.Exception(
753
+ status_code=status.HTTP_400_BAD_REQUEST,
754
+ error=api.Error(type="create", code=50,
755
+ message="Invalid MSISDN OTP"),
756
+ )
757
+ user.msisdn = profile_user.msisdn
758
+ user.is_msisdn_verified = True
759
+
760
+ if "payload" in profile.attributes and "body" in profile.attributes["payload"]:
761
+ await update_user_payload(profile, profile_user, user, shortname)
762
+
763
+ if user.is_active and profile.attributes.get("is_active", None) is not None:
764
+ if not profile.attributes.get("is_active"):
765
+ await db.remove_user_session(user.shortname)
766
+
767
+ history_diff = await db.update(
768
+ MANAGEMENT_SPACE,
769
+ USERS_SUBPATH,
770
+ user,
771
+ old_version_flattened,
772
+ flatten_dict(user.model_dump()),
773
+ list(profile.attributes.keys()),
774
+ shortname,
775
+ retrieve_lock_status=profile.retrieve_lock_status,
776
+ )
777
+
778
+ if settings.logout_on_pwd_change and profile_user.password:
779
+ await db.remove_user_session(shortname)
780
+
781
+ await plugin_manager.after_action(
782
+ core.Event(
783
+ space_name=MANAGEMENT_SPACE,
784
+ subpath=USERS_SUBPATH,
785
+ shortname=shortname,
786
+ action_type=core.ActionType.update,
787
+ resource_type=ResourceType.user,
788
+ user_shortname=shortname,
789
+ attributes={"history_diff": history_diff},
790
+ )
791
+ )
792
+ return api.Response(status=api.Status.success)
793
+
794
+
795
+ # cookie_options = {
796
+ # "key": "auth_token",
797
+ # "httponly": True,
798
+ # "secure": True,
799
+ # "samesite": "none",
800
+ # }
801
+ # "samesite": "lax" }
802
+ # samesite="none",
803
+ # secure=True,
804
+
805
+
806
+ @router.post(
807
+ "/logout",
808
+ response_model=api.Response,
809
+ response_model_exclude_none=True,
810
+ )
811
+ async def logout(
812
+ response: Response,
813
+ shortname=Depends(JWTBearer()),
814
+ ) -> api.Response:
815
+ response.set_cookie(value="", max_age=0, key="auth_token",
816
+ httponly=True, secure=True, samesite="none")
817
+
818
+ await db.remove_user_session(shortname)
819
+
820
+ return api.Response(status=api.Status.success, records=[])
821
+
822
+
823
+ @router.post("/delete", response_model=api.Response, response_model_exclude_none=True)
824
+ async def delete_account(shortname=Depends(JWTBearer())) -> api.Response:
825
+ """Delete own user"""
826
+ await plugin_manager.before_action(
827
+ core.Event(
828
+ space_name=MANAGEMENT_SPACE,
829
+ subpath=USERS_SUBPATH,
830
+ shortname=shortname,
831
+ action_type=core.ActionType.delete,
832
+ resource_type=ResourceType.user,
833
+ user_shortname=shortname,
834
+ )
835
+ )
836
+ user = await db.load(
837
+ space_name=MANAGEMENT_SPACE,
838
+ subpath=USERS_SUBPATH,
839
+ shortname=shortname,
840
+ class_type=core.User,
841
+ user_shortname=shortname,
842
+ )
843
+ await db.delete(MANAGEMENT_SPACE, USERS_SUBPATH, user, shortname)
844
+
845
+ await db.remove_user_session(shortname)
846
+
847
+ await plugin_manager.after_action(
848
+ core.Event(
849
+ space_name=MANAGEMENT_SPACE,
850
+ subpath=USERS_SUBPATH,
851
+ shortname=shortname,
852
+ action_type=core.ActionType.delete,
853
+ resource_type=ResourceType.user,
854
+ user_shortname=shortname,
855
+ attributes={"entry":user}
856
+ )
857
+ )
858
+
859
+ return api.Response(status=api.Status.success)
860
+
861
+
862
+ @router.post(
863
+ "/otp-request",
864
+ response_model=api.Response,
865
+ response_model_exclude_none=True,
866
+ )
867
+ async def otp_request(
868
+ user_request: SendOTPRequest,
869
+ skel_accept_language=Header(default=None)
870
+ ) -> api.Response:
871
+ """Request new OTP"""
872
+
873
+ user_identifier = user_request.check_fields()
874
+ key, value = list(user_identifier.items())[0]
875
+ user = await db.get_user_by_criteria(key, value)
876
+ if not user and not settings.is_registrable:
877
+ raise api.Exception(
878
+ status.HTTP_404_NOT_FOUND,
879
+ api.Error(
880
+ type="request",
881
+ code=InternalErrorCode.USERNAME_NOT_EXIST,
882
+ message="No user found with the provided information",
883
+ ),
884
+ )
885
+ otp_key = get_otp_key(user_identifier)
886
+ last_otp_since = await db.otp_created_since(otp_key)
887
+
888
+ if (last_otp_since and last_otp_since < settings.allow_otp_resend_after):
889
+ raise api.Exception(
890
+ status.HTTP_403_FORBIDDEN,
891
+ api.Error(
892
+ type="request",
893
+ code=InternalErrorCode.OTP_RESEND_BLOCKED,
894
+ message=f"Resend OTP is allowed after {int(settings.allow_otp_resend_after - last_otp_since)} seconds",
895
+ ),
896
+ )
897
+
898
+ if "msisdn" in user_identifier:
899
+ await send_otp(user_identifier["msisdn"], skel_accept_language or "")
900
+ elif "email" in user_identifier:
901
+ await email_send_otp(user_identifier["email"], skel_accept_language or "")
902
+
903
+ return api.Response(status=api.Status.success)
904
+
905
+ @router.post(
906
+ "/otp-request-login",
907
+ response_model=api.Response,
908
+ response_model_exclude_none=True,
909
+ )
910
+ async def otp_request_login(
911
+ user_request: SendOTPRequest,
912
+ skel_accept_language=Header(default=None),
913
+ ) -> api.Response:
914
+ """Request new OTP"""
915
+
916
+ result = user_request.check_fields()
917
+ shortname = result.get("shortname")
918
+ msisdn = result.get("msisdn")
919
+ email = result.get("email")
920
+
921
+ if bool(msisdn) ^ bool(email) ^ bool(shortname):
922
+ value = msisdn or email or shortname
923
+ if value is None:
924
+ raise api.Exception(
925
+ status.HTTP_400_BAD_REQUEST,
926
+ api.Error(
927
+ type="request",
928
+ code=InternalErrorCode.INVALID_IDENTIFIER,
929
+ message="Expected msisdn, email or shortname to be present."
930
+ )
931
+ )
932
+ else:
933
+ raise api.Exception(
934
+ status.HTTP_400_BAD_REQUEST,
935
+ api.Error(
936
+ type="auth",
937
+ code=InternalErrorCode.OTP_ISSUE,
938
+ message="one of msisdn, email or shortname must be provided"
939
+ )
940
+ )
941
+
942
+ user: str | core.User | None = None
943
+ if shortname:
944
+ user = await db.load_or_none('management', '/users', shortname, core.User)
945
+ else:
946
+ user = await db.get_user_by_criteria(
947
+ "msisdn" if msisdn else "email",
948
+ value,
949
+ )
950
+
951
+
952
+ if user is None:
953
+ logger.warning("user not found!")
954
+ return api.Response(status=api.Status.success)
955
+
956
+ if msisdn:
957
+ await send_otp(msisdn, skel_accept_language or "")
958
+ elif email:
959
+ await email_send_otp(email, skel_accept_language or "")
960
+ elif shortname and type(user) is core.User:
961
+ if user.msisdn and user.is_active:
962
+ await send_otp(user.msisdn, skel_accept_language or "")
963
+ else:
964
+ logger.warning(f"bad value for either {user.msisdn if hasattr(user, 'msisdn') else 'msisdn:N/A'} or {user.is_active}")
965
+ else:
966
+ logger.warning(f"Bad user object value {user} type {type(user)}")
967
+
968
+
969
+ return api.Response(status=api.Status.success)
970
+
971
+
972
+ @router.post(
973
+ "/password-reset-request",
974
+ response_model=api.Response,
975
+ response_model_exclude_none=True,
976
+ )
977
+ async def reset_password(user_request: PasswordResetRequest) -> api.Response:
978
+ result = user_request.check_fields()
979
+ key, value = list(result.items())[0]
980
+ shortname = await db.get_user_by_criteria(key, value)
981
+
982
+
983
+ if shortname is not None:
984
+ try:
985
+ user = await db.load(
986
+ space_name=MANAGEMENT_SPACE,
987
+ subpath=USERS_SUBPATH,
988
+ shortname=shortname,
989
+ class_type=core.User,
990
+ user_shortname=shortname,
991
+ )
992
+
993
+ reset_password_message = "Reset password via this link: {link}, This link can be used once and within the next 48 hours."
994
+
995
+ if "msisdn" in result or "shortname" in result:
996
+ if user.msisdn and ("msisdn" not in result or user.msisdn == result["msisdn"]):
997
+ token = await repository.store_user_invitation_token(
998
+ user, "SMS"
999
+ )
1000
+ if token:
1001
+ shortened_link = await repository.url_shortner(token)
1002
+ await send_sms(
1003
+ msisdn=user.msisdn,
1004
+ message=reset_password_message.replace("{link}", shortened_link),
1005
+ )
1006
+ else:
1007
+ logger.warning("token could not be generated")
1008
+ else:
1009
+ logger.warning("value mismatch")
1010
+ else:
1011
+ if user.email and user.email == result["email"]:
1012
+ token = await repository.store_user_invitation_token(
1013
+ user, "EMAIL"
1014
+ )
1015
+ if token:
1016
+ shortened_link = await repository.url_shortner(token)
1017
+ await send_email(
1018
+ from_address=settings.email_sender,
1019
+ to_address=user.email,
1020
+ message=reset_password_message.replace("{link}", shortened_link),
1021
+ subject="Reset password",
1022
+ )
1023
+ else:
1024
+ logger.warning("token could not be generated")
1025
+ else:
1026
+ logger.warning(f"email mismatch {user.email} {result['email']}")
1027
+ except Exception as e:
1028
+ logger.error(f"reset_password failed: {e}")
1029
+ else:
1030
+ logger.warning("user requested not found.")
1031
+
1032
+ return api.Response(
1033
+ status=api.Status.success ,
1034
+ attributes={"message": "If the provided email or phone number exists, a password reset link has been sent."},
1035
+ )
1036
+
1037
+
1038
+ @router.post(
1039
+ "/otp-confirm",
1040
+ response_model=api.Response,
1041
+ response_model_exclude_none=True,
1042
+ )
1043
+ async def confirm_otp(
1044
+ user_request: ConfirmOTPRequest, user=Depends(JWTBearer())
1045
+ ) -> api.Response:
1046
+ """Confirm OTP"""
1047
+
1048
+ result = user_request.check_fields()
1049
+ key = get_otp_key(result)
1050
+
1051
+ code = await db.get_otp(key)
1052
+ if not code or code != user_request.code:
1053
+ raise Exception(
1054
+ status.HTTP_400_BAD_REQUEST,
1055
+ Error(
1056
+ type="OTP",
1057
+ code=InternalErrorCode.OTP_EXPIRED,
1058
+ message="Invalid OTP",
1059
+ ),
1060
+ )
1061
+
1062
+ confirmation = gen_alphanumeric()
1063
+ data: core.Record = core.Record(
1064
+ resource_type=ResourceType.user,
1065
+ subpath="users",
1066
+ attributes={"confirmation": confirmation},
1067
+ shortname=user,
1068
+ )
1069
+
1070
+ if "msisdn" in result:
1071
+ key = f"users:otp:confirmation/msisdn/{user_request.msisdn}"
1072
+ data.attributes["msisdn"] = user_request.msisdn
1073
+ else:
1074
+ key = f"users:otp:confirmation/email/{user_request.email}"
1075
+ data.attributes["email"] = user_request.email
1076
+
1077
+ await db.save_otp(key, confirmation)
1078
+
1079
+ response = await update_profile(data, shortname=user)
1080
+
1081
+ if response.status == Status.success:
1082
+ return api.Response(status=api.Status.success, records=[])
1083
+ else:
1084
+ raise Exception(
1085
+ status.HTTP_400_BAD_REQUEST,
1086
+ Error(
1087
+ type="OTP",
1088
+ code=InternalErrorCode.OTP_FAILED,
1089
+ message=response.error.message
1090
+ if response.error
1091
+ else "Internal error",
1092
+ ),
1093
+ )
1094
+
1095
+
1096
+ @router.post("/reset", response_model=api.Response, response_model_exclude_none=True)
1097
+ async def user_reset(
1098
+ shortname: str = Body(..., pattern=rgx.SHORTNAME,
1099
+ embed=True, examples=["john_doo"]),
1100
+ logged_user=Depends(JWTBearer()),
1101
+ ) -> api.Response:
1102
+
1103
+ user = await db.load(
1104
+ space_name=MANAGEMENT_SPACE,
1105
+ subpath=USERS_SUBPATH,
1106
+ shortname=shortname,
1107
+ class_type=core.User,
1108
+ user_shortname=shortname,
1109
+ )
1110
+ if not await access_control.check_access(
1111
+ user_shortname=logged_user,
1112
+ space_name=MANAGEMENT_SPACE,
1113
+ subpath=USERS_SUBPATH,
1114
+ resource_type=ResourceType.user,
1115
+ action_type=ActionType.update,
1116
+ resource_is_active=user.is_active,
1117
+ resource_owner_shortname=user.owner_shortname,
1118
+ resource_owner_group=user.owner_group_shortname,
1119
+ entry_shortname=user.shortname,
1120
+ ):
1121
+ raise api.Exception(
1122
+ status.HTTP_401_UNAUTHORIZED,
1123
+ api.Error(
1124
+ type="request",
1125
+ code=InternalErrorCode.NOT_ALLOWED,
1126
+ message="You don't have permission to this action [20]",
1127
+ ),
1128
+ )
1129
+
1130
+ if not user.force_password_change:
1131
+ await db.internal_sys_update_model(
1132
+ space_name=MANAGEMENT_SPACE,
1133
+ subpath=USERS_SUBPATH,
1134
+ meta=user,
1135
+ updates={"force_password_change": True}
1136
+ )
1137
+
1138
+ sms_link = None
1139
+ email_link = None
1140
+
1141
+ if user.msisdn and not user.is_msisdn_verified:
1142
+ token = await repository.store_user_invitation_token(
1143
+ user, "SMS"
1144
+ )
1145
+ if token:
1146
+ sms_link = await repository.url_shortner(token)
1147
+ await send_sms(
1148
+ msisdn=user.msisdn,
1149
+ message=languages[
1150
+ user.language
1151
+ ]["reset_message"].replace(
1152
+ "{link}",
1153
+ sms_link
1154
+ ),
1155
+ )
1156
+ if user.email and not user.is_email_verified:
1157
+ token = await repository.store_user_invitation_token(
1158
+ user, "SMS"
1159
+ )
1160
+ if token:
1161
+ email_link = await repository.url_shortner(token)
1162
+ await send_email(
1163
+ from_address=settings.email_sender,
1164
+ to_address=user.email,
1165
+ message=generate_email_from_template(
1166
+ "activation",
1167
+ {
1168
+ "link": email_link,
1169
+ "name": user.displayname.en if user.displayname else "",
1170
+ "shortname": user.shortname,
1171
+ "msisdn": user.msisdn,
1172
+ },
1173
+ ),
1174
+ subject=generate_subject("activation"),
1175
+ )
1176
+
1177
+ return api.Response(
1178
+ status=api.Status.success,
1179
+ attributes={"sms_sent": bool(sms_link), "email_sent": bool(email_link)}
1180
+ )
1181
+
1182
+
1183
+ @router.post(
1184
+ "/validate_password",
1185
+ response_model=api.Response,
1186
+ response_model_exclude_none=True,
1187
+ )
1188
+ async def validate_password(
1189
+ password: str, shortname=Depends(JWTBearer())
1190
+ ) -> api.Response:
1191
+ """Validate Password"""
1192
+ user = await db.load(
1193
+ MANAGEMENT_SPACE, USERS_SUBPATH, shortname, core.User, shortname
1194
+ )
1195
+ if user and password_hashing.verify_password(password, user.password or ""):
1196
+ return api.Response(status=api.Status.success)
1197
+ else:
1198
+ raise api.Exception(
1199
+ status.HTTP_401_UNAUTHORIZED,
1200
+ api.Error(type="jwtauth", code=InternalErrorCode.PASSWORD_NOT_VALIDATED,
1201
+ message="Password dose not match"),
1202
+ )
1203
+
1204
+
1205
+
1206
+ async def process_user_login(
1207
+ user: core.User,
1208
+ response: Response,
1209
+ user_updates: dict = {},
1210
+ firebase_token: str | None = None,
1211
+ request_headers = None
1212
+ ) -> core.Record:
1213
+ access_token = await sign_jwt(
1214
+ {"shortname": user.shortname, "type": user.type}, settings.jwt_access_expires
1215
+ )
1216
+
1217
+ response.set_cookie(
1218
+ value=access_token,
1219
+ max_age=settings.jwt_access_expires,
1220
+ key="auth_token",
1221
+ httponly=True,
1222
+ secure=True,
1223
+ samesite="lax",
1224
+ )
1225
+ record = core.Record(
1226
+ resource_type=core.ResourceType.user,
1227
+ subpath="users",
1228
+ shortname=user.shortname,
1229
+ attributes={
1230
+ "access_token": access_token,
1231
+ "type": user.type,
1232
+ },
1233
+ )
1234
+ if user.displayname:
1235
+ record.attributes["displayname"] = user.displayname
1236
+
1237
+ if firebase_token:
1238
+ user_updates["firebase_token"] = firebase_token
1239
+
1240
+ if request_headers:
1241
+ headers_dict = dict(request_headers)
1242
+ headers_dict.pop("authorization", None)
1243
+ headers_dict.pop("cookie", None)
1244
+ user_updates["last_login"] = {
1245
+ "timestamp": int(datetime.now().timestamp()),
1246
+ "headers": headers_dict
1247
+ }
1248
+
1249
+ if user_updates:
1250
+ await db.internal_sys_update_model(
1251
+ space_name=MANAGEMENT_SPACE,
1252
+ subpath=USERS_SUBPATH,
1253
+ meta=user,
1254
+ updates=user_updates,
1255
+ sync_redis=False,
1256
+ )
1257
+
1258
+ return record
1259
+
1260
+
1261
+ async def reset_failed_login_attempt(user: core.User):
1262
+ await db.set_failed_password_attempt_count(user.shortname, 0)
1263
+
1264
+ async def handle_failed_login_attempt(user: core.User):
1265
+ failed_login_attempts_count: int = await db.get_failed_password_attempt_count(user.shortname)
1266
+
1267
+ # Increment the failed login attempts counter
1268
+ failed_login_attempts_count += 1
1269
+
1270
+ if failed_login_attempts_count >= settings.max_failed_login_attempts:
1271
+ # If the user reach the configured limit, lock the user by setting the is_active to false
1272
+ if user.is_active:
1273
+ await db.set_failed_password_attempt_count(user.shortname, failed_login_attempts_count)
1274
+
1275
+ logger.info(f"User {user.shortname} reached the maximum failed login attempts ({settings.max_failed_login_attempts}) disabling the user")
1276
+
1277
+ old_version_flattend = flatten_dict(user.model_dump())
1278
+ user.is_active = False
1279
+
1280
+ await db.remove_user_session(user.shortname)
1281
+
1282
+ await db.update(
1283
+ MANAGEMENT_SPACE,
1284
+ USERS_SUBPATH,
1285
+ user,
1286
+ old_version_flattend,
1287
+ flatten_dict(user.model_dump()),
1288
+ ["is_active"],
1289
+ user.shortname,
1290
+ )
1291
+
1292
+ await plugin_manager.after_action(
1293
+ core.Event(
1294
+ space_name=MANAGEMENT_SPACE,
1295
+ subpath=USERS_SUBPATH,
1296
+ shortname=user.shortname,
1297
+ action_type=core.ActionType.update,
1298
+ resource_type=ResourceType.user,
1299
+ user_shortname=user.shortname,
1300
+ )
1301
+ )
1302
+
1303
+ raise api.Exception(
1304
+ status.HTTP_401_UNAUTHORIZED,
1305
+ api.Error(type="auth", code=InternalErrorCode.USER_ACCOUNT_LOCKED,
1306
+ message="Account has been locked due to too many failed login attempts."),
1307
+ )
1308
+ else:
1309
+ # Count until the failed attempts reach the limit
1310
+ await db.set_failed_password_attempt_count(user.shortname, failed_login_attempts_count)
1311
+
1312
+
1313
+ if settings.social_login_allowed:
1314
+
1315
+ @router.get("/google/callback")
1316
+ async def google_profile(
1317
+ request: Request,
1318
+ response: Response,
1319
+ google_sso: GoogleSSO = Depends(get_google_sso),
1320
+ ):
1321
+ async with google_sso:
1322
+ user_model = await social_login(request, google_sso, "google")
1323
+
1324
+ record = await process_user_login(
1325
+ user=user_model,
1326
+ response=response,
1327
+ request_headers=request.headers
1328
+ )
1329
+ return api.Response(status=api.Status.success, records=[record])
1330
+
1331
+ @router.get("/facebook/callback")
1332
+ async def facebook_login(
1333
+ request: Request,
1334
+ response: Response,
1335
+ facebook_sso: FacebookSSO = Depends(get_facebook_sso),
1336
+ ):
1337
+ async with facebook_sso:
1338
+ user_model = await social_login(request, facebook_sso, "facebook")
1339
+
1340
+ record = await process_user_login(
1341
+ user=user_model,
1342
+ response=response,
1343
+ request_headers=request.headers
1344
+ )
1345
+ return api.Response(status=api.Status.success, records=[record])
1346
+
1347
+ @router.get("/apple/callback")
1348
+ async def apple_login(
1349
+ request: Request,
1350
+ response: Response,
1351
+ apple_sso: SSOBase = Depends(get_apple_sso),
1352
+ ):
1353
+ async with apple_sso:
1354
+ user_model = await social_login(request, apple_sso, "apple")
1355
+
1356
+ record = await process_user_login(
1357
+ user=user_model,
1358
+ response=response,
1359
+ request_headers=request.headers
1360
+ )
1361
+ return api.Response(status=api.Status.success, records=[record])
1362
+
1363
+ async def social_login(request: Request, sso: SSOBase, provider: str) -> core.User:
1364
+ provider_user: OpenID | None = await sso.verify_and_process(request)
1365
+ if not provider_user or not provider_user.id:
1366
+ raise api.Exception(
1367
+ status_code=status.HTTP_400_BAD_REQUEST,
1368
+ error=api.Error(type="auth", code=InternalErrorCode.INVALID_DATA, message="Misconfigured provider"),
1369
+ )
1370
+
1371
+ user: core.User | None = await db.load_or_none(
1372
+ space_name=MANAGEMENT_SPACE,
1373
+ subpath=USERS_SUBPATH,
1374
+ shortname=f"{provider}_{provider_user.id}",
1375
+ class_type=core.User
1376
+ )
1377
+
1378
+ if not user:
1379
+ msisdn = await get_provider_phone_number(sso, provider)
1380
+ user = core.User(
1381
+ shortname=f"{provider}_{provider_user.id}",
1382
+ owner_shortname="dmart",
1383
+ displayname=core.Translation(
1384
+ en=f"{provider_user.first_name} {provider_user.last_name}"
1385
+ ),
1386
+ email=provider_user.email,
1387
+ is_active=True,
1388
+ is_email_verified=True,
1389
+ social_avatar_url=provider_user.picture,
1390
+ )
1391
+ if msisdn and re.match(rgx.MSISDN, msisdn):
1392
+ user.msisdn = msisdn
1393
+ user.is_msisdn_verified = True
1394
+ setattr(user, f"{provider}_id", provider_user.id)
1395
+
1396
+ await db.create(MANAGEMENT_SPACE, USERS_SUBPATH, user)
1397
+
1398
+ return user
1399
+
1400
+ async def get_provider_phone_number(sso: SSOBase, provider: str,) -> None | str:
1401
+ if provider == "google":
1402
+ async with AsyncRequest() as session:
1403
+ res = await session.get(
1404
+ url="https://people.googleapis.com/v1/people/me",
1405
+ headers={"Authorization": f"Bearer {sso.access_token}"},
1406
+ params={"personFields": "phoneNumbers"}
1407
+ )
1408
+ if res.status != 200:
1409
+ return None
1410
+ data = await res.json()
1411
+ phone_number = data["phoneNumbers"][0].get("value") if len(data.get("phoneNumbers", [])) > 0 else None
1412
+ return str(phone_number).replace(" ", "")
1413
+ return None