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.
- core_framework/__init__.py +0 -0
- core_framework/alembic/comment/alembic/README +1 -0
- core_framework/alembic/comment/alembic/env.py +72 -0
- core_framework/alembic/comment/alembic/script.py.mako +28 -0
- core_framework/alembic/comment/alembic/versions/30334fd1347b_init.py +59 -0
- core_framework/alembic/comment/alembic/versions/a2b3c4d5e6f7_improve_comment_indexes.py +54 -0
- core_framework/alembic/comment/alembic/versions/bcc8e00cfc8b_add_extra_tables.py +64 -0
- core_framework/alembic/comment/alembic/versions/d1e2f3a4b5c6_add_comment_stats_dirty_table.py +29 -0
- core_framework/alembic/comment/alembic/versions/e3f4a5b6c7d8_cascade_delete_comment_descendants.py +49 -0
- core_framework/alembic/comment/alembic/versions/f7e6d5c4b3a2_comments_path_to_ltree.py +60 -0
- core_framework/alembic/comment/alembic.ini +52 -0
- core_framework/alembic/extension/alembic/README +1 -0
- core_framework/alembic/extension/alembic/env.py +98 -0
- core_framework/alembic/extension/alembic/script.py.mako +28 -0
- core_framework/alembic/extension/alembic/versions/0389226049cb_add_pg_trgm_extension.py +25 -0
- core_framework/alembic/extension/alembic/versions/5dc58b016cf5_add_citext_extension.py +25 -0
- core_framework/alembic/extension/alembic/versions/b0ba0d8a284e_add_pg_stat_statements_extension.py +25 -0
- core_framework/alembic/extension/alembic/versions/c9d0e1f2a3b4_add_ltree_extension.py +25 -0
- core_framework/alembic/extension/alembic.ini +147 -0
- core_framework/alembic/moderation/alembic/README +1 -0
- core_framework/alembic/moderation/alembic/env.py +98 -0
- core_framework/alembic/moderation/alembic/script.py.mako +28 -0
- core_framework/alembic/moderation/alembic/versions/085ba9021850_add_category_to_user_restrictions.py +93 -0
- core_framework/alembic/moderation/alembic/versions/5f9e4fc14a41_create_moderation_appeals_table.py +69 -0
- core_framework/alembic/moderation/alembic/versions/63e37381e73b_add_user_reports_table.py +33 -0
- core_framework/alembic/moderation/alembic/versions/6a2ae31b7ac6_add_moderation_actions_table.py +34 -0
- core_framework/alembic/moderation/alembic/versions/716aa1735c03_improve_indexes.py +36 -0
- core_framework/alembic/moderation/alembic/versions/7d243ddbfde1_add_post_reports_table.py +35 -0
- core_framework/alembic/moderation/alembic/versions/8fba1f72dd46_add_indexes.py +64 -0
- core_framework/alembic/moderation/alembic/versions/95cc35a51984_update_restriction_history.py +91 -0
- core_framework/alembic/moderation/alembic/versions/9ad79d0af730_add_unique_constraint_user_reports_.py +28 -0
- core_framework/alembic/moderation/alembic/versions/a5e569f5df1a_create_user_restrictions_table.py +38 -0
- core_framework/alembic/moderation/alembic/versions/b2c3d4e5f6a7_add_indexes.py +42 -0
- core_framework/alembic/moderation/alembic/versions/c3d4e5f6a7b8_improve_report_indexes.py +48 -0
- core_framework/alembic/moderation/alembic/versions/d4af74643ff5_add_internal_notes_table.py +38 -0
- core_framework/alembic/moderation/alembic/versions/db20f2fb7390_add_comment_reports_table.py +35 -0
- core_framework/alembic/moderation/alembic/versions/e66226952ea6_add_report_category_to_user_reports_.py +54 -0
- core_framework/alembic/moderation/alembic/versions/f5e8cb275c30_enforce_1_pending_appeal.py +29 -0
- core_framework/alembic/moderation/alembic/versions/fe1faad2832d_create_restriction_history_table.py +69 -0
- core_framework/alembic/moderation/alembic.ini +147 -0
- core_framework/alembic/post/alembic/README +1 -0
- core_framework/alembic/post/alembic/env.py +97 -0
- core_framework/alembic/post/alembic/script.py.mako +28 -0
- core_framework/alembic/post/alembic/versions/51542673f5c8_add_tables_for_muted_banned_users.py +41 -0
- core_framework/alembic/post/alembic/versions/5beeeae40a4a_add_post_views_table.py +45 -0
- core_framework/alembic/post/alembic/versions/620971509a8b_init.py +55 -0
- core_framework/alembic/post/alembic/versions/a1b2c3d4e5f6_add_indexes.py +44 -0
- core_framework/alembic/post/alembic/versions/c1d2e3f4a5b6_add_post_hashtags_table.py +36 -0
- core_framework/alembic/post/alembic/versions/e56723f2afff_add_post_stats_table.py +39 -0
- core_framework/alembic/post/alembic/versions/fbc723ac58cc_add_post_likes_table.py +32 -0
- core_framework/alembic/post/alembic.ini +149 -0
- core_framework/alembic/user/alembic/README +1 -0
- core_framework/alembic/user/alembic/env.py +98 -0
- core_framework/alembic/user/alembic/script.py.mako +28 -0
- core_framework/alembic/user/alembic/versions/1a8bb99726ed_remove_avatar_id_from_users.py +81 -0
- core_framework/alembic/user/alembic/versions/2ccacf455941_improve_indexes.py +34 -0
- core_framework/alembic/user/alembic/versions/47f47ce2110e_create_user_deletions_table.py +31 -0
- core_framework/alembic/user/alembic/versions/5976db3f0175_drop_user_states.py +26 -0
- core_framework/alembic/user/alembic/versions/62417002cf32_add_indexes.py +46 -0
- core_framework/alembic/user/alembic/versions/6f7ccf3c226b_refactor_user_login_events.py +66 -0
- core_framework/alembic/user/alembic/versions/73432817015b_add_user_preferences_table.py +33 -0
- core_framework/alembic/user/alembic/versions/765bc01a7a59_create_user_blocks_table.py +33 -0
- core_framework/alembic/user/alembic/versions/7a56631f9927_create_user_login_events_table.py +49 -0
- core_framework/alembic/user/alembic/versions/831611e589bc_create_user_state.py +31 -0
- core_framework/alembic/user/alembic/versions/83c98ab2a779_add_user_profiles_table.py +88 -0
- core_framework/alembic/user/alembic/versions/8a94362cad6d_create_user_role.py +31 -0
- core_framework/alembic/user/alembic/versions/94b973923895_add_user_change_history_table.py +97 -0
- core_framework/alembic/user/alembic/versions/cbc0f4efe84f_add_avatar_id_column_to_users_table.py +31 -0
- core_framework/alembic/user/alembic/versions/d8b98ac6b073_add_index_for_get_admin_user_ids_query.py +29 -0
- core_framework/alembic/user/alembic/versions/ddb70cc09d16_create_user_states_table.py +34 -0
- core_framework/alembic/user/alembic/versions/f9ba10815ecd_add_users_table.py +33 -0
- core_framework/alembic/user/alembic.ini +147 -0
- core_framework/api/__init__.py +0 -0
- core_framework/api/admin/__init__.py +0 -0
- core_framework/api/admin/comments/router.py +69 -0
- core_framework/api/admin/comments/schemas.py +53 -0
- core_framework/api/admin/moderation/__init__.py +0 -0
- core_framework/api/admin/moderation/router.py +205 -0
- core_framework/api/admin/moderation/schemas.py +110 -0
- core_framework/api/admin/posts/router.py +62 -0
- core_framework/api/admin/posts/schemas.py +29 -0
- core_framework/api/admin/router.py +17 -0
- core_framework/api/admin/users/__init__.py +0 -0
- core_framework/api/admin/users/router.py +181 -0
- core_framework/api/admin/users/schemas.py +137 -0
- core_framework/api/auth/__init__.py +0 -0
- core_framework/api/auth/router.py +21 -0
- core_framework/api/auth/schemas.py +28 -0
- core_framework/api/comments/authenticated/router.py +126 -0
- core_framework/api/comments/authenticated/schemas.py +27 -0
- core_framework/api/comments/public/router.py +103 -0
- core_framework/api/comments/public/schemas.py +36 -0
- core_framework/api/comments/router.py +9 -0
- core_framework/api/comments/schemas.py +17 -0
- core_framework/api/dependencies.py +168 -0
- core_framework/api/events/router.py +39 -0
- core_framework/api/events/schemas.py +20 -0
- core_framework/api/posts/authenticated/router.py +83 -0
- core_framework/api/posts/authenticated/schemas.py +37 -0
- core_framework/api/posts/public/router.py +100 -0
- core_framework/api/posts/public/schemas.py +39 -0
- core_framework/api/posts/router.py +9 -0
- core_framework/api/posts/schemas.py +39 -0
- core_framework/api/router.py +19 -0
- core_framework/api/schemas.py +9 -0
- core_framework/api/system/__init__.py +0 -0
- core_framework/api/system/router.py +108 -0
- core_framework/api/users/__init__.py +0 -0
- core_framework/api/users/authenticated/__init__.py +0 -0
- core_framework/api/users/authenticated/router.py +244 -0
- core_framework/api/users/authenticated/schemas.py +81 -0
- core_framework/api/users/public/__init__.py +0 -0
- core_framework/api/users/public/router.py +25 -0
- core_framework/api/users/public/schemas.py +7 -0
- core_framework/api/users/router.py +9 -0
- core_framework/api/users/shared/schemas.py +174 -0
- core_framework/application/__init__.py +0 -0
- core_framework/application/auth/__init__.py +0 -0
- core_framework/application/auth/access_service.py +26 -0
- core_framework/application/auth/auth_service.py +10 -0
- core_framework/application/auth/models.py +10 -0
- core_framework/application/bootstrap.py +19 -0
- core_framework/application/comments/admin_service.py +236 -0
- core_framework/application/comments/aggregation_service.py +28 -0
- core_framework/application/comments/authenticated_service.py +89 -0
- core_framework/application/comments/public_service.py +218 -0
- core_framework/application/events/README.md +26 -0
- core_framework/application/events/event_service.py +51 -0
- core_framework/application/events/event_token.py +46 -0
- core_framework/application/events/models.py +9 -0
- core_framework/application/moderation/__init__.py +0 -0
- core_framework/application/moderation/appeal_service.py +98 -0
- core_framework/application/moderation/moderator_service.py +46 -0
- core_framework/application/moderation/report_service.py +180 -0
- core_framework/application/moderation/scheduled_service.py +5 -0
- core_framework/application/moderation/user_service.py +180 -0
- core_framework/application/posts/admin_service.py +104 -0
- core_framework/application/posts/aggregation_service.py +28 -0
- core_framework/application/posts/authenticated_service.py +72 -0
- core_framework/application/posts/public_service.py +197 -0
- core_framework/application/shared/__init__.py +0 -0
- core_framework/application/shared/enums.py +16 -0
- core_framework/application/shared/exceptions.py +16 -0
- core_framework/application/shared/user_agent.py +24 -0
- core_framework/application/users/__init__.py +0 -0
- core_framework/application/users/admin_service.py +298 -0
- core_framework/application/users/authenticated_service.py +179 -0
- core_framework/application/users/public_service.py +7 -0
- core_framework/application/users/scheduled_service.py +5 -0
- core_framework/bundled_alembic.py +57 -0
- core_framework/core/__init__.py +37 -0
- core_framework/core/cache.py +234 -0
- core_framework/core/context.py +14 -0
- core_framework/core/database.py +111 -0
- core_framework/core/exception_handlers/__init__.py +3 -0
- core_framework/core/exception_handlers/comment.py +99 -0
- core_framework/core/exception_handlers/common.py +5 -0
- core_framework/core/exception_handlers/moderation.py +104 -0
- core_framework/core/exception_handlers/post.py +54 -0
- core_framework/core/exception_handlers/setup.py +80 -0
- core_framework/core/exception_handlers/user.py +72 -0
- core_framework/core/http_client.py +64 -0
- core_framework/core/logging.py +99 -0
- core_framework/core/middleware.py +64 -0
- core_framework/core/observability.py +36 -0
- core_framework/core/pagination.py +203 -0
- core_framework/core/redis.py +135 -0
- core_framework/core/runtime.py +66 -0
- core_framework/core/settings.py +189 -0
- core_framework/domains/__init__.py +0 -0
- core_framework/domains/comment/README.md +243 -0
- core_framework/domains/comment/__init__.py +25 -0
- core_framework/domains/comment/constants.py +3 -0
- core_framework/domains/comment/dependencies.py +29 -0
- core_framework/domains/comment/enums.py +11 -0
- core_framework/domains/comment/exceptions.py +31 -0
- core_framework/domains/comment/models.py +54 -0
- core_framework/domains/comment/repository.py +947 -0
- core_framework/domains/comment/service.py +259 -0
- core_framework/domains/moderation/README.md +138 -0
- core_framework/domains/moderation/__init__.py +47 -0
- core_framework/domains/moderation/dependencies.py +29 -0
- core_framework/domains/moderation/enums.py +62 -0
- core_framework/domains/moderation/exceptions.py +31 -0
- core_framework/domains/moderation/models.py +94 -0
- core_framework/domains/moderation/repository.py +828 -0
- core_framework/domains/moderation/service.py +334 -0
- core_framework/domains/post/README.md +182 -0
- core_framework/domains/post/__init__.py +22 -0
- core_framework/domains/post/constants.py +3 -0
- core_framework/domains/post/dependencies.py +29 -0
- core_framework/domains/post/enums.py +18 -0
- core_framework/domains/post/exceptions.py +21 -0
- core_framework/domains/post/models.py +53 -0
- core_framework/domains/post/repository.py +791 -0
- core_framework/domains/post/service.py +204 -0
- core_framework/domains/user/README.md +74 -0
- core_framework/domains/user/__init__.py +39 -0
- core_framework/domains/user/constants.py +8 -0
- core_framework/domains/user/dependencies.py +29 -0
- core_framework/domains/user/enums.py +19 -0
- core_framework/domains/user/exceptions.py +31 -0
- core_framework/domains/user/models.py +124 -0
- core_framework/domains/user/repository.py +612 -0
- core_framework/domains/user/service.py +257 -0
- core_framework/domains/user/utils.py +182 -0
- core_framework/main.py +104 -0
- core_framework/worker/__init__.py +0 -0
- core_framework/worker/main.py +56 -0
- core_framework/worker/schedules/__init__.py +35 -0
- core_framework/worker/schedules/schedule_aggregate_comment_stats.py +32 -0
- core_framework/worker/schedules/schedule_aggregate_post_view_counts.py +28 -0
- core_framework/worker/schedules/schedule_expired_account_deletions.py +24 -0
- core_framework/worker/schedules/schedule_expired_mute_lifts.py +24 -0
- core_framework/worker/tasks/__init__.py +11 -0
- core_framework/worker/tasks/process_account_deletion.py +13 -0
- core_framework/worker/tasks/process_aggregate_comment_stats.py +19 -0
- core_framework/worker/tasks/process_aggregate_post_stats.py +12 -0
- core_framework/worker/tasks/process_mute_lift.py +13 -0
- core_framework-0.3.0.dist-info/METADATA +22 -0
- core_framework-0.3.0.dist-info/RECORD +222 -0
- 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,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
|
+
)
|