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,137 @@
1
+ from datetime import datetime
2
+ from typing import Annotated, Literal
3
+
4
+ from fastapi.exceptions import RequestValidationError
5
+ from pydantic import BaseModel, Field, RootModel, ValidationError
6
+ from ulid import ULID
7
+
8
+ from core_framework.api.schemas import BasePatchRequest
9
+ from core_framework.api.users.shared.schemas import (
10
+ AvatarMixin,
11
+ BannerMixin,
12
+ Bio,
13
+ DisplayName,
14
+ UserID,
15
+ Username,
16
+ UserReference,
17
+ UserStatus,
18
+ )
19
+ from core_framework.domains.moderation import RestrictionCategory, RestrictionType
20
+ from core_framework.domains.user import ProfileVisibility, UserRole
21
+
22
+
23
+ class UserSummaryResponse(AvatarMixin):
24
+ user_id: UserID
25
+ username: str
26
+ display_name: DisplayName | None = None
27
+ role: UserRole
28
+ created_at: datetime
29
+
30
+
31
+ class UserIdentity(AvatarMixin):
32
+ user_id: UserID
33
+ username: str
34
+ display_name: DisplayName | None = None
35
+ role: UserRole
36
+ created_at: datetime
37
+
38
+
39
+ class UserRestriction(BaseModel):
40
+ status: RestrictionType
41
+ category: RestrictionCategory
42
+ expires_at: datetime | None
43
+
44
+
45
+ class InternalNoteRequest(BaseModel):
46
+ content: Annotated[str, Field(..., min_length=3, max_length=200)]
47
+
48
+
49
+ class InternalNoteResponse(BaseModel):
50
+ id: int
51
+ content: str
52
+ created_at: datetime
53
+
54
+
55
+ class InternalNoteDetailResponse(BaseModel):
56
+ id: int
57
+ content: str
58
+ created_at: datetime
59
+ actor: UserReference
60
+
61
+
62
+ class UserModeration(BaseModel):
63
+ restriction: UserRestriction
64
+ notes: list[InternalNoteResponse]
65
+
66
+
67
+ class AdminAccountResponse(BaseModel):
68
+ user_id: UserID
69
+ username: str
70
+ role: UserRole
71
+ email: str | None = None
72
+ email_verified: bool
73
+ deletion_scheduled_for: datetime | None = None
74
+
75
+
76
+ class AdminProfileResponse(AvatarMixin, BannerMixin):
77
+ display_name: DisplayName | None = None
78
+ bio: Bio | None = None
79
+ status: UserStatus | None = None
80
+ social_links: Annotated[dict[str, str], Field(default_factory=dict)]
81
+ profile_visibility: ProfileVisibility
82
+
83
+
84
+ class UserDetailResponse(BaseModel):
85
+ account: AdminAccountResponse
86
+ profile: AdminProfileResponse
87
+ moderation: UserModeration
88
+
89
+
90
+ class UserSearchQueryParams(BaseModel):
91
+ username: Annotated[str | None, Field(default=None, min_length=1, max_length=50)]
92
+ role: Annotated[UserRole | None, Field(default=None)]
93
+
94
+
95
+ class UsernameChangeRequest(BaseModel):
96
+ username: Username
97
+
98
+
99
+ class RoleChangeRequest(BaseModel):
100
+ role: Literal["member", "moderator"]
101
+
102
+
103
+ class AdminProfileUpdateRequest(BasePatchRequest):
104
+ display_name: DisplayName | None = None
105
+ avatar_id: ULID | None = None
106
+ banner_id: ULID | None = None
107
+ bio: Bio | None = None
108
+ status: UserStatus | None = None
109
+ social_links: dict[str, str] | None = None
110
+ profile_visibility: ProfileVisibility | None = None
111
+
112
+
113
+ class UserChangeHistoryItem(BaseModel):
114
+ user_id: UserID
115
+ actor: UserReference | None
116
+ entity_type: str
117
+ field: str
118
+ old_value: str | None
119
+ new_value: str | None
120
+ created_at: datetime
121
+
122
+
123
+ class NoteID(RootModel[int]):
124
+ root: Annotated[
125
+ int,
126
+ Field(
127
+ description="The ID of the internal note",
128
+ ge=1,
129
+ ),
130
+ ]
131
+
132
+
133
+ def validate_note_id(*, note_id: int) -> NoteID:
134
+ try:
135
+ return NoteID(root=note_id)
136
+ except ValidationError as e:
137
+ raise RequestValidationError(e.errors()) from e
File without changes
@@ -0,0 +1,21 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, status
4
+
5
+ from core_framework.api.auth.schemas import CreatedUserResponse
6
+ from core_framework.api.dependencies import RequiredFirebaseUser
7
+ from core_framework.application.auth.auth_service import register_user
8
+
9
+ router = APIRouter(
10
+ prefix="/auth",
11
+ tags=["auth"],
12
+ )
13
+
14
+
15
+ @router.post(
16
+ "/register",
17
+ status_code=status.HTTP_201_CREATED,
18
+ response_model=CreatedUserResponse,
19
+ )
20
+ async def post_register(firebase_user: RequiredFirebaseUser) -> Any:
21
+ return await register_user(user_id=firebase_user.user_id.root)
@@ -0,0 +1,28 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import BaseModel, EmailStr, Field, RootModel
4
+
5
+ from core_framework.api.users.shared.schemas import UserID, Username
6
+
7
+
8
+ class CountryCode(RootModel[str]):
9
+ root: Annotated[
10
+ str,
11
+ Field(
12
+ description="ISO 3166-1 alpha-2 country code",
13
+ pattern="^[A-Z]{2}$",
14
+ examples=["US", "GB"],
15
+ ),
16
+ ]
17
+
18
+
19
+ class CreatedUserResponse(BaseModel):
20
+ user_id: UserID
21
+ username: Username
22
+
23
+
24
+ class FirebaseUser(BaseModel):
25
+ user_id: UserID
26
+ email: EmailStr
27
+ email_verified: bool
28
+ sign_in_provider: str
@@ -0,0 +1,126 @@
1
+ from fastapi import APIRouter, BackgroundTasks, Depends, status
2
+ from ulid import ULID
3
+
4
+ from core_framework.api.comments.authenticated.schemas import (
5
+ CommentReportRequest,
6
+ CreateCommentRequest,
7
+ CreateReplyRequest,
8
+ UpdateCommentRequest,
9
+ )
10
+ from core_framework.api.dependencies import RequiredUserID, check_not_banned
11
+ from core_framework.application.comments.authenticated_service import (
12
+ add_comment_report,
13
+ create_comment,
14
+ create_reply,
15
+ delete_comment,
16
+ edit_comment,
17
+ like_comment,
18
+ mark_comment_stats_dirty,
19
+ remove_comment_report,
20
+ unlike_comment,
21
+ )
22
+
23
+ router = APIRouter(
24
+ prefix="/comments",
25
+ tags=["comments - authenticated"],
26
+ dependencies=[Depends(check_not_banned)],
27
+ )
28
+
29
+
30
+ @router.put("/{comment_id}/like", status_code=status.HTTP_204_NO_CONTENT)
31
+ async def put_comment_like(
32
+ comment_id: ULID,
33
+ user_id: RequiredUserID,
34
+ background_tasks: BackgroundTasks,
35
+ ) -> None:
36
+ await like_comment(comment_id=str(comment_id), user_id=user_id.root)
37
+ background_tasks.add_task(mark_comment_stats_dirty, comment_id=str(comment_id))
38
+
39
+
40
+ @router.delete("/{comment_id}/like", status_code=status.HTTP_204_NO_CONTENT)
41
+ async def delete_comment_like(
42
+ comment_id: ULID,
43
+ user_id: RequiredUserID,
44
+ background_tasks: BackgroundTasks,
45
+ ) -> None:
46
+ await unlike_comment(comment_id=str(comment_id), user_id=user_id.root)
47
+ background_tasks.add_task(mark_comment_stats_dirty, comment_id=str(comment_id))
48
+
49
+
50
+ @router.post("/{comment_id}/report", status_code=status.HTTP_204_NO_CONTENT)
51
+ async def post_comment_report(
52
+ comment_id: ULID,
53
+ user_id: RequiredUserID,
54
+ report_request: CommentReportRequest,
55
+ background_tasks: BackgroundTasks,
56
+ ) -> None:
57
+ await add_comment_report(
58
+ reporter_id=user_id.root,
59
+ comment_id=str(comment_id),
60
+ category=report_request.category,
61
+ reason=report_request.reason,
62
+ )
63
+ background_tasks.add_task(mark_comment_stats_dirty, comment_id=str(comment_id))
64
+
65
+
66
+ @router.delete("/{comment_id}/report", status_code=status.HTTP_204_NO_CONTENT)
67
+ async def delete_comment_report(
68
+ comment_id: ULID,
69
+ user_id: RequiredUserID,
70
+ background_tasks: BackgroundTasks,
71
+ ) -> None:
72
+ await remove_comment_report(reporter_id=user_id.root, comment_id=str(comment_id))
73
+ background_tasks.add_task(mark_comment_stats_dirty, comment_id=str(comment_id))
74
+
75
+
76
+ @router.patch("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
77
+ async def patch_comment(
78
+ comment_id: ULID,
79
+ user_id: RequiredUserID,
80
+ request_body: UpdateCommentRequest,
81
+ ) -> None:
82
+ validated_update_request = request_body.model_dump(mode="json", exclude_unset=True)
83
+ await edit_comment(
84
+ comment_id=str(comment_id),
85
+ author_id=user_id.root,
86
+ validated_update_request=validated_update_request,
87
+ )
88
+
89
+
90
+ @router.delete("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
91
+ async def delete_comment_route(
92
+ comment_id: ULID,
93
+ user_id: RequiredUserID,
94
+ background_tasks: BackgroundTasks,
95
+ ) -> None:
96
+ parent_comment_id = await delete_comment(
97
+ comment_id=str(comment_id),
98
+ author_id=user_id.root,
99
+ )
100
+ if parent_comment_id is not None:
101
+ background_tasks.add_task(mark_comment_stats_dirty, comment_id=parent_comment_id)
102
+
103
+
104
+ @router.post("", status_code=status.HTTP_201_CREATED)
105
+ async def post_comment(user_id: RequiredUserID, request_body: CreateCommentRequest) -> None:
106
+ await create_comment(
107
+ author_id=user_id.root,
108
+ content=request_body.content.root,
109
+ subject_type=request_body.subject_type,
110
+ subject_id=request_body.subject_id,
111
+ )
112
+
113
+
114
+ @router.post("/{comment_id}/replies", status_code=status.HTTP_201_CREATED)
115
+ async def post_reply(
116
+ comment_id: ULID,
117
+ user_id: RequiredUserID,
118
+ request_body: CreateReplyRequest,
119
+ background_tasks: BackgroundTasks,
120
+ ) -> None:
121
+ await create_reply(
122
+ author_id=user_id.root,
123
+ content=request_body.content.root,
124
+ parent_comment_id=str(comment_id),
125
+ )
126
+ background_tasks.add_task(mark_comment_stats_dirty, comment_id=str(comment_id))
@@ -0,0 +1,27 @@
1
+ from typing import Annotated
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from core_framework.api.comments.schemas import CommentContent
6
+ from core_framework.api.schemas import BasePatchRequest
7
+ from core_framework.domains.comment import CommentSubjectType
8
+ from core_framework.domains.moderation import ReportCategory
9
+
10
+
11
+ class UpdateCommentRequest(BasePatchRequest):
12
+ content: CommentContent | None = None
13
+
14
+
15
+ class CommentReportRequest(BaseModel):
16
+ category: ReportCategory
17
+ reason: Annotated[str, Field(default="", max_length=250)]
18
+
19
+
20
+ class CreateCommentRequest(BaseModel):
21
+ content: CommentContent
22
+ subject_type: CommentSubjectType
23
+ subject_id: Annotated[str, Field(min_length=1)]
24
+
25
+
26
+ class CreateReplyRequest(BaseModel):
27
+ content: CommentContent
@@ -0,0 +1,103 @@
1
+ from typing import Annotated, Any
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
4
+ from ulid import ULID
5
+
6
+ from core_framework.api.comments.public.schemas import CommentResponse
7
+ from core_framework.api.dependencies import OptionalUserID
8
+ from core_framework.api.users.shared.schemas import validate_user_id
9
+ from core_framework.application.comments.public_service import (
10
+ retrieve_comment_by_id,
11
+ retrieve_comments,
12
+ retrieve_comments_by_user_id,
13
+ retrieve_replies,
14
+ )
15
+ from core_framework.core.pagination import (
16
+ PaginationCursorType,
17
+ PaginationResponse,
18
+ TokenCursorQueryParams,
19
+ paginate_cursor,
20
+ )
21
+ from core_framework.domains.comment import CommentSubjectType
22
+
23
+ router = APIRouter(
24
+ prefix="/comments",
25
+ tags=["comments - public"],
26
+ )
27
+
28
+
29
+ @router.get("/users/{user_id}", response_model=PaginationResponse[CommentResponse])
30
+ async def get_comments_by_user_id(
31
+ user_id: str,
32
+ request: Request,
33
+ viewer_id: OptionalUserID,
34
+ pagination_params: Annotated[TokenCursorQueryParams, Depends()],
35
+ ) -> Any:
36
+ validated_user_id = validate_user_id(user_id=user_id)
37
+ comments = await retrieve_comments_by_user_id(
38
+ user_id=validated_user_id.root,
39
+ cursor=pagination_params.cursor,
40
+ limit=pagination_params.limit,
41
+ viewer_id=viewer_id.root if viewer_id else None,
42
+ )
43
+ return paginate_cursor(
44
+ request=request,
45
+ items=comments,
46
+ limit=pagination_params.limit,
47
+ cursor_field=PaginationCursorType.CREATED_AT,
48
+ retrieved=pagination_params.retrieved,
49
+ )
50
+
51
+
52
+ @router.get("", response_model=PaginationResponse[CommentResponse])
53
+ async def get_comments(
54
+ request: Request,
55
+ subject_type: CommentSubjectType,
56
+ subject_id: Annotated[str, Query(min_length=1)],
57
+ pagination_params: Annotated[TokenCursorQueryParams, Depends()],
58
+ viewer_id: OptionalUserID,
59
+ ) -> Any:
60
+ comments = await retrieve_comments(
61
+ subject_type=subject_type,
62
+ subject_id=subject_id,
63
+ cursor=pagination_params.cursor,
64
+ limit=pagination_params.limit,
65
+ viewer_id=viewer_id.root if viewer_id else None,
66
+ )
67
+ return paginate_cursor(
68
+ request=request,
69
+ items=comments,
70
+ limit=pagination_params.limit,
71
+ cursor_field=PaginationCursorType.CREATED_AT,
72
+ retrieved=pagination_params.retrieved,
73
+ )
74
+
75
+
76
+ @router.get("/{comment_id}/replies", response_model=PaginationResponse[CommentResponse])
77
+ async def get_replies(
78
+ comment_id: ULID,
79
+ request: Request,
80
+ pagination_params: Annotated[TokenCursorQueryParams, Depends()],
81
+ viewer_id: OptionalUserID,
82
+ ) -> Any:
83
+ replies = await retrieve_replies(
84
+ parent_comment_id=str(comment_id),
85
+ cursor=pagination_params.cursor,
86
+ limit=pagination_params.limit,
87
+ viewer_id=viewer_id.root if viewer_id else None,
88
+ )
89
+ return paginate_cursor(
90
+ request=request,
91
+ items=replies,
92
+ limit=pagination_params.limit,
93
+ cursor_field=PaginationCursorType.CREATED_AT,
94
+ retrieved=pagination_params.retrieved,
95
+ )
96
+
97
+
98
+ @router.get("/{comment_id}", response_model=CommentResponse)
99
+ async def get_comment_by_id(comment_id: ULID, viewer_id: OptionalUserID) -> Any:
100
+ comment = await retrieve_comment_by_id(comment_id=str(comment_id), viewer_id=viewer_id.root if viewer_id else None)
101
+ if comment is None:
102
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
103
+ return comment
@@ -0,0 +1,36 @@
1
+ from datetime import datetime
2
+ from typing import Annotated
3
+
4
+ from pydantic import BaseModel, Field
5
+ from ulid import ULID
6
+
7
+ from core_framework.api.comments.schemas import CommentContent
8
+ from core_framework.api.users.shared.schemas import UserReference
9
+
10
+
11
+ class CommentStatsResponse(BaseModel):
12
+ like_count: Annotated[int, Field(ge=0)] = 0
13
+ reply_count: Annotated[int, Field(ge=0)] = 0
14
+
15
+
16
+ class CommentViewerContext(BaseModel):
17
+ is_liked: bool
18
+ is_reported: bool
19
+
20
+
21
+ class AuthorContext(BaseModel):
22
+ can_edit: bool
23
+ can_delete: bool
24
+
25
+
26
+ class CommentResponse(BaseModel):
27
+ id: ULID
28
+ author: UserReference
29
+ content: CommentContent
30
+ stats: CommentStatsResponse
31
+ can_reply: bool
32
+ engagement_allowed: bool
33
+ edited_at: datetime | None
34
+ created_at: datetime
35
+ viewer_context: CommentViewerContext | None
36
+ author_context: AuthorContext | None
@@ -0,0 +1,9 @@
1
+ from fastapi import APIRouter
2
+
3
+ from core_framework.api.comments.authenticated.router import router as authenticated_router
4
+ from core_framework.api.comments.public.router import router as public_router
5
+
6
+ router = APIRouter()
7
+
8
+ router.include_router(public_router)
9
+ router.include_router(authenticated_router)
@@ -0,0 +1,17 @@
1
+ from typing import Annotated, Final
2
+
3
+ from pydantic import Field, RootModel, field_validator
4
+
5
+ MAX_COMMENT_CONTENT_LENGTH: Final[int] = 5000
6
+
7
+
8
+ class CommentContent(RootModel[str]):
9
+ root: Annotated[str, Field(min_length=1, max_length=MAX_COMMENT_CONTENT_LENGTH)]
10
+
11
+ @field_validator("root", mode="after")
12
+ @classmethod
13
+ def validate_not_blank(cls, v: str) -> str:
14
+ s = v.strip()
15
+ if not s:
16
+ raise ValueError("Comment content must not be blank")
17
+ return s
@@ -0,0 +1,168 @@
1
+ from typing import Annotated, Any, Final
2
+
3
+ from async_lru import alru_cache
4
+ from asyncer import asyncify
5
+ from fastapi import Depends, Header, Security
6
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
7
+ from firebase_admin import auth
8
+
9
+ from core_framework.api.auth.schemas import CountryCode, FirebaseUser
10
+ from core_framework.api.users.shared.schemas import UserID
11
+ from core_framework.application.auth.access_service import get_user_detail
12
+ from core_framework.application.auth.models import UserDetail
13
+ from core_framework.application.shared.exceptions import ForbiddenException, UnauthorizedException
14
+ from core_framework.application.shared.user_agent import parse_user_agent
15
+ from core_framework.application.users.admin_service import retrieve_admin_user_ids
16
+ from core_framework.core.context import user_id
17
+ from core_framework.domains.moderation import RestrictionType
18
+ from core_framework.domains.user import UserIdentity
19
+
20
+ MAX_REFERRER_LENGTH: Final[int] = 512
21
+
22
+ bearer_scheme = HTTPBearer(auto_error=False)
23
+
24
+
25
+ @alru_cache(maxsize=5000, ttl=300) # ~10MB per worker, 5 minutes TTL
26
+ async def _verify_id_token(token: str) -> dict[str, Any]:
27
+ return await asyncify(auth.verify_id_token)(token)
28
+
29
+
30
+ def _build_firebase_user_from_claims(user_dict: dict[str, Any]) -> FirebaseUser | None:
31
+ uid = user_dict.get("uid")
32
+ email = user_dict.get("email")
33
+ email_verified = user_dict.get("email_verified")
34
+ firebase_claim = user_dict.get("firebase")
35
+
36
+ if not isinstance(uid, str):
37
+ return None
38
+ if not isinstance(email, str):
39
+ return None
40
+ if not isinstance(email_verified, bool):
41
+ return None
42
+ if not isinstance(firebase_claim, dict):
43
+ return None
44
+
45
+ sign_in_provider = firebase_claim.get("sign_in_provider")
46
+ if not isinstance(sign_in_provider, str):
47
+ return None
48
+
49
+ try:
50
+ return FirebaseUser(
51
+ user_id=UserID(uid),
52
+ email=email,
53
+ email_verified=email_verified,
54
+ sign_in_provider=sign_in_provider,
55
+ )
56
+ except TypeError, ValueError:
57
+ return None
58
+
59
+
60
+ async def _get_bearer_token(
61
+ creds: Annotated[HTTPAuthorizationCredentials | None, Security(bearer_scheme)],
62
+ ) -> str | None:
63
+ if not creds:
64
+ return None
65
+
66
+ if creds.scheme.lower() != "bearer":
67
+ raise UnauthorizedException("Invalid token scheme")
68
+
69
+ return creds.credentials
70
+
71
+
72
+ async def _get_firebase_user(token: Annotated[str | None, Depends(_get_bearer_token)]) -> FirebaseUser | None:
73
+ if not token:
74
+ return None
75
+
76
+ try:
77
+ user_dict = await _verify_id_token(token)
78
+ except (
79
+ ValueError,
80
+ auth.InvalidIdTokenError,
81
+ auth.ExpiredIdTokenError,
82
+ auth.RevokedIdTokenError,
83
+ auth.CertificateFetchError,
84
+ auth.UserDisabledError,
85
+ ):
86
+ return None
87
+
88
+ firebase_user = _build_firebase_user_from_claims(user_dict)
89
+ if firebase_user is None:
90
+ return None
91
+
92
+ user_id.set(firebase_user.user_id.root)
93
+ return firebase_user
94
+
95
+
96
+ async def _get_required_firebase_user(
97
+ user: Annotated[FirebaseUser | None, Depends(_get_firebase_user)],
98
+ ) -> FirebaseUser:
99
+ if not user:
100
+ raise UnauthorizedException("Unauthorized")
101
+ return user
102
+
103
+
104
+ async def _get_user_id(user: Annotated[FirebaseUser | None, Depends(_get_firebase_user)]) -> UserID | None:
105
+ if not user:
106
+ return None
107
+ return user.user_id
108
+
109
+
110
+ async def _get_required_user_id(user_id: Annotated[UserID | None, Depends(_get_user_id)]) -> UserID:
111
+ if not user_id:
112
+ raise UnauthorizedException("Unauthorized")
113
+ return user_id
114
+
115
+
116
+ async def check_admin_role(user_id: Annotated[UserID, Depends(_get_required_user_id)]) -> None:
117
+ if user_id.root not in await retrieve_admin_user_ids():
118
+ raise ForbiddenException("Admin access required")
119
+
120
+
121
+ async def _get_user_detail(user_id: Annotated[UserID, Depends(_get_required_user_id)]) -> UserDetail:
122
+ return await get_user_detail(user_id=user_id.root)
123
+
124
+
125
+ async def check_not_banned(user_detail: Annotated[UserDetail, Depends(_get_user_detail)]) -> None:
126
+ if (
127
+ user_detail.identity == UserIdentity.DEFAULT
128
+ or user_detail.moderation.restriction.status == RestrictionType.BANNED
129
+ ):
130
+ raise ForbiddenException()
131
+
132
+
133
+ async def _get_request_context(
134
+ firebase_user: Annotated[FirebaseUser | None, Depends(_get_firebase_user)],
135
+ user_agent: Annotated[str | None, Header()] = None,
136
+ cf_ipcountry: Annotated[CountryCode | None, Header()] = None,
137
+ referrer: Annotated[str | None, Header(alias="Referer")] = None,
138
+ ) -> dict[str, str]:
139
+ metadata: dict[str, str] = {}
140
+
141
+ if firebase_user:
142
+ metadata["viewer_id"] = firebase_user.user_id.root
143
+ metadata["provider"] = firebase_user.sign_in_provider
144
+ if cf_ipcountry:
145
+ metadata["country_code"] = cf_ipcountry.root
146
+ if referrer:
147
+ metadata["referrer"] = referrer[:MAX_REFERRER_LENGTH]
148
+
149
+ client_info = parse_user_agent(user_agent)
150
+ metadata.update(client_info)
151
+
152
+ return {k: v for k, v in metadata.items() if v is not None}
153
+
154
+
155
+ RequiredFirebaseUser = Annotated[FirebaseUser, Depends(_get_required_firebase_user)]
156
+ OptionalUserID = Annotated[UserID | None, Depends(_get_user_id)]
157
+ RequiredUserID = Annotated[UserID, Depends(_get_required_user_id)]
158
+ RequestContext = Annotated[dict[str, str], Depends(_get_request_context)]
159
+
160
+
161
+ __all__ = [
162
+ "RequestContext",
163
+ "RequiredFirebaseUser",
164
+ "OptionalUserID",
165
+ "RequiredUserID",
166
+ "check_admin_role",
167
+ "check_not_banned",
168
+ ]