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,828 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+
4
+ from core_framework.core.database import Postgres
5
+ from core_framework.domains.moderation.enums import (
6
+ AppealDecision,
7
+ HistoryAction,
8
+ ModerationActionType,
9
+ ReportCategory,
10
+ RestrictionCategory,
11
+ RestrictionType,
12
+ )
13
+ from core_framework.domains.moderation.exceptions import (
14
+ AppealAlreadyDecidedException,
15
+ AppealNotFoundException,
16
+ AppealRequirementException,
17
+ BaseModerationException,
18
+ ExistingPendingAppealException,
19
+ )
20
+ from core_framework.domains.moderation.models import (
21
+ Appeal,
22
+ InternalNote,
23
+ ModerationAction,
24
+ Report,
25
+ RestrictionHistory,
26
+ UserRestriction,
27
+ )
28
+
29
+
30
+ class ModerationRepository:
31
+ def __init__(self, write_database: Postgres, read_database: Postgres, strong_read_database: Postgres):
32
+ self.write_database = write_database
33
+ self.read_database = read_database
34
+ self.strong_read_database = strong_read_database
35
+
36
+ @staticmethod
37
+ def _record_to_report(record: Any) -> Report:
38
+ return Report(
39
+ id=record["id"],
40
+ reporter_id=record["reporter_id"],
41
+ target_id=record["target_id"],
42
+ category=ReportCategory(record["category"]),
43
+ reason=record["reason"],
44
+ created_at=record["created_at"],
45
+ )
46
+
47
+ # Restrictions
48
+ async def upsert_ban_state(
49
+ self,
50
+ *,
51
+ actor_id: str,
52
+ user_id: str,
53
+ reason: str,
54
+ category: RestrictionCategory,
55
+ expires_at: datetime,
56
+ ) -> None:
57
+ query = """
58
+ merge into moderation.user_restrictions as target
59
+ using (values ($1, 'banned'::moderation.restriction_type, $2, $3::moderation.restriction_category, $4::timestamptz))
60
+ as source(user_id, restriction_type, reason, category, expires_at)
61
+ on target.user_id = source.user_id
62
+ when matched and target.restriction_type != 'banned'::moderation.restriction_type then
63
+ update set
64
+ restriction_type = source.restriction_type,
65
+ reason = source.reason,
66
+ category = source.category,
67
+ expires_at = source.expires_at,
68
+ updated_at = now()
69
+ when not matched then
70
+ insert (user_id, restriction_type, reason, category, expires_at)
71
+ values (source.user_id, source.restriction_type, source.reason, source.category, source.expires_at)
72
+ """
73
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
74
+ await connection.execute(query, user_id, reason, category, expires_at)
75
+
76
+ async def upsert_mute_state(
77
+ self,
78
+ *,
79
+ actor_id: str,
80
+ user_id: str,
81
+ reason: str,
82
+ category: RestrictionCategory,
83
+ expires_at: datetime,
84
+ ) -> None:
85
+ query = """
86
+ merge into moderation.user_restrictions as target
87
+ using (values ($1, 'muted'::moderation.restriction_type, $2, $3::moderation.restriction_category, $4::timestamptz))
88
+ as source(user_id, restriction_type, reason, category, expires_at)
89
+ on target.user_id = source.user_id
90
+ when matched and target.restriction_type = 'warned'::moderation.restriction_type then
91
+ update set
92
+ restriction_type = source.restriction_type,
93
+ reason = source.reason,
94
+ category = source.category,
95
+ expires_at = source.expires_at,
96
+ updated_at = now()
97
+ when not matched then
98
+ insert (user_id, restriction_type, reason, category, expires_at)
99
+ values (source.user_id, source.restriction_type, source.reason, source.category, source.expires_at)
100
+ """
101
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
102
+ await connection.execute(query, user_id, reason, category, expires_at)
103
+
104
+ async def upsert_warn_state(
105
+ self,
106
+ *,
107
+ actor_id: str,
108
+ user_id: str,
109
+ reason: str,
110
+ category: RestrictionCategory,
111
+ ) -> None:
112
+ query = """
113
+ merge into moderation.user_restrictions as target
114
+ using (values ($1, 'warned'::moderation.restriction_type, $2, $3::moderation.restriction_category))
115
+ as source(user_id, restriction_type, reason, category)
116
+ on target.user_id = source.user_id
117
+ when matched then
118
+ do nothing
119
+ when not matched then
120
+ insert (user_id, restriction_type, reason, category)
121
+ values (source.user_id, source.restriction_type, source.reason, source.category)
122
+ """
123
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
124
+ await connection.execute(query, user_id, reason, category)
125
+
126
+ async def delete_user_restriction(self, *, actor_id: str, user_id: str) -> None:
127
+ query = """
128
+ delete from moderation.user_restrictions
129
+ where user_id = $1
130
+ """
131
+ async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
132
+ await connection.execute(query, user_id)
133
+
134
+ async def select_expired_mute_user_ids(self) -> list[str]:
135
+ query = """
136
+ select
137
+ user_id
138
+ from moderation.user_restrictions
139
+ where true
140
+ and restriction_type = 'muted'::moderation.restriction_type
141
+ and expires_at is not null
142
+ and expires_at <= now()
143
+ """
144
+ async with self.read_database.get_connection() as connection:
145
+ result = await connection.fetch(query)
146
+ return [record["user_id"] for record in result]
147
+
148
+ # Reports
149
+ async def insert_user_report(
150
+ self,
151
+ *,
152
+ reporter_id: str,
153
+ target_id: str,
154
+ category: ReportCategory,
155
+ reason: str,
156
+ ) -> None:
157
+ query = """
158
+ insert into moderation.user_reports (reporter_id, target_id, category, reason)
159
+ values ($1, $2, $3, $4)
160
+ on conflict (reporter_id, target_id) do nothing
161
+ """
162
+ async with self.write_database.get_connection() as connection:
163
+ await connection.execute(query, reporter_id, target_id, category, reason)
164
+
165
+ async def select_user_reports(
166
+ self,
167
+ *,
168
+ reporter_id: str | None,
169
+ target_id: str | None,
170
+ cursor: datetime,
171
+ limit: int,
172
+ ) -> list[Report]:
173
+ query = """
174
+ select
175
+ id,
176
+ reporter_id,
177
+ target_id,
178
+ category,
179
+ reason,
180
+ created_at
181
+ from moderation.user_reports
182
+ where true
183
+ and created_at <= $1
184
+ and ($3::text is null or reporter_id = $3)
185
+ and ($4::text is null or target_id = $4)
186
+ order by created_at desc
187
+ limit $2
188
+ """
189
+ async with self.read_database.get_connection() as connection:
190
+ result = await connection.fetch(query, cursor, limit, reporter_id, target_id)
191
+ return [self._record_to_report(record) for record in result]
192
+
193
+ async def delete_user_report_by_id(self, *, report_id: int) -> None:
194
+ query = """
195
+ delete from moderation.user_reports
196
+ where id = $1
197
+ """
198
+ async with self.write_database.get_connection() as connection:
199
+ await connection.execute(query, report_id)
200
+
201
+ async def delete_user_report_by_reporter_and_target(self, *, reporter_id: str, target_id: str) -> None:
202
+ query = """
203
+ delete from moderation.user_reports
204
+ where true
205
+ and reporter_id = $1
206
+ and target_id = $2
207
+ """
208
+ async with self.write_database.get_connection() as connection:
209
+ await connection.execute(query, reporter_id, target_id)
210
+
211
+ async def insert_post_report(
212
+ self,
213
+ *,
214
+ reporter_id: str,
215
+ target_id: str,
216
+ category: ReportCategory,
217
+ reason: str,
218
+ ) -> None:
219
+ query = """
220
+ insert into moderation.post_reports (reporter_id, target_id, category, reason)
221
+ values ($1, $2, $3, $4)
222
+ on conflict (reporter_id, target_id) do nothing
223
+ """
224
+ async with self.write_database.get_connection() as connection:
225
+ await connection.execute(query, reporter_id, target_id, category, reason)
226
+
227
+ async def select_post_report_count(self, *, post_id: str) -> int:
228
+ query = """
229
+ select count(*)
230
+ from moderation.post_reports
231
+ where target_id = $1
232
+ """
233
+ async with self.read_database.get_connection() as connection:
234
+ return await connection.fetchval(query, post_id)
235
+
236
+ async def select_post_reports(
237
+ self,
238
+ *,
239
+ reporter_id: str | None,
240
+ target_id: str | None,
241
+ cursor: datetime,
242
+ limit: int,
243
+ ) -> list[Report]:
244
+ query = """
245
+ select
246
+ id,
247
+ reporter_id,
248
+ target_id,
249
+ category,
250
+ reason,
251
+ created_at
252
+ from moderation.post_reports
253
+ where true
254
+ and created_at <= $1
255
+ and ($3::text is null or reporter_id = $3)
256
+ and ($4::text is null or target_id = $4)
257
+ order by created_at desc
258
+ limit $2
259
+ """
260
+ async with self.read_database.get_connection() as connection:
261
+ result = await connection.fetch(query, cursor, limit, reporter_id, target_id)
262
+ return [self._record_to_report(record) for record in result]
263
+
264
+ async def delete_post_report_by_id(self, *, report_id: int) -> None:
265
+ query = """
266
+ delete from moderation.post_reports
267
+ where id = $1
268
+ """
269
+ async with self.write_database.get_connection() as connection:
270
+ await connection.execute(query, report_id)
271
+
272
+ async def delete_post_report_by_reporter_and_target(self, *, reporter_id: str, target_id: str) -> None:
273
+ query = """
274
+ delete from moderation.post_reports
275
+ where true
276
+ and reporter_id = $1
277
+ and target_id = $2
278
+ """
279
+ async with self.write_database.get_connection() as connection:
280
+ await connection.execute(query, reporter_id, target_id)
281
+
282
+ async def delete_post_reports_by_target_id(self, *, target_id: str) -> None:
283
+ query = """
284
+ delete from moderation.post_reports
285
+ where target_id = $1
286
+ """
287
+ async with self.write_database.get_connection() as connection:
288
+ await connection.execute(query, target_id)
289
+
290
+ async def select_post_ids_reported_by_user(self, *, reporter_id: str, post_ids: set[str]) -> list[str]:
291
+ if not post_ids:
292
+ return []
293
+ query = """
294
+ select target_id
295
+ from moderation.post_reports
296
+ where true
297
+ and reporter_id = $1
298
+ and target_id = any($2)
299
+ """
300
+ async with self.read_database.get_connection() as connection:
301
+ rows = await connection.fetch(query, reporter_id, list(post_ids))
302
+ return [row["target_id"] for row in rows]
303
+
304
+ async def select_comment_ids_reported_by_user(self, *, reporter_id: str, comment_ids: set[str]) -> list[str]:
305
+ if not comment_ids:
306
+ return []
307
+ query = """
308
+ select target_id
309
+ from moderation.comment_reports
310
+ where true
311
+ and reporter_id = $1
312
+ and target_id = any($2)
313
+ """
314
+ async with self.read_database.get_connection() as connection:
315
+ rows = await connection.fetch(query, reporter_id, list(comment_ids))
316
+ return [row["target_id"] for row in rows]
317
+
318
+ async def select_comment_report_count(self, *, comment_id: str) -> int:
319
+ query = """
320
+ select count(*)::int
321
+ from moderation.comment_reports
322
+ where target_id = $1
323
+ """
324
+ async with self.read_database.get_connection() as connection:
325
+ return await connection.fetchval(query, comment_id) or 0
326
+
327
+ async def select_comment_reports(
328
+ self,
329
+ *,
330
+ reporter_id: str | None,
331
+ target_id: str | None,
332
+ cursor: datetime,
333
+ limit: int,
334
+ ) -> list[Report]:
335
+ query = """
336
+ select
337
+ id,
338
+ reporter_id,
339
+ target_id,
340
+ category,
341
+ reason,
342
+ created_at
343
+ from moderation.comment_reports
344
+ where true
345
+ and created_at <= $1
346
+ and ($3::text is null or reporter_id = $3)
347
+ and ($4::text is null or target_id = $4)
348
+ order by created_at desc
349
+ limit $2
350
+ """
351
+ async with self.read_database.get_connection() as connection:
352
+ result = await connection.fetch(query, cursor, limit, reporter_id, target_id)
353
+ return [self._record_to_report(record) for record in result]
354
+
355
+ async def insert_comment_report(
356
+ self,
357
+ *,
358
+ reporter_id: str,
359
+ target_id: str,
360
+ category: ReportCategory,
361
+ reason: str,
362
+ ) -> None:
363
+ query = """
364
+ insert into moderation.comment_reports (reporter_id, target_id, category, reason)
365
+ values ($1, $2, $3, $4)
366
+ on conflict (reporter_id, target_id) do nothing
367
+ """
368
+ async with self.write_database.get_connection() as connection:
369
+ await connection.execute(query, reporter_id, target_id, category, reason)
370
+
371
+ async def delete_comment_report_by_id(self, *, report_id: int) -> None:
372
+ query = """
373
+ delete from moderation.comment_reports
374
+ where id = $1
375
+ """
376
+ async with self.write_database.get_connection() as connection:
377
+ await connection.execute(query, report_id)
378
+
379
+ async def delete_comment_report_by_reporter_and_target(
380
+ self,
381
+ *,
382
+ reporter_id: str,
383
+ target_id: str,
384
+ ) -> None:
385
+ query = """
386
+ delete from moderation.comment_reports
387
+ where true
388
+ and reporter_id = $1
389
+ and target_id = $2
390
+ """
391
+ async with self.write_database.get_connection() as connection:
392
+ await connection.execute(query, reporter_id, target_id)
393
+
394
+ async def delete_comment_reports_by_target_ids(self, *, target_ids: set[str]) -> None:
395
+ if not target_ids:
396
+ return
397
+ query = """
398
+ delete from moderation.comment_reports
399
+ where target_id = any($1::varchar[])
400
+ """
401
+ async with self.write_database.get_connection() as connection:
402
+ await connection.execute(query, list(target_ids))
403
+
404
+ # Appeals
405
+ async def insert_appeal(self, *, user_id: str, justification: str) -> None:
406
+ query = """
407
+ with has_restriction as (
408
+ select exists (
409
+ select 1
410
+ from moderation.user_restrictions
411
+ where true
412
+ and user_id = $1
413
+ and restriction_type in ('muted', 'banned')
414
+ ) as yes
415
+ ),
416
+ ins as (
417
+ insert into moderation.appeals (user_id, justification)
418
+ select $1, $2
419
+ where (select yes from has_restriction)
420
+ on conflict (user_id) where status = 'pending' do nothing
421
+ returning id
422
+ )
423
+ select
424
+ case when ins.id is not null then 'success'
425
+ when not has_restriction.yes then 'restriction_required'
426
+ else 'conflict'
427
+ end as state
428
+ from has_restriction left join ins on true
429
+ """
430
+ async with self.write_database.get_connection() as connection:
431
+ result = await connection.fetchval(query, user_id, justification)
432
+ if result == "success":
433
+ return
434
+ if result == "conflict":
435
+ raise ExistingPendingAppealException()
436
+ if result == "restriction_required":
437
+ raise AppealRequirementException()
438
+ raise BaseModerationException("Failed to create appeal")
439
+
440
+ async def select_appeal_strong(self, *, appeal_id: int) -> Appeal:
441
+ query = """
442
+ select
443
+ id,
444
+ user_id,
445
+ justification,
446
+ decision_reason,
447
+ reviewer_id,
448
+ status,
449
+ created_at,
450
+ updated_at
451
+ from moderation.appeals
452
+ where id = $1
453
+ """
454
+ async with self.strong_read_database.get_connection() as connection:
455
+ result = await connection.fetchrow(query, appeal_id)
456
+ if result is None:
457
+ raise AppealNotFoundException()
458
+ return Appeal(
459
+ id=result["id"],
460
+ user_id=result["user_id"],
461
+ justification=result["justification"],
462
+ decision_reason=result["decision_reason"],
463
+ reviewer_id=result["reviewer_id"],
464
+ status=AppealDecision(result["status"]),
465
+ created_at=result["created_at"],
466
+ updated_at=result["updated_at"],
467
+ )
468
+
469
+ async def update_appeal_decision(
470
+ self,
471
+ *,
472
+ actor_id: str,
473
+ appeal_id: int,
474
+ decision: AppealDecision,
475
+ reason: str,
476
+ ) -> None:
477
+ query = """
478
+ update moderation.appeals
479
+ set
480
+ status = $2,
481
+ decision_reason = $3,
482
+ reviewer_id = $4
483
+ where true
484
+ and id = $1
485
+ and status = 'pending'
486
+ returning id
487
+ """
488
+ existing_appeal_query = """
489
+ select 1 from moderation.appeals where id = $1
490
+ """
491
+ async with self.write_database.get_connection() as connection:
492
+ row = await connection.fetchrow(query, appeal_id, decision, reason, actor_id)
493
+ if row is None:
494
+ existing = await connection.fetchrow(existing_appeal_query, appeal_id)
495
+ if existing is None:
496
+ raise AppealNotFoundException()
497
+ else:
498
+ raise AppealAlreadyDecidedException()
499
+
500
+ async def select_appeals(
501
+ self,
502
+ *,
503
+ cursor: datetime,
504
+ limit: int,
505
+ status: AppealDecision | None,
506
+ user_id: str | None = None,
507
+ ) -> list[Appeal]:
508
+ query = """
509
+ select
510
+ id,
511
+ user_id,
512
+ justification,
513
+ decision_reason,
514
+ reviewer_id,
515
+ status,
516
+ created_at,
517
+ updated_at
518
+ from moderation.appeals
519
+ where true
520
+ and updated_at <= $1
521
+ and ($3::text is null or user_id = $3)
522
+ and ($4::moderation.appeal_status is null or status = $4::moderation.appeal_status)
523
+ order by updated_at desc
524
+ limit $2
525
+ """
526
+ async with self.read_database.get_connection() as connection:
527
+ result = await connection.fetch(query, cursor, limit, user_id, status)
528
+ return [
529
+ Appeal(
530
+ id=record["id"],
531
+ user_id=record["user_id"],
532
+ justification=record["justification"],
533
+ decision_reason=record["decision_reason"],
534
+ reviewer_id=record["reviewer_id"],
535
+ status=AppealDecision(record["status"]),
536
+ created_at=record["created_at"],
537
+ updated_at=record["updated_at"],
538
+ )
539
+ for record in result
540
+ ]
541
+
542
+ async def delete_current_appeal_of_user(self, *, user_id: str) -> None:
543
+ query = """
544
+ delete from moderation.appeals
545
+ where true
546
+ and user_id = $1
547
+ and status = 'pending'
548
+ """
549
+ async with self.write_database.get_connection() as connection:
550
+ await connection.execute(query, user_id)
551
+
552
+ # Internal Notes
553
+ async def insert_internal_note(
554
+ self,
555
+ *,
556
+ actor_id: str,
557
+ target_user_id: str,
558
+ content: str,
559
+ ) -> InternalNote:
560
+ query = """
561
+ insert into moderation.internal_notes (target_user_id, actor_id, content)
562
+ values ($1, $2, $3)
563
+ returning id, target_user_id, actor_id, content, created_at
564
+ """
565
+ async with self.write_database.get_connection() as connection:
566
+ result = await connection.fetchrow(query, target_user_id, actor_id, content)
567
+ assert result is not None
568
+ return InternalNote(
569
+ id=result["id"],
570
+ target_user_id=result["target_user_id"],
571
+ actor_id=result["actor_id"],
572
+ content=result["content"],
573
+ created_at=result["created_at"],
574
+ )
575
+
576
+ async def select_internal_note_by_id_strong(self, *, note_id: int, target_user_id: str) -> InternalNote | None:
577
+ query = """
578
+ select
579
+ id,
580
+ target_user_id,
581
+ actor_id,
582
+ content,
583
+ created_at
584
+ from moderation.internal_notes
585
+ where true
586
+ and id = $1
587
+ and target_user_id = $2
588
+ """
589
+ async with self.strong_read_database.get_connection() as connection:
590
+ result = await connection.fetchrow(query, note_id, target_user_id)
591
+ if result is None:
592
+ return None
593
+ return InternalNote(
594
+ id=result["id"],
595
+ target_user_id=result["target_user_id"],
596
+ actor_id=result["actor_id"],
597
+ content=result["content"],
598
+ created_at=result["created_at"],
599
+ )
600
+
601
+ async def select_internal_notes(self, *, target_user_id: str) -> list[InternalNote]:
602
+ query = """
603
+ select
604
+ id,
605
+ target_user_id,
606
+ actor_id,
607
+ content,
608
+ created_at
609
+ from moderation.internal_notes
610
+ where target_user_id = $1
611
+ order by created_at desc
612
+ """
613
+ async with self.read_database.get_connection() as connection:
614
+ result = await connection.fetch(query, target_user_id)
615
+ return [
616
+ InternalNote(
617
+ id=record["id"],
618
+ target_user_id=record["target_user_id"],
619
+ actor_id=record["actor_id"],
620
+ content=record["content"],
621
+ created_at=record["created_at"],
622
+ )
623
+ for record in result
624
+ ]
625
+
626
+ async def select_internal_notes_paginated(
627
+ self,
628
+ *,
629
+ target_user_id: str,
630
+ cursor: datetime,
631
+ limit: int,
632
+ ) -> list[InternalNote]:
633
+ query = """
634
+ select
635
+ id,
636
+ target_user_id,
637
+ actor_id,
638
+ content,
639
+ created_at
640
+ from moderation.internal_notes
641
+ where true
642
+ and target_user_id = $1
643
+ and created_at <= $2
644
+ order by created_at desc
645
+ limit $3
646
+ """
647
+ async with self.read_database.get_connection() as connection:
648
+ result = await connection.fetch(query, target_user_id, cursor, limit)
649
+ return [
650
+ InternalNote(
651
+ id=record["id"],
652
+ target_user_id=record["target_user_id"],
653
+ actor_id=record["actor_id"],
654
+ content=record["content"],
655
+ created_at=record["created_at"],
656
+ )
657
+ for record in result
658
+ ]
659
+
660
+ async def delete_internal_note(self, *, note_id: int, target_user_id: str) -> None:
661
+ query = """
662
+ delete from moderation.internal_notes
663
+ where true
664
+ and id = $1
665
+ and target_user_id = $2
666
+ """
667
+ async with self.write_database.get_connection() as connection:
668
+ await connection.execute(query, note_id, target_user_id)
669
+
670
+ # User Moderation
671
+ async def _select_user_restriction_mapping_with_database(
672
+ self, *, user_ids: set[str], database: Postgres
673
+ ) -> dict[str, UserRestriction]:
674
+ if not user_ids:
675
+ return {}
676
+
677
+ query = """
678
+ select
679
+ user_id,
680
+ restriction_type,
681
+ category,
682
+ expires_at
683
+ from moderation.user_restrictions
684
+ where user_id = any($1)
685
+ """
686
+ async with database.get_connection() as connection:
687
+ result = await connection.fetch(query, user_ids)
688
+ return {
689
+ record["user_id"]: UserRestriction(
690
+ user_id=record["user_id"],
691
+ status=RestrictionType(record["restriction_type"]),
692
+ category=RestrictionCategory(record["category"]),
693
+ expires_at=record["expires_at"],
694
+ )
695
+ for record in result
696
+ }
697
+
698
+ async def select_user_restriction_mapping(self, *, user_ids: set[str]) -> dict[str, UserRestriction]:
699
+ return await self._select_user_restriction_mapping_with_database(
700
+ user_ids=user_ids,
701
+ database=self.read_database,
702
+ )
703
+
704
+ async def select_user_restriction_mapping_strong(self, *, user_ids: set[str]) -> dict[str, UserRestriction]:
705
+ return await self._select_user_restriction_mapping_with_database(
706
+ user_ids=user_ids,
707
+ database=self.strong_read_database,
708
+ )
709
+
710
+ async def delete_user(self, *, user_id: str) -> None:
711
+ restriction_query = """
712
+ delete from moderation.user_restrictions
713
+ where user_id = $1
714
+ """
715
+ restriction_history_query = """
716
+ delete from moderation.restriction_history
717
+ where user_id = $1
718
+ """
719
+ report_query = """
720
+ delete from moderation.user_reports
721
+ where target_id = $1
722
+ or reporter_id = $1
723
+ """
724
+ appeal_query = """
725
+ delete from moderation.appeals
726
+ where user_id = $1
727
+ """
728
+ internal_notes_query = """
729
+ delete from moderation.internal_notes
730
+ where target_user_id = $1
731
+ """
732
+ async with self.write_database.get_transaction() as connection:
733
+ await connection.execute(restriction_query, user_id)
734
+ await connection.execute(restriction_history_query, user_id)
735
+ await connection.execute(report_query, user_id)
736
+ await connection.execute(appeal_query, user_id)
737
+ await connection.execute(internal_notes_query, user_id)
738
+
739
+ # Moderation Actions
740
+ async def insert_moderation_action(
741
+ self,
742
+ *,
743
+ actor_id: str,
744
+ action_type: ModerationActionType,
745
+ target_user_id: str | None = None,
746
+ action_metadata: dict[str, Any] | None = None,
747
+ ) -> None:
748
+ metadata = action_metadata if action_metadata is not None else {}
749
+ query = """
750
+ insert into moderation.moderation_actions (actor_id, action_type, target_user_id, action_metadata)
751
+ values ($1, $2, $3, $4)
752
+ """
753
+ async with self.write_database.get_connection() as connection:
754
+ await connection.execute(query, actor_id, action_type, target_user_id, metadata)
755
+
756
+ async def select_moderation_actions_strong(
757
+ self,
758
+ *,
759
+ actor_id: str,
760
+ cursor: datetime,
761
+ limit: int,
762
+ ) -> list[ModerationAction]:
763
+ query = """
764
+ select
765
+ id,
766
+ actor_id,
767
+ action_type,
768
+ target_user_id,
769
+ action_metadata,
770
+ created_at
771
+ from moderation.moderation_actions
772
+ where true
773
+ and actor_id = $3
774
+ and created_at <= $1
775
+ order by created_at desc
776
+ limit $2
777
+ """
778
+ async with self.strong_read_database.get_connection() as connection:
779
+ result = await connection.fetch(query, cursor, limit, actor_id)
780
+ return [
781
+ ModerationAction(
782
+ id=record["id"],
783
+ actor_id=record["actor_id"],
784
+ action_type=ModerationActionType(record["action_type"]),
785
+ target_user_id=record["target_user_id"],
786
+ action_metadata=record["action_metadata"],
787
+ created_at=record["created_at"],
788
+ )
789
+ for record in result
790
+ ]
791
+
792
+ async def select_restriction_history(
793
+ self,
794
+ *,
795
+ user_id: str,
796
+ cursor: datetime,
797
+ limit: int,
798
+ ) -> list[RestrictionHistory]:
799
+ query = """
800
+ select
801
+ user_id,
802
+ action,
803
+ restriction_type,
804
+ category,
805
+ reason,
806
+ actor_id,
807
+ created_at
808
+ from moderation.restriction_history
809
+ where true
810
+ and user_id = $1
811
+ and created_at <= $2
812
+ order by created_at desc
813
+ limit $3
814
+ """
815
+ async with self.read_database.get_connection() as connection:
816
+ result = await connection.fetch(query, user_id, cursor, limit)
817
+ return [
818
+ RestrictionHistory(
819
+ user_id=record["user_id"],
820
+ action=HistoryAction(record["action"]),
821
+ restriction_type=RestrictionType(record["restriction_type"]),
822
+ category=RestrictionCategory(record["category"]),
823
+ reason=record["reason"],
824
+ actor_id=record["actor_id"],
825
+ created_at=record["created_at"],
826
+ )
827
+ for record in result
828
+ ]