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,612 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import asyncpg
|
|
5
|
+
import orjson
|
|
6
|
+
|
|
7
|
+
from core_framework.core.database import Postgres
|
|
8
|
+
from core_framework.domains.user.enums import UserRole
|
|
9
|
+
from core_framework.domains.user.exceptions import (
|
|
10
|
+
DomainUserNotFoundException,
|
|
11
|
+
SelfBlockException,
|
|
12
|
+
UserCreationException,
|
|
13
|
+
UserIdConflictException,
|
|
14
|
+
UsernameConflictException,
|
|
15
|
+
)
|
|
16
|
+
from core_framework.domains.user.models import (
|
|
17
|
+
BlockedUser,
|
|
18
|
+
Preferences,
|
|
19
|
+
Profile,
|
|
20
|
+
UserChangeHistory,
|
|
21
|
+
UserIdentity,
|
|
22
|
+
UserWithProfile,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UserRepository:
|
|
27
|
+
def __init__(self, write_database: Postgres, read_database: Postgres, strong_read_database: Postgres):
|
|
28
|
+
self.write_database = write_database
|
|
29
|
+
self.read_database = read_database
|
|
30
|
+
self.strong_read_database = strong_read_database
|
|
31
|
+
|
|
32
|
+
# User Management
|
|
33
|
+
async def select_admin_user_ids(self) -> set[str]:
|
|
34
|
+
query = """
|
|
35
|
+
select
|
|
36
|
+
user_id
|
|
37
|
+
from "user".users
|
|
38
|
+
where true
|
|
39
|
+
and role = 'admin'
|
|
40
|
+
"""
|
|
41
|
+
async with self.read_database.get_connection() as connection:
|
|
42
|
+
result = await connection.fetch(query)
|
|
43
|
+
return {record["user_id"] for record in result}
|
|
44
|
+
|
|
45
|
+
async def insert_user(self, *, user_id: str, username: str, role: str) -> None:
|
|
46
|
+
user_query = """
|
|
47
|
+
with attempt as (
|
|
48
|
+
merge into "user".users as target
|
|
49
|
+
using (values ($1, $2, $3::"user".user_role)) as source(user_id, username, role)
|
|
50
|
+
on target.user_id = source.user_id
|
|
51
|
+
or target.username = source.username
|
|
52
|
+
when matched then
|
|
53
|
+
do nothing
|
|
54
|
+
when not matched then
|
|
55
|
+
insert (user_id, username, role)
|
|
56
|
+
values (source.user_id, source.username, source.role)
|
|
57
|
+
returning 1 as inserted
|
|
58
|
+
)
|
|
59
|
+
select
|
|
60
|
+
case
|
|
61
|
+
when exists (select 1 from attempt) then 'INSERTED'
|
|
62
|
+
when exists (select 1 from "user".users u where u.user_id = $1) then 'USER_ID_CONFLICT'
|
|
63
|
+
when exists (select 1 from "user".users u where u.username = $2) then 'USERNAME_CONFLICT'
|
|
64
|
+
else 'UNKNOWN'
|
|
65
|
+
end as result;
|
|
66
|
+
"""
|
|
67
|
+
profile_query = """
|
|
68
|
+
insert into "user".user_profiles (user_id) values ($1)
|
|
69
|
+
"""
|
|
70
|
+
preferences_query = """
|
|
71
|
+
insert into "user".user_preferences (user_id) values ($1)
|
|
72
|
+
"""
|
|
73
|
+
async with self.write_database.get_transaction() as connection:
|
|
74
|
+
result = await connection.fetchrow(user_query, user_id, username, role)
|
|
75
|
+
if result["result"] == "USER_ID_CONFLICT":
|
|
76
|
+
raise UserIdConflictException(user_id)
|
|
77
|
+
if result["result"] == "USERNAME_CONFLICT":
|
|
78
|
+
raise UsernameConflictException(username)
|
|
79
|
+
if result["result"] == "UNKNOWN":
|
|
80
|
+
raise UserCreationException(user_id)
|
|
81
|
+
|
|
82
|
+
await connection.execute(profile_query, user_id)
|
|
83
|
+
await connection.execute(preferences_query, user_id)
|
|
84
|
+
|
|
85
|
+
async def delete_user(self, *, user_id: str) -> None:
|
|
86
|
+
deletion_query = """
|
|
87
|
+
delete from "user".user_deletions
|
|
88
|
+
where user_id = $1
|
|
89
|
+
"""
|
|
90
|
+
user_query = """
|
|
91
|
+
delete from "user".users
|
|
92
|
+
where user_id = $1
|
|
93
|
+
"""
|
|
94
|
+
async with self.write_database.get_transaction() as connection:
|
|
95
|
+
# Delete from user_deletions first so its AFTER DELETE trigger can insert
|
|
96
|
+
# into user_change_history while the parent users row still exists.
|
|
97
|
+
# If users is deleted first, the trigger insert may violate FK to users.
|
|
98
|
+
await connection.execute(deletion_query, user_id)
|
|
99
|
+
await connection.execute(user_query, user_id)
|
|
100
|
+
|
|
101
|
+
# Login/Authentication
|
|
102
|
+
async def insert_login_event(self, *, user_id: str, request_context: dict[str, str]) -> None:
|
|
103
|
+
query = """
|
|
104
|
+
insert into "user".user_login_events (user_id, request_context)
|
|
105
|
+
values ($1, $2::jsonb)
|
|
106
|
+
"""
|
|
107
|
+
json_value = orjson.dumps(request_context).decode()
|
|
108
|
+
async with self.write_database.get_connection() as connection:
|
|
109
|
+
try:
|
|
110
|
+
await connection.execute(query, user_id, json_value)
|
|
111
|
+
except asyncpg.exceptions.ForeignKeyViolationError:
|
|
112
|
+
raise DomainUserNotFoundException()
|
|
113
|
+
|
|
114
|
+
# User Blocks
|
|
115
|
+
async def select_blocked_users(self, *, blocker_id: str, created_at: datetime, limit: int) -> list[BlockedUser]:
|
|
116
|
+
query = """
|
|
117
|
+
select
|
|
118
|
+
blocked_id,
|
|
119
|
+
username,
|
|
120
|
+
"user".user_blocks.created_at
|
|
121
|
+
from "user".user_blocks
|
|
122
|
+
join "user".users on "user".user_blocks.blocked_id = "user".users.user_id
|
|
123
|
+
where true
|
|
124
|
+
and "user".user_blocks.blocker_id = $1
|
|
125
|
+
and "user".user_blocks.created_at <= $2
|
|
126
|
+
order by "user".user_blocks.created_at desc
|
|
127
|
+
limit $3
|
|
128
|
+
"""
|
|
129
|
+
async with self.read_database.get_connection() as connection:
|
|
130
|
+
result = await connection.fetch(query, blocker_id, created_at, limit)
|
|
131
|
+
return [
|
|
132
|
+
BlockedUser(user_id=record["blocked_id"], username=record["username"], created_at=record["created_at"])
|
|
133
|
+
for record in result
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
async def insert_user_block(self, *, blocker_id: str, blocked_id: str) -> None:
|
|
137
|
+
query = """
|
|
138
|
+
merge into "user".user_blocks as target
|
|
139
|
+
using (values ($1, $2)) as source(blocker_id, blocked_id)
|
|
140
|
+
on target.blocker_id = source.blocker_id
|
|
141
|
+
and target.blocked_id = source.blocked_id
|
|
142
|
+
when matched then
|
|
143
|
+
do nothing
|
|
144
|
+
when not matched then
|
|
145
|
+
insert (blocker_id, blocked_id)
|
|
146
|
+
values (source.blocker_id, source.blocked_id)
|
|
147
|
+
"""
|
|
148
|
+
async with self.write_database.get_connection() as connection:
|
|
149
|
+
try:
|
|
150
|
+
await connection.execute(query, blocker_id, blocked_id)
|
|
151
|
+
except asyncpg.exceptions.ForeignKeyViolationError:
|
|
152
|
+
pass
|
|
153
|
+
except asyncpg.exceptions.CheckViolationError as exc:
|
|
154
|
+
if getattr(exc, "constraint_name", None) == "no_self_block":
|
|
155
|
+
raise SelfBlockException() from exc
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
async def delete_user_block(self, *, blocker_id: str, blocked_id: str) -> None:
|
|
159
|
+
query = """
|
|
160
|
+
delete from "user".user_blocks
|
|
161
|
+
where true
|
|
162
|
+
and blocker_id = $1
|
|
163
|
+
and blocked_id = $2
|
|
164
|
+
"""
|
|
165
|
+
async with self.write_database.get_connection() as connection:
|
|
166
|
+
await connection.execute(query, blocker_id, blocked_id)
|
|
167
|
+
|
|
168
|
+
# Preferences
|
|
169
|
+
async def _select_preferences_with_database(self, *, user_id: str, database: Postgres) -> Preferences:
|
|
170
|
+
query = """
|
|
171
|
+
select
|
|
172
|
+
theme,
|
|
173
|
+
language
|
|
174
|
+
from "user".user_preferences
|
|
175
|
+
where user_id = $1
|
|
176
|
+
"""
|
|
177
|
+
async with database.get_connection() as connection:
|
|
178
|
+
result = await connection.fetchrow(query, user_id)
|
|
179
|
+
if result is None:
|
|
180
|
+
raise DomainUserNotFoundException()
|
|
181
|
+
return Preferences(
|
|
182
|
+
theme=result["theme"],
|
|
183
|
+
language=result["language"],
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
async def select_preferences(self, *, user_id: str) -> Preferences:
|
|
187
|
+
return await self._select_preferences_with_database(
|
|
188
|
+
user_id=user_id,
|
|
189
|
+
database=self.read_database,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
async def select_preferences_strong(self, *, user_id: str) -> Preferences:
|
|
193
|
+
return await self._select_preferences_with_database(
|
|
194
|
+
user_id=user_id,
|
|
195
|
+
database=self.strong_read_database,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
async def update_preferences(self, *, user_id: str, validated_update_request: dict[str, Any]) -> None:
|
|
199
|
+
if not validated_update_request:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
ensure_preferences_row_query = """
|
|
203
|
+
insert into "user".user_preferences (user_id)
|
|
204
|
+
values ($1)
|
|
205
|
+
on conflict (user_id) do nothing
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
allowed_fields = {
|
|
209
|
+
"theme": "theme",
|
|
210
|
+
"language": "language",
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
set_clauses = []
|
|
214
|
+
params: list[Any] = [user_id]
|
|
215
|
+
|
|
216
|
+
for field, value in validated_update_request.items():
|
|
217
|
+
if field not in allowed_fields:
|
|
218
|
+
continue
|
|
219
|
+
column = allowed_fields[field]
|
|
220
|
+
params.append(value)
|
|
221
|
+
set_clauses.append(f"{column} = ${len(params)}")
|
|
222
|
+
|
|
223
|
+
if not set_clauses:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
set_clauses.append("updated_at = now()")
|
|
227
|
+
|
|
228
|
+
query = f"""
|
|
229
|
+
update "user".user_preferences
|
|
230
|
+
set {", ".join(set_clauses)}
|
|
231
|
+
where user_id = $1
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
async with self.write_database.get_transaction() as connection:
|
|
235
|
+
await connection.execute(ensure_preferences_row_query, user_id)
|
|
236
|
+
await connection.execute(query, *params)
|
|
237
|
+
|
|
238
|
+
# Profile
|
|
239
|
+
async def _select_profile_with_database(self, *, user_id: str, database: Postgres) -> Profile:
|
|
240
|
+
query = """
|
|
241
|
+
select
|
|
242
|
+
display_name,
|
|
243
|
+
avatar_id,
|
|
244
|
+
banner_id,
|
|
245
|
+
bio,
|
|
246
|
+
status,
|
|
247
|
+
social_links,
|
|
248
|
+
profile_visibility::text as profile_visibility
|
|
249
|
+
from "user".user_profiles
|
|
250
|
+
where user_id = $1
|
|
251
|
+
"""
|
|
252
|
+
async with database.get_connection() as connection:
|
|
253
|
+
result = await connection.fetchrow(query, user_id)
|
|
254
|
+
if result is None:
|
|
255
|
+
raise DomainUserNotFoundException()
|
|
256
|
+
return Profile(
|
|
257
|
+
display_name=result["display_name"],
|
|
258
|
+
avatar_id=result["avatar_id"],
|
|
259
|
+
banner_id=result["banner_id"],
|
|
260
|
+
bio=result["bio"],
|
|
261
|
+
status=result["status"],
|
|
262
|
+
social_links=result["social_links"],
|
|
263
|
+
profile_visibility=result["profile_visibility"],
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
async def select_profile(self, *, user_id: str) -> Profile:
|
|
267
|
+
return await self._select_profile_with_database(
|
|
268
|
+
user_id=user_id,
|
|
269
|
+
database=self.read_database,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
async def select_profile_strong(self, *, user_id: str) -> Profile:
|
|
273
|
+
return await self._select_profile_with_database(
|
|
274
|
+
user_id=user_id,
|
|
275
|
+
database=self.strong_read_database,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
async def update_profile(
|
|
279
|
+
self,
|
|
280
|
+
*,
|
|
281
|
+
user_id: str,
|
|
282
|
+
validated_update_request: dict[str, Any],
|
|
283
|
+
actor_id: str | None = None,
|
|
284
|
+
) -> None:
|
|
285
|
+
if not validated_update_request:
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
ensure_profile_row_query = """
|
|
289
|
+
insert into "user".user_profiles (user_id)
|
|
290
|
+
values ($1)
|
|
291
|
+
on conflict (user_id) do nothing
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
# Map of allowed fields to column names
|
|
295
|
+
allowed_fields = {
|
|
296
|
+
"display_name": "display_name",
|
|
297
|
+
"avatar_id": "avatar_id",
|
|
298
|
+
"banner_id": "banner_id",
|
|
299
|
+
"bio": "bio",
|
|
300
|
+
"status": "status",
|
|
301
|
+
"social_links": "social_links",
|
|
302
|
+
"profile_visibility": "profile_visibility",
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
set_clauses = []
|
|
306
|
+
params: list[Any] = [user_id]
|
|
307
|
+
|
|
308
|
+
for field, value in validated_update_request.items():
|
|
309
|
+
if field not in allowed_fields:
|
|
310
|
+
continue
|
|
311
|
+
column = allowed_fields[field]
|
|
312
|
+
params.append(value)
|
|
313
|
+
set_clauses.append(f"{column} = ${len(params)}")
|
|
314
|
+
|
|
315
|
+
if not set_clauses:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
set_clauses.append("updated_at = now()")
|
|
319
|
+
|
|
320
|
+
query = f"""
|
|
321
|
+
update "user".user_profiles
|
|
322
|
+
set {", ".join(set_clauses)}
|
|
323
|
+
where user_id = $1
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
|
|
327
|
+
await connection.execute(ensure_profile_row_query, user_id)
|
|
328
|
+
await connection.execute(query, *params)
|
|
329
|
+
|
|
330
|
+
async def update_account(
|
|
331
|
+
self,
|
|
332
|
+
*,
|
|
333
|
+
user_id: str,
|
|
334
|
+
validated_update_request: dict[str, Any],
|
|
335
|
+
actor_id: str | None = None,
|
|
336
|
+
) -> None:
|
|
337
|
+
if not validated_update_request:
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
allowed_fields = {
|
|
341
|
+
"username": "username",
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
set_clauses = []
|
|
345
|
+
params: list[Any] = [user_id]
|
|
346
|
+
|
|
347
|
+
for field, value in validated_update_request.items():
|
|
348
|
+
if field not in allowed_fields:
|
|
349
|
+
continue
|
|
350
|
+
column = allowed_fields[field]
|
|
351
|
+
params.append(value)
|
|
352
|
+
set_clauses.append(f"{column} = ${len(params)}")
|
|
353
|
+
|
|
354
|
+
if not set_clauses:
|
|
355
|
+
return
|
|
356
|
+
|
|
357
|
+
set_clauses.append("updated_at = now()")
|
|
358
|
+
|
|
359
|
+
query = f"""
|
|
360
|
+
update "user".users
|
|
361
|
+
set {", ".join(set_clauses)}
|
|
362
|
+
where user_id = $1
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
|
|
366
|
+
try:
|
|
367
|
+
await connection.execute(query, *params)
|
|
368
|
+
except asyncpg.exceptions.UniqueViolationError as exc:
|
|
369
|
+
if getattr(exc, "constraint_name", None) == "users_username_key":
|
|
370
|
+
raise UsernameConflictException(validated_update_request.get("username", "")) from exc
|
|
371
|
+
raise
|
|
372
|
+
|
|
373
|
+
async def update_user_role(
|
|
374
|
+
self,
|
|
375
|
+
*,
|
|
376
|
+
user_id: str,
|
|
377
|
+
role: str,
|
|
378
|
+
actor_id: str | None = None,
|
|
379
|
+
) -> None:
|
|
380
|
+
query = """
|
|
381
|
+
update "user".users
|
|
382
|
+
set role = $2::"user".user_role, updated_at = now()
|
|
383
|
+
where user_id = $1
|
|
384
|
+
"""
|
|
385
|
+
async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
|
|
386
|
+
await connection.execute(query, user_id, role)
|
|
387
|
+
|
|
388
|
+
# Account Deletion
|
|
389
|
+
async def insert_user_deletion(
|
|
390
|
+
self,
|
|
391
|
+
*,
|
|
392
|
+
user_id: str,
|
|
393
|
+
scheduled_for: datetime,
|
|
394
|
+
actor_id: str | None = None,
|
|
395
|
+
) -> None:
|
|
396
|
+
query = """
|
|
397
|
+
merge into "user".user_deletions as target
|
|
398
|
+
using (values ($1, $2::timestamptz)) as source(user_id, scheduled_for)
|
|
399
|
+
on target.user_id = source.user_id
|
|
400
|
+
when matched then
|
|
401
|
+
do nothing
|
|
402
|
+
when not matched then
|
|
403
|
+
insert (user_id, scheduled_for)
|
|
404
|
+
values (source.user_id, source.scheduled_for)
|
|
405
|
+
"""
|
|
406
|
+
async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
|
|
407
|
+
await connection.execute(query, user_id, scheduled_for)
|
|
408
|
+
|
|
409
|
+
async def delete_user_deletion(self, *, user_id: str, actor_id: str | None = None) -> None:
|
|
410
|
+
query = """
|
|
411
|
+
delete from "user".user_deletions
|
|
412
|
+
where true
|
|
413
|
+
and user_id = $1
|
|
414
|
+
"""
|
|
415
|
+
async with self.write_database.get_transaction_with_actor_id(actor_id) as connection:
|
|
416
|
+
await connection.execute(query, user_id)
|
|
417
|
+
|
|
418
|
+
async def select_user_for_detail(self, *, user_id: str) -> UserWithProfile | None:
|
|
419
|
+
query = """
|
|
420
|
+
select
|
|
421
|
+
users.user_id,
|
|
422
|
+
users.username,
|
|
423
|
+
users.role,
|
|
424
|
+
users.created_at,
|
|
425
|
+
user_profiles.display_name,
|
|
426
|
+
user_profiles.avatar_id,
|
|
427
|
+
user_profiles.banner_id,
|
|
428
|
+
user_profiles.bio,
|
|
429
|
+
user_profiles.status,
|
|
430
|
+
user_profiles.social_links,
|
|
431
|
+
user_profiles.profile_visibility::text as profile_visibility,
|
|
432
|
+
user_deletions.scheduled_for
|
|
433
|
+
from "user".users
|
|
434
|
+
inner join "user".user_profiles on users.user_id = user_profiles.user_id
|
|
435
|
+
left join "user".user_deletions on users.user_id = user_deletions.user_id
|
|
436
|
+
where users.user_id = $1
|
|
437
|
+
"""
|
|
438
|
+
async with self.read_database.get_connection() as connection:
|
|
439
|
+
result = await connection.fetchrow(query, user_id)
|
|
440
|
+
if result is None:
|
|
441
|
+
return None
|
|
442
|
+
return UserWithProfile(
|
|
443
|
+
identity=UserIdentity(
|
|
444
|
+
user_id=result["user_id"],
|
|
445
|
+
username=result["username"],
|
|
446
|
+
display_name=result["display_name"],
|
|
447
|
+
role=UserRole(result["role"]),
|
|
448
|
+
avatar_id=result["avatar_id"],
|
|
449
|
+
created_at=result["created_at"],
|
|
450
|
+
),
|
|
451
|
+
profile=Profile(
|
|
452
|
+
display_name=result["display_name"],
|
|
453
|
+
avatar_id=result["avatar_id"],
|
|
454
|
+
banner_id=result["banner_id"],
|
|
455
|
+
bio=result["bio"],
|
|
456
|
+
status=result["status"],
|
|
457
|
+
social_links=result["social_links"],
|
|
458
|
+
profile_visibility=result["profile_visibility"],
|
|
459
|
+
),
|
|
460
|
+
deletion_scheduled_for=result["scheduled_for"],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
async def select_user_deletion_scheduled_for(self, *, user_id: str) -> datetime | None:
|
|
464
|
+
query = """
|
|
465
|
+
select
|
|
466
|
+
scheduled_for
|
|
467
|
+
from "user".user_deletions
|
|
468
|
+
where true
|
|
469
|
+
and user_id = $1
|
|
470
|
+
"""
|
|
471
|
+
async with self.read_database.get_connection() as connection:
|
|
472
|
+
result = await connection.fetchrow(query, user_id)
|
|
473
|
+
return result["scheduled_for"] if result is not None else None
|
|
474
|
+
|
|
475
|
+
async def select_expired_user_deletions(self) -> list[str]:
|
|
476
|
+
query = """
|
|
477
|
+
select
|
|
478
|
+
user_id
|
|
479
|
+
from "user".user_deletions
|
|
480
|
+
where true
|
|
481
|
+
and scheduled_for <= now()
|
|
482
|
+
order by scheduled_for asc
|
|
483
|
+
"""
|
|
484
|
+
async with self.read_database.get_connection() as connection:
|
|
485
|
+
result = await connection.fetch(query)
|
|
486
|
+
return [record["user_id"] for record in result]
|
|
487
|
+
|
|
488
|
+
# User Identity
|
|
489
|
+
async def _select_user_identity_mapping_with_database(
|
|
490
|
+
self,
|
|
491
|
+
*,
|
|
492
|
+
user_ids: set[str],
|
|
493
|
+
database: Postgres,
|
|
494
|
+
) -> dict[str, UserIdentity]:
|
|
495
|
+
if not user_ids:
|
|
496
|
+
return {}
|
|
497
|
+
|
|
498
|
+
query = """
|
|
499
|
+
select
|
|
500
|
+
users.user_id,
|
|
501
|
+
users.username,
|
|
502
|
+
user_profiles.display_name,
|
|
503
|
+
users.role,
|
|
504
|
+
user_profiles.avatar_id,
|
|
505
|
+
users.created_at
|
|
506
|
+
from "user".users
|
|
507
|
+
left join "user".user_profiles on users.user_id = user_profiles.user_id
|
|
508
|
+
where users.user_id = any($1)
|
|
509
|
+
"""
|
|
510
|
+
async with database.get_connection() as connection:
|
|
511
|
+
result = await connection.fetch(query, user_ids)
|
|
512
|
+
return {
|
|
513
|
+
record["user_id"]: UserIdentity(
|
|
514
|
+
user_id=record["user_id"],
|
|
515
|
+
username=record["username"],
|
|
516
|
+
display_name=record["display_name"],
|
|
517
|
+
role=UserRole(record["role"]),
|
|
518
|
+
avatar_id=record["avatar_id"],
|
|
519
|
+
created_at=record["created_at"],
|
|
520
|
+
)
|
|
521
|
+
for record in result
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async def select_user_identity_mapping(self, *, user_ids: set[str]) -> dict[str, UserIdentity]:
|
|
525
|
+
return await self._select_user_identity_mapping_with_database(
|
|
526
|
+
user_ids=user_ids,
|
|
527
|
+
database=self.read_database,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
async def select_user_identity_mapping_strong(self, *, user_ids: set[str]) -> dict[str, UserIdentity]:
|
|
531
|
+
return await self._select_user_identity_mapping_with_database(
|
|
532
|
+
user_ids=user_ids,
|
|
533
|
+
database=self.strong_read_database,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
async def select_user_identities(
|
|
537
|
+
self,
|
|
538
|
+
*,
|
|
539
|
+
username: str | None,
|
|
540
|
+
role: UserRole | None,
|
|
541
|
+
cursor: datetime,
|
|
542
|
+
limit: int,
|
|
543
|
+
) -> list[UserIdentity]:
|
|
544
|
+
query = """
|
|
545
|
+
select
|
|
546
|
+
users.user_id,
|
|
547
|
+
users.username,
|
|
548
|
+
user_profiles.display_name,
|
|
549
|
+
users.role,
|
|
550
|
+
user_profiles.avatar_id,
|
|
551
|
+
users.created_at
|
|
552
|
+
from "user".users
|
|
553
|
+
left join "user".user_profiles on users.user_id = user_profiles.user_id
|
|
554
|
+
where true
|
|
555
|
+
and users.created_at <= $1
|
|
556
|
+
and ($3::text is null or users.username ilike $3)
|
|
557
|
+
and ($4::"user".user_role is null or users.role = $4::"user".user_role)
|
|
558
|
+
order by users.created_at desc
|
|
559
|
+
limit $2
|
|
560
|
+
"""
|
|
561
|
+
async with self.read_database.get_connection() as connection:
|
|
562
|
+
username_filter = f"%{username}%" if username else None
|
|
563
|
+
result = await connection.fetch(query, cursor, limit, username_filter, role)
|
|
564
|
+
return [
|
|
565
|
+
UserIdentity(
|
|
566
|
+
user_id=record["user_id"],
|
|
567
|
+
username=record["username"],
|
|
568
|
+
display_name=record["display_name"],
|
|
569
|
+
role=UserRole(record["role"]),
|
|
570
|
+
avatar_id=record["avatar_id"],
|
|
571
|
+
created_at=record["created_at"],
|
|
572
|
+
)
|
|
573
|
+
for record in result
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
async def select_user_change_history(
|
|
577
|
+
self,
|
|
578
|
+
*,
|
|
579
|
+
user_id: str,
|
|
580
|
+
cursor: datetime,
|
|
581
|
+
limit: int,
|
|
582
|
+
) -> list[UserChangeHistory]:
|
|
583
|
+
query = """
|
|
584
|
+
select
|
|
585
|
+
user_id,
|
|
586
|
+
actor_id,
|
|
587
|
+
entity_type,
|
|
588
|
+
field,
|
|
589
|
+
old_value,
|
|
590
|
+
new_value,
|
|
591
|
+
created_at
|
|
592
|
+
from "user".user_change_history
|
|
593
|
+
where true
|
|
594
|
+
and user_id = $1
|
|
595
|
+
and created_at <= $2
|
|
596
|
+
order by created_at desc
|
|
597
|
+
limit $3
|
|
598
|
+
"""
|
|
599
|
+
async with self.read_database.get_connection() as connection:
|
|
600
|
+
result = await connection.fetch(query, user_id, cursor, limit)
|
|
601
|
+
return [
|
|
602
|
+
UserChangeHistory(
|
|
603
|
+
user_id=record["user_id"],
|
|
604
|
+
actor_id=record["actor_id"],
|
|
605
|
+
entity_type=record["entity_type"],
|
|
606
|
+
field=record["field"],
|
|
607
|
+
old_value=record["old_value"],
|
|
608
|
+
new_value=record["new_value"],
|
|
609
|
+
created_at=record["created_at"],
|
|
610
|
+
)
|
|
611
|
+
for record in result
|
|
612
|
+
]
|