core-framework 0.3.0__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 (222) hide show
  1. core_framework/__init__.py +0 -0
  2. core_framework/alembic/comment/alembic/README +1 -0
  3. core_framework/alembic/comment/alembic/env.py +72 -0
  4. core_framework/alembic/comment/alembic/script.py.mako +28 -0
  5. core_framework/alembic/comment/alembic/versions/30334fd1347b_init.py +59 -0
  6. core_framework/alembic/comment/alembic/versions/a2b3c4d5e6f7_improve_comment_indexes.py +54 -0
  7. core_framework/alembic/comment/alembic/versions/bcc8e00cfc8b_add_extra_tables.py +64 -0
  8. core_framework/alembic/comment/alembic/versions/d1e2f3a4b5c6_add_comment_stats_dirty_table.py +29 -0
  9. core_framework/alembic/comment/alembic/versions/e3f4a5b6c7d8_cascade_delete_comment_descendants.py +49 -0
  10. core_framework/alembic/comment/alembic/versions/f7e6d5c4b3a2_comments_path_to_ltree.py +60 -0
  11. core_framework/alembic/comment/alembic.ini +52 -0
  12. core_framework/alembic/extension/alembic/README +1 -0
  13. core_framework/alembic/extension/alembic/env.py +98 -0
  14. core_framework/alembic/extension/alembic/script.py.mako +28 -0
  15. core_framework/alembic/extension/alembic/versions/0389226049cb_add_pg_trgm_extension.py +25 -0
  16. core_framework/alembic/extension/alembic/versions/5dc58b016cf5_add_citext_extension.py +25 -0
  17. core_framework/alembic/extension/alembic/versions/b0ba0d8a284e_add_pg_stat_statements_extension.py +25 -0
  18. core_framework/alembic/extension/alembic/versions/c9d0e1f2a3b4_add_ltree_extension.py +25 -0
  19. core_framework/alembic/extension/alembic.ini +147 -0
  20. core_framework/alembic/moderation/alembic/README +1 -0
  21. core_framework/alembic/moderation/alembic/env.py +98 -0
  22. core_framework/alembic/moderation/alembic/script.py.mako +28 -0
  23. core_framework/alembic/moderation/alembic/versions/085ba9021850_add_category_to_user_restrictions.py +93 -0
  24. core_framework/alembic/moderation/alembic/versions/5f9e4fc14a41_create_moderation_appeals_table.py +69 -0
  25. core_framework/alembic/moderation/alembic/versions/63e37381e73b_add_user_reports_table.py +33 -0
  26. core_framework/alembic/moderation/alembic/versions/6a2ae31b7ac6_add_moderation_actions_table.py +34 -0
  27. core_framework/alembic/moderation/alembic/versions/716aa1735c03_improve_indexes.py +36 -0
  28. core_framework/alembic/moderation/alembic/versions/7d243ddbfde1_add_post_reports_table.py +35 -0
  29. core_framework/alembic/moderation/alembic/versions/8fba1f72dd46_add_indexes.py +64 -0
  30. core_framework/alembic/moderation/alembic/versions/95cc35a51984_update_restriction_history.py +91 -0
  31. core_framework/alembic/moderation/alembic/versions/9ad79d0af730_add_unique_constraint_user_reports_.py +28 -0
  32. core_framework/alembic/moderation/alembic/versions/a5e569f5df1a_create_user_restrictions_table.py +38 -0
  33. core_framework/alembic/moderation/alembic/versions/b2c3d4e5f6a7_add_indexes.py +42 -0
  34. core_framework/alembic/moderation/alembic/versions/c3d4e5f6a7b8_improve_report_indexes.py +48 -0
  35. core_framework/alembic/moderation/alembic/versions/d4af74643ff5_add_internal_notes_table.py +38 -0
  36. core_framework/alembic/moderation/alembic/versions/db20f2fb7390_add_comment_reports_table.py +35 -0
  37. core_framework/alembic/moderation/alembic/versions/e66226952ea6_add_report_category_to_user_reports_.py +54 -0
  38. core_framework/alembic/moderation/alembic/versions/f5e8cb275c30_enforce_1_pending_appeal.py +29 -0
  39. core_framework/alembic/moderation/alembic/versions/fe1faad2832d_create_restriction_history_table.py +69 -0
  40. core_framework/alembic/moderation/alembic.ini +147 -0
  41. core_framework/alembic/post/alembic/README +1 -0
  42. core_framework/alembic/post/alembic/env.py +97 -0
  43. core_framework/alembic/post/alembic/script.py.mako +28 -0
  44. core_framework/alembic/post/alembic/versions/51542673f5c8_add_tables_for_muted_banned_users.py +41 -0
  45. core_framework/alembic/post/alembic/versions/5beeeae40a4a_add_post_views_table.py +45 -0
  46. core_framework/alembic/post/alembic/versions/620971509a8b_init.py +55 -0
  47. core_framework/alembic/post/alembic/versions/a1b2c3d4e5f6_add_indexes.py +44 -0
  48. core_framework/alembic/post/alembic/versions/c1d2e3f4a5b6_add_post_hashtags_table.py +36 -0
  49. core_framework/alembic/post/alembic/versions/e56723f2afff_add_post_stats_table.py +39 -0
  50. core_framework/alembic/post/alembic/versions/fbc723ac58cc_add_post_likes_table.py +32 -0
  51. core_framework/alembic/post/alembic.ini +149 -0
  52. core_framework/alembic/user/alembic/README +1 -0
  53. core_framework/alembic/user/alembic/env.py +98 -0
  54. core_framework/alembic/user/alembic/script.py.mako +28 -0
  55. core_framework/alembic/user/alembic/versions/1a8bb99726ed_remove_avatar_id_from_users.py +81 -0
  56. core_framework/alembic/user/alembic/versions/2ccacf455941_improve_indexes.py +34 -0
  57. core_framework/alembic/user/alembic/versions/47f47ce2110e_create_user_deletions_table.py +31 -0
  58. core_framework/alembic/user/alembic/versions/5976db3f0175_drop_user_states.py +26 -0
  59. core_framework/alembic/user/alembic/versions/62417002cf32_add_indexes.py +46 -0
  60. core_framework/alembic/user/alembic/versions/6f7ccf3c226b_refactor_user_login_events.py +66 -0
  61. core_framework/alembic/user/alembic/versions/73432817015b_add_user_preferences_table.py +33 -0
  62. core_framework/alembic/user/alembic/versions/765bc01a7a59_create_user_blocks_table.py +33 -0
  63. core_framework/alembic/user/alembic/versions/7a56631f9927_create_user_login_events_table.py +49 -0
  64. core_framework/alembic/user/alembic/versions/831611e589bc_create_user_state.py +31 -0
  65. core_framework/alembic/user/alembic/versions/83c98ab2a779_add_user_profiles_table.py +88 -0
  66. core_framework/alembic/user/alembic/versions/8a94362cad6d_create_user_role.py +31 -0
  67. core_framework/alembic/user/alembic/versions/94b973923895_add_user_change_history_table.py +97 -0
  68. core_framework/alembic/user/alembic/versions/cbc0f4efe84f_add_avatar_id_column_to_users_table.py +31 -0
  69. core_framework/alembic/user/alembic/versions/d8b98ac6b073_add_index_for_get_admin_user_ids_query.py +29 -0
  70. core_framework/alembic/user/alembic/versions/ddb70cc09d16_create_user_states_table.py +34 -0
  71. core_framework/alembic/user/alembic/versions/f9ba10815ecd_add_users_table.py +33 -0
  72. core_framework/alembic/user/alembic.ini +147 -0
  73. core_framework/api/__init__.py +0 -0
  74. core_framework/api/admin/__init__.py +0 -0
  75. core_framework/api/admin/comments/router.py +69 -0
  76. core_framework/api/admin/comments/schemas.py +53 -0
  77. core_framework/api/admin/moderation/__init__.py +0 -0
  78. core_framework/api/admin/moderation/router.py +205 -0
  79. core_framework/api/admin/moderation/schemas.py +110 -0
  80. core_framework/api/admin/posts/router.py +62 -0
  81. core_framework/api/admin/posts/schemas.py +29 -0
  82. core_framework/api/admin/router.py +17 -0
  83. core_framework/api/admin/users/__init__.py +0 -0
  84. core_framework/api/admin/users/router.py +181 -0
  85. core_framework/api/admin/users/schemas.py +137 -0
  86. core_framework/api/auth/__init__.py +0 -0
  87. core_framework/api/auth/router.py +21 -0
  88. core_framework/api/auth/schemas.py +28 -0
  89. core_framework/api/comments/authenticated/router.py +126 -0
  90. core_framework/api/comments/authenticated/schemas.py +27 -0
  91. core_framework/api/comments/public/router.py +103 -0
  92. core_framework/api/comments/public/schemas.py +36 -0
  93. core_framework/api/comments/router.py +9 -0
  94. core_framework/api/comments/schemas.py +17 -0
  95. core_framework/api/dependencies.py +168 -0
  96. core_framework/api/events/router.py +39 -0
  97. core_framework/api/events/schemas.py +20 -0
  98. core_framework/api/posts/authenticated/router.py +83 -0
  99. core_framework/api/posts/authenticated/schemas.py +37 -0
  100. core_framework/api/posts/public/router.py +100 -0
  101. core_framework/api/posts/public/schemas.py +39 -0
  102. core_framework/api/posts/router.py +9 -0
  103. core_framework/api/posts/schemas.py +39 -0
  104. core_framework/api/router.py +19 -0
  105. core_framework/api/schemas.py +9 -0
  106. core_framework/api/system/__init__.py +0 -0
  107. core_framework/api/system/router.py +108 -0
  108. core_framework/api/users/__init__.py +0 -0
  109. core_framework/api/users/authenticated/__init__.py +0 -0
  110. core_framework/api/users/authenticated/router.py +244 -0
  111. core_framework/api/users/authenticated/schemas.py +81 -0
  112. core_framework/api/users/public/__init__.py +0 -0
  113. core_framework/api/users/public/router.py +25 -0
  114. core_framework/api/users/public/schemas.py +7 -0
  115. core_framework/api/users/router.py +9 -0
  116. core_framework/api/users/shared/schemas.py +174 -0
  117. core_framework/application/__init__.py +0 -0
  118. core_framework/application/auth/__init__.py +0 -0
  119. core_framework/application/auth/access_service.py +26 -0
  120. core_framework/application/auth/auth_service.py +10 -0
  121. core_framework/application/auth/models.py +10 -0
  122. core_framework/application/bootstrap.py +19 -0
  123. core_framework/application/comments/admin_service.py +236 -0
  124. core_framework/application/comments/aggregation_service.py +28 -0
  125. core_framework/application/comments/authenticated_service.py +89 -0
  126. core_framework/application/comments/public_service.py +218 -0
  127. core_framework/application/events/README.md +26 -0
  128. core_framework/application/events/event_service.py +51 -0
  129. core_framework/application/events/event_token.py +46 -0
  130. core_framework/application/events/models.py +9 -0
  131. core_framework/application/moderation/__init__.py +0 -0
  132. core_framework/application/moderation/appeal_service.py +98 -0
  133. core_framework/application/moderation/moderator_service.py +46 -0
  134. core_framework/application/moderation/report_service.py +180 -0
  135. core_framework/application/moderation/scheduled_service.py +5 -0
  136. core_framework/application/moderation/user_service.py +180 -0
  137. core_framework/application/posts/admin_service.py +104 -0
  138. core_framework/application/posts/aggregation_service.py +28 -0
  139. core_framework/application/posts/authenticated_service.py +72 -0
  140. core_framework/application/posts/public_service.py +197 -0
  141. core_framework/application/shared/__init__.py +0 -0
  142. core_framework/application/shared/enums.py +16 -0
  143. core_framework/application/shared/exceptions.py +16 -0
  144. core_framework/application/shared/user_agent.py +24 -0
  145. core_framework/application/users/__init__.py +0 -0
  146. core_framework/application/users/admin_service.py +298 -0
  147. core_framework/application/users/authenticated_service.py +179 -0
  148. core_framework/application/users/public_service.py +7 -0
  149. core_framework/application/users/scheduled_service.py +5 -0
  150. core_framework/bundled_alembic.py +57 -0
  151. core_framework/core/__init__.py +37 -0
  152. core_framework/core/cache.py +234 -0
  153. core_framework/core/context.py +14 -0
  154. core_framework/core/database.py +111 -0
  155. core_framework/core/exception_handlers/__init__.py +3 -0
  156. core_framework/core/exception_handlers/comment.py +99 -0
  157. core_framework/core/exception_handlers/common.py +5 -0
  158. core_framework/core/exception_handlers/moderation.py +104 -0
  159. core_framework/core/exception_handlers/post.py +54 -0
  160. core_framework/core/exception_handlers/setup.py +80 -0
  161. core_framework/core/exception_handlers/user.py +72 -0
  162. core_framework/core/http_client.py +64 -0
  163. core_framework/core/logging.py +99 -0
  164. core_framework/core/middleware.py +64 -0
  165. core_framework/core/observability.py +36 -0
  166. core_framework/core/pagination.py +203 -0
  167. core_framework/core/redis.py +135 -0
  168. core_framework/core/runtime.py +66 -0
  169. core_framework/core/settings.py +189 -0
  170. core_framework/domains/__init__.py +0 -0
  171. core_framework/domains/comment/README.md +243 -0
  172. core_framework/domains/comment/__init__.py +25 -0
  173. core_framework/domains/comment/constants.py +3 -0
  174. core_framework/domains/comment/dependencies.py +29 -0
  175. core_framework/domains/comment/enums.py +11 -0
  176. core_framework/domains/comment/exceptions.py +31 -0
  177. core_framework/domains/comment/models.py +54 -0
  178. core_framework/domains/comment/repository.py +947 -0
  179. core_framework/domains/comment/service.py +259 -0
  180. core_framework/domains/moderation/README.md +138 -0
  181. core_framework/domains/moderation/__init__.py +47 -0
  182. core_framework/domains/moderation/dependencies.py +29 -0
  183. core_framework/domains/moderation/enums.py +62 -0
  184. core_framework/domains/moderation/exceptions.py +31 -0
  185. core_framework/domains/moderation/models.py +94 -0
  186. core_framework/domains/moderation/repository.py +828 -0
  187. core_framework/domains/moderation/service.py +334 -0
  188. core_framework/domains/post/README.md +182 -0
  189. core_framework/domains/post/__init__.py +22 -0
  190. core_framework/domains/post/constants.py +3 -0
  191. core_framework/domains/post/dependencies.py +29 -0
  192. core_framework/domains/post/enums.py +18 -0
  193. core_framework/domains/post/exceptions.py +21 -0
  194. core_framework/domains/post/models.py +53 -0
  195. core_framework/domains/post/repository.py +791 -0
  196. core_framework/domains/post/service.py +204 -0
  197. core_framework/domains/user/README.md +74 -0
  198. core_framework/domains/user/__init__.py +39 -0
  199. core_framework/domains/user/constants.py +8 -0
  200. core_framework/domains/user/dependencies.py +29 -0
  201. core_framework/domains/user/enums.py +19 -0
  202. core_framework/domains/user/exceptions.py +31 -0
  203. core_framework/domains/user/models.py +124 -0
  204. core_framework/domains/user/repository.py +612 -0
  205. core_framework/domains/user/service.py +257 -0
  206. core_framework/domains/user/utils.py +182 -0
  207. core_framework/main.py +104 -0
  208. core_framework/worker/__init__.py +0 -0
  209. core_framework/worker/main.py +56 -0
  210. core_framework/worker/schedules/__init__.py +35 -0
  211. core_framework/worker/schedules/schedule_aggregate_comment_stats.py +32 -0
  212. core_framework/worker/schedules/schedule_aggregate_post_view_counts.py +28 -0
  213. core_framework/worker/schedules/schedule_expired_account_deletions.py +24 -0
  214. core_framework/worker/schedules/schedule_expired_mute_lifts.py +24 -0
  215. core_framework/worker/tasks/__init__.py +11 -0
  216. core_framework/worker/tasks/process_account_deletion.py +13 -0
  217. core_framework/worker/tasks/process_aggregate_comment_stats.py +19 -0
  218. core_framework/worker/tasks/process_aggregate_post_stats.py +12 -0
  219. core_framework/worker/tasks/process_mute_lift.py +13 -0
  220. core_framework-0.3.0.dist-info/METADATA +22 -0
  221. core_framework-0.3.0.dist-info/RECORD +222 -0
  222. core_framework-0.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,72 @@
1
+ from typing import Any
2
+
3
+ import core_framework.domains.moderation.dependencies as moderation_deps
4
+ import core_framework.domains.post.dependencies as post_deps
5
+ from core_framework.domains.moderation import ReportCategory
6
+ from core_framework.domains.post import PostStatus, PostVisibility
7
+
8
+
9
+ async def create_post(
10
+ *,
11
+ author_id: str,
12
+ content: str,
13
+ visibility: PostVisibility,
14
+ hashtags: list[str],
15
+ ) -> None:
16
+ await post_deps.post_service.add_post(
17
+ author_id=author_id,
18
+ content=content,
19
+ visibility=visibility,
20
+ hashtags=hashtags,
21
+ )
22
+
23
+
24
+ async def edit_post(
25
+ *,
26
+ post_id: str,
27
+ author_id: str,
28
+ validated_update_request: dict[str, Any],
29
+ ) -> None:
30
+ await post_deps.post_service.edit_post(
31
+ post_id=post_id,
32
+ author_id=author_id,
33
+ validated_update_request=validated_update_request,
34
+ )
35
+
36
+
37
+ async def delete_post(*, post_id: str, author_id: str) -> None:
38
+ await post_deps.post_service.set_post_status_for_author(
39
+ post_id=post_id,
40
+ author_id=author_id,
41
+ status=PostStatus.DELETED,
42
+ )
43
+
44
+
45
+ async def like_post(*, post_id: str, user_id: str) -> None:
46
+ await post_deps.post_service.add_post_like(post_id=post_id, liker_id=user_id)
47
+
48
+
49
+ async def unlike_post(*, post_id: str, user_id: str) -> None:
50
+ await post_deps.post_service.remove_post_like(post_id=post_id, liker_id=user_id)
51
+
52
+
53
+ async def add_post_report(
54
+ *,
55
+ reporter_id: str,
56
+ post_id: str,
57
+ category: ReportCategory,
58
+ reason: str,
59
+ ) -> None:
60
+ await moderation_deps.moderation_service.add_post_report(
61
+ reporter_id=reporter_id,
62
+ target_id=post_id,
63
+ category=category,
64
+ reason=reason,
65
+ )
66
+
67
+
68
+ async def remove_post_report(*, reporter_id: str, post_id: str) -> None:
69
+ await moderation_deps.moderation_service.remove_post_report_by_reporter(
70
+ reporter_id=reporter_id,
71
+ target_id=post_id,
72
+ )
@@ -0,0 +1,197 @@
1
+ from asyncio import TaskGroup
2
+ from collections import defaultdict
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ import core_framework.domains.moderation.dependencies as moderation_deps
7
+ import core_framework.domains.post.dependencies as post_deps
8
+ import core_framework.domains.user.dependencies as user_deps
9
+ from core_framework.domains.post import Post, PostStats
10
+ from core_framework.domains.user import REDACTED_AUTHOR_ID, UserIdentity
11
+
12
+
13
+ async def _retrieve_viewer_context_ids(
14
+ *,
15
+ viewer_id: str | None,
16
+ post_ids: set[str],
17
+ ) -> tuple[frozenset[str] | None, frozenset[str] | None]:
18
+ if viewer_id is None or not post_ids:
19
+ return None, None
20
+ async with TaskGroup() as tg:
21
+ liked_task = tg.create_task(
22
+ post_deps.post_service.retrieve_post_ids_liked_by_user(
23
+ liker_id=viewer_id,
24
+ post_ids=post_ids,
25
+ )
26
+ )
27
+ reported_task = tg.create_task(
28
+ moderation_deps.moderation_service.retrieve_post_ids_reported_by_user(
29
+ reporter_id=viewer_id,
30
+ post_ids=post_ids,
31
+ )
32
+ )
33
+ return liked_task.result(), reported_task.result()
34
+
35
+
36
+ def _serialize_post(
37
+ *,
38
+ post: Post,
39
+ post_stats_mapping: defaultdict[str, PostStats],
40
+ hashtags_mapping: defaultdict[str, list[str]],
41
+ user_identity_mapping: defaultdict[str, UserIdentity],
42
+ viewer_id: str | None = None,
43
+ liked_ids: frozenset[str] | None = None,
44
+ reported_ids: frozenset[str] | None = None,
45
+ ) -> dict[str, Any]:
46
+ stats = post_stats_mapping[post.id]
47
+ hashtags = hashtags_mapping[post.id]
48
+ author_identity = user_identity_mapping[post.author_id]
49
+ viewer_context = None
50
+ if liked_ids is not None and reported_ids is not None:
51
+ viewer_context = {
52
+ "is_liked": post.id in liked_ids,
53
+ "is_reported": post.id in reported_ids,
54
+ }
55
+ author_context = None
56
+ if viewer_id is not None and viewer_id == post.author_id:
57
+ author_context = {
58
+ "can_edit": post.edited_count < post_deps.post_service.MAX_EDIT_COUNT,
59
+ "can_delete": True,
60
+ }
61
+ return {
62
+ "id": post.id,
63
+ "author": {
64
+ "user_id": post.author_id,
65
+ "username": author_identity.username,
66
+ "display_name": author_identity.display_name,
67
+ },
68
+ "content": post.content,
69
+ "hashtags": hashtags,
70
+ "visibility": post.visibility,
71
+ "stats": {
72
+ "like_count": stats.like_count,
73
+ "comment_count": stats.comment_count,
74
+ "view_count": stats.view_count,
75
+ },
76
+ "engagement_allowed": post.author_id != REDACTED_AUTHOR_ID,
77
+ "edited_at": post.edited_at,
78
+ "created_at": post.created_at,
79
+ "viewer_context": viewer_context,
80
+ "author_context": author_context,
81
+ }
82
+
83
+
84
+ async def retrieve_post_by_id(*, post_id: str, viewer_id: str | None) -> dict[str, Any] | None:
85
+ post = await post_deps.post_service.retrieve_post_by_id(post_id=post_id, viewer_id=viewer_id)
86
+ if post is None:
87
+ return None
88
+ post_ids = {post.id}
89
+ async with TaskGroup() as tg:
90
+ liked_reported_task = tg.create_task(_retrieve_viewer_context_ids(viewer_id=viewer_id, post_ids=post_ids))
91
+ stats_task = tg.create_task(post_deps.post_service.retrieve_post_stats_mapping(post_ids=post_ids))
92
+ hashtags_task = tg.create_task(post_deps.post_service.retrieve_hashtags_mapping(post_ids=post_ids))
93
+ identity_task = tg.create_task(user_deps.user_service.retrieve_user_identity_mapping(user_ids={post.author_id}))
94
+ liked_ids, reported_ids = liked_reported_task.result()
95
+ return _serialize_post(
96
+ post=post,
97
+ post_stats_mapping=stats_task.result(),
98
+ hashtags_mapping=hashtags_task.result(),
99
+ user_identity_mapping=identity_task.result(),
100
+ viewer_id=viewer_id,
101
+ liked_ids=liked_ids,
102
+ reported_ids=reported_ids,
103
+ )
104
+
105
+
106
+ async def retrieve_posts(*, viewer_id: str | None, cursor: datetime, limit: int) -> list[dict[str, Any]]:
107
+ posts = await post_deps.post_service.retrieve_posts(viewer_id=viewer_id, cursor=cursor, limit=limit)
108
+ post_ids = {post.id for post in posts}
109
+ author_ids = {post.author_id for post in posts}
110
+ async with TaskGroup() as tg:
111
+ liked_reported_task = tg.create_task(_retrieve_viewer_context_ids(viewer_id=viewer_id, post_ids=post_ids))
112
+ stats_task = tg.create_task(post_deps.post_service.retrieve_post_stats_mapping(post_ids=post_ids))
113
+ hashtags_task = tg.create_task(post_deps.post_service.retrieve_hashtags_mapping(post_ids=post_ids))
114
+ identity_task = tg.create_task(user_deps.user_service.retrieve_user_identity_mapping(user_ids=author_ids))
115
+ liked_ids, reported_ids = liked_reported_task.result()
116
+ return [
117
+ _serialize_post(
118
+ post=post,
119
+ post_stats_mapping=stats_task.result(),
120
+ hashtags_mapping=hashtags_task.result(),
121
+ user_identity_mapping=identity_task.result(),
122
+ viewer_id=viewer_id,
123
+ liked_ids=liked_ids,
124
+ reported_ids=reported_ids,
125
+ )
126
+ for post in posts
127
+ ]
128
+
129
+
130
+ async def retrieve_posts_by_user_id(
131
+ *,
132
+ user_id: str,
133
+ viewer_id: str | None,
134
+ cursor: datetime,
135
+ limit: int,
136
+ ) -> list[dict[str, Any]]:
137
+ posts = await post_deps.post_service.retrieve_posts_by_user_id(
138
+ user_id=user_id,
139
+ cursor=cursor,
140
+ limit=limit,
141
+ viewer_id=viewer_id,
142
+ )
143
+ post_ids = {post.id for post in posts}
144
+ author_ids = {post.author_id for post in posts}
145
+ async with TaskGroup() as tg:
146
+ liked_reported_task = tg.create_task(_retrieve_viewer_context_ids(viewer_id=viewer_id, post_ids=post_ids))
147
+ stats_task = tg.create_task(post_deps.post_service.retrieve_post_stats_mapping(post_ids=post_ids))
148
+ hashtags_task = tg.create_task(post_deps.post_service.retrieve_hashtags_mapping(post_ids=post_ids))
149
+ identity_task = tg.create_task(user_deps.user_service.retrieve_user_identity_mapping(user_ids=author_ids))
150
+ liked_ids, reported_ids = liked_reported_task.result()
151
+ return [
152
+ _serialize_post(
153
+ post=post,
154
+ post_stats_mapping=stats_task.result(),
155
+ hashtags_mapping=hashtags_task.result(),
156
+ user_identity_mapping=identity_task.result(),
157
+ viewer_id=viewer_id,
158
+ liked_ids=liked_ids,
159
+ reported_ids=reported_ids,
160
+ )
161
+ for post in posts
162
+ ]
163
+
164
+
165
+ async def retrieve_posts_by_hashtag(
166
+ *,
167
+ hashtag: str,
168
+ viewer_id: str | None,
169
+ cursor: datetime,
170
+ limit: int,
171
+ ) -> list[dict[str, Any]]:
172
+ posts = await post_deps.post_service.retrieve_posts_by_hashtag(
173
+ hashtag=hashtag,
174
+ cursor=cursor,
175
+ limit=limit,
176
+ viewer_id=viewer_id,
177
+ )
178
+ post_ids = {post.id for post in posts}
179
+ author_ids = {post.author_id for post in posts}
180
+ async with TaskGroup() as tg:
181
+ liked_reported_task = tg.create_task(_retrieve_viewer_context_ids(viewer_id=viewer_id, post_ids=post_ids))
182
+ stats_task = tg.create_task(post_deps.post_service.retrieve_post_stats_mapping(post_ids=post_ids))
183
+ hashtags_task = tg.create_task(post_deps.post_service.retrieve_hashtags_mapping(post_ids=post_ids))
184
+ identity_task = tg.create_task(user_deps.user_service.retrieve_user_identity_mapping(user_ids=author_ids))
185
+ liked_ids, reported_ids = liked_reported_task.result()
186
+ return [
187
+ _serialize_post(
188
+ post=post,
189
+ post_stats_mapping=stats_task.result(),
190
+ hashtags_mapping=hashtags_task.result(),
191
+ user_identity_mapping=identity_task.result(),
192
+ viewer_id=viewer_id,
193
+ liked_ids=liked_ids,
194
+ reported_ids=reported_ids,
195
+ )
196
+ for post in posts
197
+ ]
File without changes
@@ -0,0 +1,16 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class EventType(StrEnum):
5
+ LOGIN = "login"
6
+ VIEW = "view"
7
+
8
+
9
+ class SubjectType(StrEnum):
10
+ POST = "post"
11
+ USER = "user"
12
+
13
+
14
+ class RedisKeys(StrEnum):
15
+ TAKEN_USERNAMES = "taken_usernames"
16
+ USER_DETAIL = "user_detail"
@@ -0,0 +1,16 @@
1
+ class UnauthorizedException(Exception):
2
+ def __init__(self, message: str = "Unauthorized"):
3
+ self.message = message
4
+ super().__init__(self.message)
5
+
6
+
7
+ class ForbiddenException(Exception):
8
+ def __init__(self, message: str = "Forbidden"):
9
+ self.message = message
10
+ super().__init__(self.message)
11
+
12
+
13
+ class UserNotFoundException(Exception):
14
+ def __init__(self, message: str = "User not found"):
15
+ self.message = message
16
+ super().__init__(self.message)
@@ -0,0 +1,24 @@
1
+ from typing import Final
2
+
3
+ from device_detector import DeviceDetector
4
+
5
+ MAX_METADATA_STRING_LENGTH: Final[int] = 32
6
+
7
+
8
+ def parse_user_agent(user_agent: str | None) -> dict[str, str]:
9
+ result: dict[str, str] = {}
10
+ if not user_agent or not user_agent.strip():
11
+ return result
12
+ dd = DeviceDetector(user_agent).parse()
13
+ device = dd.device_type()
14
+ if device is not None:
15
+ raw = str(device)
16
+ if raw:
17
+ result["client_type"] = raw[:MAX_METADATA_STRING_LENGTH]
18
+ client_name = dd.client_name()
19
+ if client_name:
20
+ result["browser_name"] = client_name[:MAX_METADATA_STRING_LENGTH]
21
+ os_name = dd.os_name()
22
+ if os_name:
23
+ result["os_name"] = os_name[:MAX_METADATA_STRING_LENGTH]
24
+ return result
File without changes
@@ -0,0 +1,298 @@
1
+ import asyncio
2
+ from contextlib import suppress
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from async_lru import alru_cache
7
+ from asyncer import asyncify
8
+ from firebase_admin import auth
9
+ from loguru import logger
10
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt
11
+
12
+ import core_framework.core as core_fw
13
+ import core_framework.domains.comment.dependencies as comment_deps
14
+ import core_framework.domains.moderation.dependencies as moderation_deps
15
+ import core_framework.domains.post.dependencies as post_deps
16
+ import core_framework.domains.user.dependencies as user_deps
17
+ from core_framework.application.shared.enums import RedisKeys
18
+ from core_framework.application.shared.exceptions import ForbiddenException, UserNotFoundException
19
+ from core_framework.core.cache import invalidate_cache
20
+ from core_framework.domains.moderation import InternalNote, ModerationActionType, UserModeration
21
+ from core_framework.domains.user import UserIdentity, UserRole, UserWithProfile
22
+
23
+
24
+ # User Queries
25
+ @alru_cache(ttl=3600) # 1 hour
26
+ async def retrieve_admin_user_ids() -> set[str]:
27
+ return await user_deps.user_service.retrieve_admin_user_ids()
28
+
29
+
30
+ async def retrieve_users(
31
+ *,
32
+ cursor: datetime,
33
+ limit: int,
34
+ username: str | None = None,
35
+ role: UserRole | None = None,
36
+ ) -> list[UserIdentity]:
37
+ return await user_deps.user_service.retrieve_user_identities(
38
+ username=username,
39
+ role=role,
40
+ cursor=cursor,
41
+ limit=limit,
42
+ )
43
+
44
+
45
+ async def retrieve_user_detail(*, user_id: str) -> dict[str, Any]:
46
+ user_data, moderation = await _concurrent_get_user_and_moderation(user_id=user_id)
47
+
48
+ if user_data is None:
49
+ raise UserNotFoundException()
50
+
51
+ email: str | None = None
52
+ email_verified: bool = False
53
+ with suppress(auth.UserNotFoundError):
54
+ firebase_user = await asyncify(auth.get_user)(user_id)
55
+ email = firebase_user.email
56
+ email_verified = firebase_user.email_verified
57
+
58
+ return {
59
+ "account": {
60
+ "user_id": user_id,
61
+ "username": user_data.identity.username,
62
+ "role": user_data.identity.role,
63
+ "email": email,
64
+ "email_verified": email_verified,
65
+ "deletion_scheduled_for": user_data.deletion_scheduled_for,
66
+ },
67
+ "profile": user_data.profile,
68
+ "moderation": {
69
+ "restriction": moderation.restriction,
70
+ "notes": moderation.notes,
71
+ },
72
+ }
73
+
74
+
75
+ async def _concurrent_get_user_and_moderation(*, user_id: str) -> tuple[UserWithProfile | None, UserModeration]:
76
+ async with asyncio.TaskGroup() as tg:
77
+ user_task = tg.create_task(user_deps.user_service.retrieve_user_for_detail(user_id=user_id))
78
+ moderation_task = tg.create_task(
79
+ moderation_deps.moderation_service.retrieve_user_moderation_for_detail(user_id=user_id)
80
+ )
81
+ return user_task.result(), moderation_task.result()
82
+
83
+
84
+ async def retrieve_user_notes(
85
+ *,
86
+ user_id: str,
87
+ cursor: datetime,
88
+ limit: int,
89
+ ) -> list[dict[str, Any]]:
90
+ notes = await moderation_deps.moderation_service.retrieve_internal_notes_paginated(
91
+ target_user_id=user_id,
92
+ cursor=cursor,
93
+ limit=limit,
94
+ )
95
+ actor_ids = {note.actor_id for note in notes}
96
+ user_identity_mapping = await user_deps.user_service.retrieve_user_identity_mapping(user_ids=actor_ids)
97
+ return [
98
+ {
99
+ "id": note.id,
100
+ "content": note.content,
101
+ "created_at": note.created_at,
102
+ "actor": {
103
+ "user_id": note.actor_id,
104
+ "username": user_identity_mapping[note.actor_id].username,
105
+ "display_name": user_identity_mapping[note.actor_id].display_name,
106
+ },
107
+ }
108
+ for note in notes
109
+ ]
110
+
111
+
112
+ async def retrieve_user_history(
113
+ *,
114
+ user_id: str,
115
+ cursor: datetime,
116
+ limit: int,
117
+ ) -> list[dict[str, Any]]:
118
+ history = await user_deps.user_service.retrieve_user_change_history(
119
+ user_id=user_id,
120
+ cursor=cursor,
121
+ limit=limit,
122
+ )
123
+ actor_ids = {entry.actor_id for entry in history if entry.actor_id is not None}
124
+ user_identity_mapping = await user_deps.user_service.retrieve_user_identity_mapping(user_ids=actor_ids)
125
+ return [
126
+ {
127
+ "user_id": entry.user_id,
128
+ "actor": {
129
+ "user_id": entry.actor_id,
130
+ "username": user_identity_mapping[entry.actor_id].username,
131
+ "display_name": user_identity_mapping[entry.actor_id].display_name,
132
+ }
133
+ if entry.actor_id is not None
134
+ else None,
135
+ "entity_type": entry.entity_type,
136
+ "field": entry.field,
137
+ "old_value": entry.old_value,
138
+ "new_value": entry.new_value,
139
+ "created_at": entry.created_at,
140
+ }
141
+ for entry in history
142
+ ]
143
+
144
+
145
+ # User Management
146
+ async def change_user_username(
147
+ *,
148
+ actor_id: str,
149
+ user_id: str,
150
+ new_username: str,
151
+ ) -> None:
152
+ identity = await user_deps.user_service.retrieve_user_identity_strong(user_id=user_id)
153
+ if identity == UserIdentity.DEFAULT:
154
+ raise UserNotFoundException()
155
+ if identity.username == new_username:
156
+ return
157
+ await user_deps.user_service.change_account(
158
+ actor_id=actor_id,
159
+ user_id=user_id,
160
+ validated_update_request={"username": new_username},
161
+ )
162
+ await moderation_deps.moderation_service.record_moderation_action(
163
+ actor_id=actor_id,
164
+ action_type=ModerationActionType.CHANGE_USERNAME,
165
+ target_user_id=user_id,
166
+ action_metadata={"old_username": identity.username, "new_username": new_username},
167
+ )
168
+ await core_fw.redis_cache.sadd(RedisKeys.TAKEN_USERNAMES, new_username)
169
+ await invalidate_cache(RedisKeys.USER_DETAIL, user_id=user_id)
170
+
171
+
172
+ async def change_user_role(
173
+ *,
174
+ actor_id: str,
175
+ user_id: str,
176
+ new_role: str,
177
+ ) -> None:
178
+ identity = await user_deps.user_service.retrieve_user_identity_strong(user_id=user_id)
179
+ if identity == UserIdentity.DEFAULT:
180
+ raise UserNotFoundException()
181
+ if identity.role == UserRole.ADMIN:
182
+ raise ForbiddenException("Admin role cannot be changed via API")
183
+ if identity.role == new_role:
184
+ return
185
+ await user_deps.user_service.change_user_role(
186
+ actor_id=actor_id,
187
+ user_id=user_id,
188
+ role=new_role,
189
+ )
190
+ await moderation_deps.moderation_service.record_moderation_action(
191
+ actor_id=actor_id,
192
+ action_type=ModerationActionType.CHANGE_ROLE,
193
+ target_user_id=user_id,
194
+ action_metadata={"old_role": identity.role.value, "new_role": new_role},
195
+ )
196
+ await invalidate_cache(RedisKeys.USER_DETAIL, user_id=user_id)
197
+
198
+
199
+ async def change_user_profile(
200
+ *,
201
+ actor_id: str,
202
+ user_id: str,
203
+ validated_update_request: dict[str, Any],
204
+ ) -> None:
205
+ if not validated_update_request:
206
+ return
207
+ current_profile = await user_deps.user_service.retrieve_profile_optional_strong(user_id=user_id)
208
+ if current_profile is None:
209
+ raise UserNotFoundException()
210
+ actual_changes = {
211
+ field: value
212
+ for field, value in validated_update_request.items()
213
+ if getattr(current_profile, field, None) != value
214
+ }
215
+ if not actual_changes:
216
+ return
217
+ await user_deps.user_service.change_profile(
218
+ actor_id=actor_id,
219
+ user_id=user_id,
220
+ validated_update_request=actual_changes,
221
+ )
222
+ await moderation_deps.moderation_service.record_moderation_action(
223
+ actor_id=actor_id,
224
+ action_type=ModerationActionType.CHANGE_PROFILE,
225
+ target_user_id=user_id,
226
+ action_metadata={"fields": list(actual_changes.keys())},
227
+ )
228
+ await invalidate_cache(RedisKeys.USER_DETAIL, user_id=user_id)
229
+
230
+
231
+ async def add_user_note(*, actor_id: str, user_id: str, content: str) -> InternalNote:
232
+ identity = await user_deps.user_service.retrieve_user_identity_strong(user_id=user_id)
233
+ if identity == UserIdentity.DEFAULT:
234
+ raise UserNotFoundException()
235
+ note = await moderation_deps.moderation_service.add_internal_note(
236
+ actor_id=actor_id,
237
+ target_user_id=user_id,
238
+ content=content,
239
+ )
240
+ await moderation_deps.moderation_service.record_moderation_action(
241
+ actor_id=actor_id,
242
+ action_type=ModerationActionType.ADD_NOTE,
243
+ target_user_id=user_id,
244
+ action_metadata={"note_id": note.id, "content": content},
245
+ )
246
+ return note
247
+
248
+
249
+ async def remove_user_note(*, actor_id: str, user_id: str, note_id: int) -> None:
250
+ note = await moderation_deps.moderation_service.retrieve_internal_note_strong(
251
+ note_id=note_id,
252
+ target_user_id=user_id,
253
+ )
254
+ if note is None:
255
+ return
256
+ await moderation_deps.moderation_service.remove_internal_note(
257
+ note_id=note_id,
258
+ target_user_id=user_id,
259
+ )
260
+ await moderation_deps.moderation_service.record_moderation_action(
261
+ actor_id=actor_id,
262
+ action_type=ModerationActionType.DELETE_NOTE,
263
+ target_user_id=user_id,
264
+ action_metadata={"note_id": note_id, "content": note.content},
265
+ )
266
+
267
+
268
+ async def remove_user(*, actor_id: str, user_id: str) -> None:
269
+ try:
270
+ identity = await user_deps.user_service.retrieve_user_identity_strong(user_id=user_id)
271
+ await _remove_user(user_id=user_id)
272
+ await invalidate_cache(RedisKeys.USER_DETAIL, user_id=user_id)
273
+ if identity != UserIdentity.DEFAULT:
274
+ await moderation_deps.moderation_service.record_moderation_action(
275
+ actor_id=actor_id,
276
+ action_type=ModerationActionType.DELETE_USER,
277
+ target_user_id=user_id,
278
+ action_metadata={"username": identity.username},
279
+ )
280
+ except Exception as e:
281
+ logger.error(f"Error removing user {user_id=}: {e}")
282
+ raise
283
+
284
+
285
+ @retry(
286
+ stop=stop_after_attempt(3),
287
+ retry=retry_if_exception_type(Exception),
288
+ reraise=True,
289
+ )
290
+ async def _remove_user(*, user_id: str) -> None:
291
+ await moderation_deps.moderation_service.remove_user(user_id=user_id)
292
+ await comment_deps.comment_service.remove_user(user_id=user_id)
293
+ await post_deps.post_service.remove_user(user_id=user_id)
294
+ await user_deps.user_service.remove_user(user_id=user_id)
295
+ try:
296
+ await asyncify(auth.revoke_refresh_tokens)(user_id)
297
+ except Exception as e:
298
+ logger.error(f"Failed to revoke refresh tokens for deleted user {user_id=}: {e}")