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,179 @@
1
+ from asyncio import TaskGroup
2
+ from datetime import datetime
3
+ from typing import Any
4
+
5
+ import core_framework.core as core_fw
6
+ import core_framework.domains.comment.dependencies as comment_deps
7
+ import core_framework.domains.moderation.dependencies as moderation_deps
8
+ import core_framework.domains.post.dependencies as post_deps
9
+ import core_framework.domains.user.dependencies as user_deps
10
+ from core_framework.application.shared.enums import RedisKeys
11
+ from core_framework.core.cache import invalidate_cache
12
+ from core_framework.domains.moderation import AppealDecision, ReportCategory
13
+ from core_framework.domains.user import BlockedUser, Preferences, Profile
14
+
15
+ USER_DETAIL_PROFILE_FIELDS = frozenset({"display_name", "avatar_id", "banner_id"})
16
+
17
+
18
+ # User Blocks
19
+ async def retrieve_my_blocked_users(*, user_id: str, created_at: datetime, limit: int) -> list[BlockedUser]:
20
+ return await user_deps.user_service.retrieve_blocked_users(
21
+ blocker_id=user_id,
22
+ created_at=created_at,
23
+ limit=limit,
24
+ )
25
+
26
+
27
+ async def block_user(*, user_id: str, target_user_id: str) -> None:
28
+ await user_deps.user_service.block_user(user_id=user_id, target_user_id=target_user_id)
29
+ async with TaskGroup() as tg:
30
+ tg.create_task(
31
+ post_deps.post_service.add_user_block_lookup(
32
+ blocker_id=user_id,
33
+ blocked_id=target_user_id,
34
+ )
35
+ )
36
+ tg.create_task(
37
+ comment_deps.comment_service.add_user_block_lookup(
38
+ blocker_id=user_id,
39
+ blocked_id=target_user_id,
40
+ )
41
+ )
42
+
43
+
44
+ async def unblock_user(*, user_id: str, target_user_id: str) -> None:
45
+ await user_deps.user_service.unblock_user(user_id=user_id, target_user_id=target_user_id)
46
+ async with TaskGroup() as tg:
47
+ tg.create_task(
48
+ post_deps.post_service.remove_user_block_lookup(
49
+ blocker_id=user_id,
50
+ blocked_id=target_user_id,
51
+ )
52
+ )
53
+ tg.create_task(
54
+ comment_deps.comment_service.remove_user_block_lookup(
55
+ blocker_id=user_id,
56
+ blocked_id=target_user_id,
57
+ )
58
+ )
59
+
60
+
61
+ # Preferences
62
+ async def retrieve_my_preferences(*, user_id: str) -> Preferences:
63
+ return await user_deps.user_service.retrieve_preferences(user_id=user_id)
64
+
65
+
66
+ async def change_my_preferences(*, user_id: str, validated_update_request: dict[str, Any]) -> Preferences:
67
+ await user_deps.user_service.change_preferences(
68
+ user_id=user_id,
69
+ validated_update_request=validated_update_request,
70
+ )
71
+ return await user_deps.user_service.retrieve_preferences_strong(user_id=user_id)
72
+
73
+
74
+ # Profile
75
+ async def retrieve_my_profile(*, user_id: str) -> Profile:
76
+ return await user_deps.user_service.retrieve_profile(user_id=user_id)
77
+
78
+
79
+ async def change_my_profile(*, user_id: str, validated_update_request: dict[str, Any]) -> Profile:
80
+ await user_deps.user_service.change_profile(
81
+ actor_id=user_id,
82
+ user_id=user_id,
83
+ validated_update_request=validated_update_request,
84
+ )
85
+ if USER_DETAIL_PROFILE_FIELDS.intersection(validated_update_request):
86
+ await invalidate_cache(RedisKeys.USER_DETAIL, user_id=user_id)
87
+ return await user_deps.user_service.retrieve_profile_strong(user_id=user_id)
88
+
89
+
90
+ # Account
91
+ async def retrieve_my_account(*, user_id: str, email: str, email_verified: bool) -> dict[str, Any]:
92
+ user_identity = await user_deps.user_service.retrieve_user_identity(user_id=user_id)
93
+ deletion_scheduled_for = await user_deps.user_service.retrieve_user_deletion_scheduled_for(user_id=user_id)
94
+
95
+ return {
96
+ "username": user_identity.username,
97
+ "email": email,
98
+ "email_verified": email_verified,
99
+ "deletion_scheduled_for": deletion_scheduled_for,
100
+ }
101
+
102
+
103
+ async def change_my_account(*, user_id: str, validated_update_request: dict[str, Any]) -> None:
104
+ await user_deps.user_service.change_account(
105
+ actor_id=user_id,
106
+ user_id=user_id,
107
+ validated_update_request=validated_update_request,
108
+ )
109
+ if (new_username := validated_update_request.get("username")) is not None:
110
+ await core_fw.redis_cache.sadd(RedisKeys.TAKEN_USERNAMES, new_username)
111
+ await invalidate_cache(RedisKeys.USER_DETAIL, user_id=user_id)
112
+
113
+
114
+ async def schedule_account_deletion(*, user_id: str) -> None:
115
+ await user_deps.user_service.schedule_account_deletion(user_id=user_id)
116
+
117
+
118
+ async def cancel_account_deletion(*, user_id: str) -> None:
119
+ await user_deps.user_service.cancel_account_deletion(user_id=user_id)
120
+
121
+
122
+ # Reports
123
+ async def add_user_report(*, reporter_id: str, target_id: str, category: ReportCategory, reason: str) -> None:
124
+ await moderation_deps.moderation_service.add_user_report(
125
+ reporter_id=reporter_id,
126
+ target_id=target_id,
127
+ category=category,
128
+ reason=reason,
129
+ )
130
+
131
+
132
+ async def remove_user_report(*, reporter_id: str, target_id: str) -> None:
133
+ await moderation_deps.moderation_service.remove_user_report_by_reporter(
134
+ reporter_id=reporter_id,
135
+ target_id=target_id,
136
+ )
137
+
138
+
139
+ # Appeals
140
+ async def add_appeal(*, user_id: str, justification: str) -> None:
141
+ await moderation_deps.moderation_service.add_appeal(user_id=user_id, justification=justification)
142
+
143
+
144
+ async def retrieve_my_appeals(
145
+ *,
146
+ user_id: str,
147
+ status: AppealDecision | None,
148
+ cursor: datetime,
149
+ limit: int,
150
+ ) -> list[dict[str, Any]]:
151
+ raw_appeals = await moderation_deps.moderation_service.retrieve_appeals_of_user(
152
+ user_id=user_id,
153
+ status=status,
154
+ cursor=cursor,
155
+ limit=limit,
156
+ )
157
+ user_ids = {appeal.reviewer_id for appeal in raw_appeals if appeal.reviewer_id is not None}
158
+ user_identity_mapping = await user_deps.user_service.retrieve_user_identity_mapping(user_ids=user_ids)
159
+ return [
160
+ {
161
+ "justification": appeal.justification,
162
+ "reviewer": {
163
+ "user_id": appeal.reviewer_id,
164
+ "username": user_identity_mapping[appeal.reviewer_id].username,
165
+ "display_name": user_identity_mapping[appeal.reviewer_id].display_name,
166
+ }
167
+ if appeal.reviewer_id is not None
168
+ else None,
169
+ "decision_reason": appeal.decision_reason,
170
+ "status": appeal.status,
171
+ "created_at": appeal.created_at,
172
+ "updated_at": appeal.updated_at,
173
+ }
174
+ for appeal in raw_appeals
175
+ ]
176
+
177
+
178
+ async def delete_my_current_appeal(*, user_id: str) -> None:
179
+ await moderation_deps.moderation_service.remove_current_appeal_of_user(user_id=user_id)
@@ -0,0 +1,7 @@
1
+ import core_framework.core as core_fw
2
+ from core_framework.application.shared.enums import RedisKeys
3
+
4
+
5
+ async def is_username_available(*, username: str) -> bool:
6
+ is_taken = await core_fw.redis_cache.sismember(RedisKeys.TAKEN_USERNAMES, username)
7
+ return not is_taken
@@ -0,0 +1,5 @@
1
+ import core_framework.domains.user.dependencies as user_deps
2
+
3
+
4
+ async def retrieve_expired_user_deletions() -> list[str]:
5
+ return await user_deps.user_service.retrieve_expired_user_deletions()
@@ -0,0 +1,57 @@
1
+ """Locate Alembic migration trees shipped with the ``core_framework`` package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import core_framework
8
+
9
+
10
+ def bundled_alembic_root() -> Path:
11
+ """Root directory containing ``extension/``, ``user/``, … (each with ``alembic.ini``).
12
+
13
+ Resolves to:
14
+
15
+ - ``site-packages/core_framework/alembic`` when installed from a wheel (bundled copy).
16
+ - The repository ``alembic/`` directory when developing this project from a checkout
17
+ (no ``core_framework/alembic`` in the working tree).
18
+ """
19
+
20
+ pkg = Path(core_framework.__file__).resolve().parent
21
+ bundled = pkg / "alembic"
22
+ if bundled.is_dir():
23
+ return bundled
24
+ legacy = pkg.parent / "alembic"
25
+ if legacy.is_dir():
26
+ return legacy
27
+ msg = (
28
+ f"could not find Alembic trees: expected {bundled} or {legacy}. "
29
+ "Install core-framework from a wheel or use a full repository checkout."
30
+ )
31
+ raise FileNotFoundError(msg)
32
+
33
+
34
+ # Order matches production deploy (``.github/workflows/_deploy.yml``).
35
+ ALEMBIC_DOMAINS: tuple[str, ...] = (
36
+ "extension",
37
+ "user",
38
+ "moderation",
39
+ "post",
40
+ "comment",
41
+ )
42
+
43
+
44
+ def alembic_ini_path(domain: str) -> Path:
45
+ """Return the path to ``<domain>/alembic.ini`` under :func:`bundled_alembic_root`."""
46
+
47
+ p = bundled_alembic_root() / domain / "alembic.ini"
48
+ if not p.is_file():
49
+ msg = f"no alembic.ini for domain {domain!r} under {bundled_alembic_root()}"
50
+ raise FileNotFoundError(msg)
51
+ return p
52
+
53
+
54
+ def iter_alembic_ini_paths() -> tuple[Path, ...]:
55
+ """``alembic.ini`` paths in deploy order."""
56
+
57
+ return tuple(alembic_ini_path(d) for d in ALEMBIC_DOMAINS)
@@ -0,0 +1,37 @@
1
+ from core_framework.core.exception_handlers import setup_exception_handlers
2
+ from core_framework.core.logging import setup_logging
3
+ from core_framework.core.middleware import setup_middleware
4
+ from core_framework.core.observability import setup_observability
5
+ from core_framework.core.runtime import CoreRuntime, unconfigured_dependency
6
+ from core_framework.core.settings import load_default_settings
7
+
8
+ settings = load_default_settings()
9
+ write_postgres = unconfigured_dependency("Postgres")
10
+ read_postgres = unconfigured_dependency("Postgres")
11
+ redis_queue = unconfigured_dependency("RedisQueue")
12
+ redis_cache = unconfigured_dependency("RedisCache")
13
+ general_http_client = unconfigured_dependency("HttpClient")
14
+
15
+
16
+ def configure_core_runtime(core_runtime: CoreRuntime) -> None:
17
+ global write_postgres, read_postgres, redis_queue, redis_cache, general_http_client
18
+ write_postgres = core_runtime.write_postgres
19
+ read_postgres = core_runtime.read_postgres
20
+ redis_queue = core_runtime.redis_queue
21
+ redis_cache = core_runtime.redis_cache
22
+ general_http_client = core_runtime.general_http_client
23
+
24
+
25
+ __all__ = [
26
+ "write_postgres",
27
+ "read_postgres",
28
+ "settings",
29
+ "redis_queue",
30
+ "redis_cache",
31
+ "general_http_client",
32
+ "configure_core_runtime",
33
+ "setup_logging",
34
+ "setup_middleware",
35
+ "setup_observability",
36
+ "setup_exception_handlers",
37
+ ]
@@ -0,0 +1,234 @@
1
+ import inspect
2
+ import random
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import is_dataclass
5
+ from functools import wraps
6
+ from typing import Any, Final, get_type_hints
7
+
8
+ import orjson
9
+ from loguru import logger
10
+ from mashumaro.codecs.orjson import ORJSONDecoder
11
+
12
+ from core_framework.core.redis import RedisCache
13
+
14
+ CACHE_SCAN_COUNT: Final[int] = 2000
15
+ CACHE_BATCH_SIZE: Final[int] = 2000
16
+ DEFAULT_CACHE_TTL: Final[int] = 60 * 60 * 24 * 30 # 30 days
17
+ _configured_redis_cache: RedisCache | None = None
18
+
19
+ ALLOWED_CACHE_TYPES: Final[tuple[type, ...]] = (str, int, float, bool, type(None))
20
+
21
+ # orjson only supports 64-bit integers
22
+ _MIN_INT64: Final[int] = -(2**63)
23
+ _MAX_INT64: Final[int] = 2**63 - 1
24
+
25
+ type CacheValue = str | int | float | bool | None
26
+
27
+
28
+ def configure_cache(*, redis_cache: RedisCache) -> None:
29
+ global _configured_redis_cache
30
+ _configured_redis_cache = redis_cache
31
+
32
+
33
+ def _get_redis_cache() -> RedisCache:
34
+ if _configured_redis_cache is not None:
35
+ return _configured_redis_cache
36
+
37
+ msg = "Cache is not configured. Call configure_cache() during application bootstrap."
38
+ raise RuntimeError(msg)
39
+
40
+
41
+ def _ensure_cacheable(value: object) -> None:
42
+ if not isinstance(value, ALLOWED_CACHE_TYPES):
43
+ raise ValueError(f"Value is not a cache primitive: {value}")
44
+
45
+ # Validate integer range for orjson compatibility
46
+ if isinstance(value, int):
47
+ if value < _MIN_INT64 or value > _MAX_INT64:
48
+ raise ValueError(f"Integer {value} exceeds 64-bit range ({_MIN_INT64} to {_MAX_INT64})")
49
+
50
+
51
+ def _stable_serialize(value: CacheValue) -> bytes:
52
+ _ensure_cacheable(value)
53
+ return orjson.dumps(value)
54
+
55
+
56
+ def _generate_cache_key(resource_name: str, **kwargs: CacheValue) -> bytes:
57
+ if not resource_name:
58
+ raise ValueError("resource_name cannot be empty")
59
+
60
+ segments: list[bytes] = [resource_name.encode()]
61
+
62
+ for k, v in sorted(kwargs.items()):
63
+ segments.append(k.encode() + b"=" + _stable_serialize(v))
64
+
65
+ return b":".join(segments)
66
+
67
+
68
+ def _generate_cache_pattern(resource_name: str, **kwargs: CacheValue) -> bytes:
69
+ if not resource_name:
70
+ raise ValueError("resource_name cannot be empty")
71
+
72
+ segments: list[bytes] = [resource_name.encode()]
73
+
74
+ for k, v in sorted(kwargs.items()):
75
+ if v is None:
76
+ segments.append(k.encode() + b"=*")
77
+ else:
78
+ segments.append(k.encode() + b"=" + _stable_serialize(v))
79
+
80
+ return b":*".join(segments) + b"*"
81
+
82
+
83
+ async def _invalidate_cache_by_pattern(pattern: bytes) -> None:
84
+ to_delete: list[bytes] = []
85
+ redis_cache = _get_redis_cache()
86
+
87
+ async for key in redis_cache.scan_iter(
88
+ match=pattern,
89
+ count=CACHE_SCAN_COUNT,
90
+ ):
91
+ to_delete.append(key)
92
+
93
+ if len(to_delete) >= CACHE_BATCH_SIZE:
94
+ await redis_cache.unlink(*to_delete)
95
+ to_delete.clear()
96
+
97
+ if to_delete:
98
+ await redis_cache.unlink(*to_delete)
99
+
100
+
101
+ async def invalidate_cache(resource_name: str, **kwargs: CacheValue) -> None:
102
+ pattern = _generate_cache_pattern(resource_name, **kwargs)
103
+ await _invalidate_cache_by_pattern(pattern)
104
+
105
+
106
+ def cache(
107
+ *,
108
+ resource_name: str,
109
+ ttl: int = DEFAULT_CACHE_TTL,
110
+ skip_if: Callable[[Any], bool] | None = None,
111
+ ) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
112
+ def wrapper(func: Callable[..., Awaitable[Any]]) -> Callable[..., Awaitable[Any]]:
113
+ if ttl <= 0:
114
+ raise ValueError("ttl must be positive")
115
+
116
+ sig = inspect.signature(func)
117
+
118
+ # Validate that function only accepts keyword arguments
119
+ # Allow: keyword-only parameters (*, x: int) or **kwargs
120
+ # Reject: positional parameters (x: int) or *args
121
+ func_name = getattr(func, "__name__", "<unknown>")
122
+ for param_name, param in sig.parameters.items():
123
+ if param.kind == inspect.Parameter.VAR_POSITIONAL:
124
+ raise ValueError(
125
+ f"Function {func_name} has *{param_name} which is not supported. "
126
+ "Cached functions must use only keyword arguments (no *args)."
127
+ )
128
+ if param.kind == inspect.Parameter.VAR_KEYWORD:
129
+ # **kwargs is allowed
130
+ continue
131
+ if param.kind == inspect.Parameter.POSITIONAL_ONLY:
132
+ raise ValueError(
133
+ f"Function {func_name} has positional-only parameter {param_name} which is not supported. "
134
+ "Cached functions must use only keyword arguments."
135
+ )
136
+ if param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD:
137
+ # Allow self/cls for methods, but reject other positional-or-keyword params
138
+ if param_name not in {"self", "cls"}:
139
+ raise ValueError(
140
+ f"Function {func_name} has positional parameter {param_name} which is not supported. "
141
+ f"Cached functions must use only keyword arguments (use *, {param_name}... for keyword-only)."
142
+ )
143
+ # KEYWORD_ONLY is allowed (parameters after * or *args)
144
+
145
+ first_param = next(iter(sig.parameters.values()), None)
146
+ ignore_first = first_param is not None and first_param.name in {
147
+ "self",
148
+ "cls",
149
+ }
150
+
151
+ return_type = get_type_hints(func).get("return")
152
+ decoder: ORJSONDecoder | None = None
153
+ if return_type is not None and is_dataclass(return_type):
154
+ decoder = ORJSONDecoder(return_type)
155
+
156
+ @wraps(func)
157
+ async def inner(*args: Any, **kwargs: Any) -> Any:
158
+ # Reject positional arguments (except self/cls for methods)
159
+ # Only kwargs are allowed for cache key generation
160
+ if ignore_first:
161
+ # For methods, allow self/cls as first positional arg
162
+ if len(args) > 1:
163
+ raise TypeError(
164
+ f"{func_name}() does not accept positional arguments (except self/cls). "
165
+ "Cached functions must be called with keyword arguments only."
166
+ )
167
+ else:
168
+ # For regular functions, no positional args allowed
169
+ if args:
170
+ raise TypeError(
171
+ f"{func_name}() does not accept positional arguments. "
172
+ "Cached functions must be called with keyword arguments only."
173
+ )
174
+
175
+ # Remove self/cls from kwargs if present (for methods)
176
+ key_kwargs = dict(kwargs)
177
+ if ignore_first and first_param is not None:
178
+ fp_name = first_param.name
179
+ if fp_name in key_kwargs:
180
+ del key_kwargs[fp_name]
181
+
182
+ cache_key = _generate_cache_key(resource_name, **key_kwargs)
183
+ redis_cache = _get_redis_cache()
184
+
185
+ try:
186
+ cached_bytes = await redis_cache.get(cache_key)
187
+ except Exception as e:
188
+ logger.error(f"Failed to get cache for key: {cache_key}: {e}")
189
+ cached_bytes = None
190
+
191
+ if cached_bytes is not None:
192
+ try:
193
+ if decoder:
194
+ return decoder.decode(cached_bytes)
195
+ return orjson.loads(cached_bytes)
196
+ except Exception:
197
+ logger.error(f"Failed to decode JSON for key: {cache_key}")
198
+ # No need to unlink - the cache will be overwritten after fetching from DB
199
+
200
+ result = await func(*args, **kwargs)
201
+
202
+ if skip_if is not None and skip_if(result):
203
+ return result
204
+
205
+ try:
206
+ payload = orjson.dumps(result)
207
+ # Apply 5% jitter with minimum of 1 second to prevent cache stampede
208
+ # Ensure jitter never exceeds base TTL to avoid negative/zero results
209
+ jitter = min(max(1, int(ttl * 0.05)), ttl // 2)
210
+ ttl_jittered = ttl + random.randint(-jitter, jitter)
211
+
212
+ await redis_cache.set(cache_key, payload, ex=ttl_jittered)
213
+ except TypeError as exc:
214
+ raise ValueError(f"Value is not JSON serializable for key: {cache_key}") from exc
215
+ except Exception as e:
216
+ logger.warning(f"Failed to cache result for key {cache_key}: {e}")
217
+
218
+ return result
219
+
220
+ return inner
221
+
222
+ return wrapper
223
+
224
+
225
+ if __name__ == "__main__": # pragma: no cover
226
+ print(
227
+ _generate_cache_key(
228
+ "user_cache",
229
+ user_id=1,
230
+ series_id="SDFAZV45SOLDFDF534",
231
+ date="2025-01-01",
232
+ )
233
+ )
234
+ print(_generate_cache_pattern("user_cache", user_id=1, series_id="SDFAZV45SOLDFDF534"))
@@ -0,0 +1,14 @@
1
+ from contextvars import ContextVar
2
+
3
+ user_id: ContextVar[str | None] = ContextVar[str | None]("user_id", default=None)
4
+ user_ip: ContextVar[str | None] = ContextVar[str | None]("user_ip", default=None)
5
+ client_ip: ContextVar[str | None] = ContextVar[str | None]("client_ip", default=None)
6
+ request_id: ContextVar[str | None] = ContextVar[str | None]("request_id", default=None)
7
+
8
+
9
+ REQUEST_CONTEXTVARS: list[ContextVar[str | None]] = [
10
+ user_id,
11
+ user_ip,
12
+ client_ip,
13
+ request_id,
14
+ ]
@@ -0,0 +1,111 @@
1
+ from collections.abc import AsyncGenerator
2
+ from contextlib import asynccontextmanager
3
+ from types import TracebackType
4
+ from typing import Any, Final, Self
5
+
6
+ import asyncpg
7
+ import orjson
8
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
9
+
10
+ DB_NOT_CONNECTED_MSG: Final[str] = "Database is not connected"
11
+ DB_ALREADY_CONNECTED_MSG: Final[str] = "Database is already connected"
12
+
13
+
14
+ class Postgres:
15
+ __slots__ = ("_pool", "_database_url", "_min_connections", "_max_connections")
16
+
17
+ def __init__(
18
+ self,
19
+ database_url: str,
20
+ min_connections: int,
21
+ max_connections: int,
22
+ ) -> None:
23
+ self._pool: asyncpg.Pool | None = None
24
+ self._database_url = database_url
25
+ self._min_connections = min_connections
26
+ self._max_connections = max_connections
27
+
28
+ async def __aenter__(self) -> Self:
29
+ return await self.connect()
30
+
31
+ async def __aexit__(
32
+ self,
33
+ exc_type: type[BaseException] | None,
34
+ exc_value: BaseException | None,
35
+ traceback: TracebackType | None,
36
+ ) -> None:
37
+ await self.disconnect()
38
+
39
+ @retry(
40
+ stop=stop_after_attempt(10),
41
+ wait=wait_exponential(multiplier=2, min=2, max=30),
42
+ retry=retry_if_exception_type((asyncpg.PostgresConnectionError, OSError)),
43
+ )
44
+ async def connect(self) -> Self:
45
+ if self._pool is not None:
46
+ raise RuntimeError(DB_ALREADY_CONNECTED_MSG)
47
+
48
+ self._pool = await asyncpg.create_pool(
49
+ self._database_url,
50
+ min_size=self._min_connections,
51
+ max_size=self._max_connections,
52
+ init=self.init_connection,
53
+ server_settings={
54
+ "search_path": "extension, public",
55
+ },
56
+ )
57
+ return self
58
+
59
+ async def disconnect(self) -> None:
60
+ if self._pool is None:
61
+ return
62
+
63
+ await self._pool.close()
64
+ self._pool = None
65
+
66
+ @asynccontextmanager
67
+ async def get_connection(self) -> AsyncGenerator[asyncpg.Connection]:
68
+ if self._pool is None:
69
+ raise RuntimeError(DB_NOT_CONNECTED_MSG)
70
+
71
+ async with self._pool.acquire() as connection:
72
+ yield connection
73
+
74
+ @asynccontextmanager
75
+ async def get_transaction_with_actor_id(self, actor_id: str | None) -> AsyncGenerator[asyncpg.Connection]:
76
+ if self._pool is None:
77
+ raise RuntimeError(DB_NOT_CONNECTED_MSG)
78
+
79
+ async with self._pool.acquire() as connection:
80
+ async with connection.transaction():
81
+ if actor_id is not None:
82
+ try:
83
+ await connection.execute("select set_config('app.actor_id', $1, true)", actor_id)
84
+ except asyncpg.PostgresError:
85
+ pass # Actor ID is optional - transaction proceeds without it
86
+ yield connection
87
+
88
+ @asynccontextmanager
89
+ async def get_transaction(self) -> AsyncGenerator[asyncpg.Connection]:
90
+ if self._pool is None:
91
+ raise RuntimeError(DB_NOT_CONNECTED_MSG)
92
+
93
+ async with self._pool.acquire() as connection:
94
+ async with connection.transaction():
95
+ yield connection
96
+
97
+ @staticmethod
98
+ def orjson_dump_str(value: Any) -> str:
99
+ return orjson.dumps(value).decode()
100
+
101
+ @staticmethod
102
+ def orjson_load_str(value: str) -> Any:
103
+ return orjson.loads(value)
104
+
105
+ async def init_connection(self, connection: asyncpg.Connection) -> None:
106
+ await connection.set_type_codec(
107
+ "jsonb",
108
+ encoder=self.orjson_dump_str,
109
+ decoder=self.orjson_load_str,
110
+ schema="pg_catalog",
111
+ )
@@ -0,0 +1,3 @@
1
+ from core_framework.core.exception_handlers.setup import setup_exception_handlers
2
+
3
+ __all__ = ["setup_exception_handlers"]