Отедельные таблицы User и TelegramUser

This commit is contained in:
Artem Tsyrulnikov
2026-01-07 14:00:35 +03:00
parent 3e1ad27c66
commit f6669c3f5a
19 changed files with 344 additions and 74 deletions

View File

@@ -23,6 +23,24 @@ class Postgres(DatabaseBase, Database):
async def update_user(self, user: domain.User) -> None:
await user.save()
async def get_telegram_user(
self,
telegram_user_id: uuid.UUID | None = None,
telegram_id: int | None = None,
) -> domain.TelegramUser | None:
if telegram_user_id:
return await domain.TelegramUser.get_or_none(id=telegram_user_id)
if telegram_id:
return await domain.TelegramUser.get_or_none(telegram_id=telegram_id)
raise ValueError('Either telegram_user_id or telegram_id must be provided')
async def create_telegram_user(self, telegram_user: domain.TelegramUser) -> None:
await telegram_user.save()
async def update_telegram_user(self, telegram_user: domain.TelegramUser) -> None:
await telegram_user.save()
async def create_workspace(self, workspace: domain.Workspace) -> None:
await workspace.save()
@@ -75,14 +93,14 @@ class Postgres(DatabaseBase, Database):
) -> domain.WorkspaceUser | None:
return (
await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id)
.prefetch_related('workspace', 'user', 'permissions', 'permission_scopes')
.prefetch_related('workspace', 'user', 'user__telegram_user', 'permissions', 'permission_scopes')
.first()
)
async def get_workspace_members(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]:
return (
await domain.WorkspaceUser.filter(workspace_id=workspace_id)
.prefetch_related('workspace', 'user', 'permissions', 'permission_scopes')
.prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes')
.order_by('created_at')
.all()
)
@@ -90,7 +108,7 @@ class Postgres(DatabaseBase, Database):
async def get_workspace_member(self, workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None:
return (
await domain.WorkspaceUser.filter(id=workspace_user_id)
.prefetch_related('workspace', 'user', 'permissions', 'permission_scopes')
.prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes')
.first()
)
@@ -103,7 +121,7 @@ class Postgres(DatabaseBase, Database):
async def get_workspace_invite(self, invite_id: uuid.UUID) -> domain.WorkspaceInvite | None:
return (
await domain.WorkspaceInvite.filter(id=invite_id)
.prefetch_related('workspace', 'user', 'invited_by')
.prefetch_related('workspace', 'user__telegram_user', 'invited_by__telegram_user')
.first()
)
@@ -115,7 +133,7 @@ class Postgres(DatabaseBase, Database):
async def get_workspace_invites(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]:
return (
await domain.WorkspaceInvite.filter(workspace_id=workspace_id)
.prefetch_related('user', 'invited_by')
.prefetch_related('user__telegram_user', 'invited_by__telegram_user')
.order_by('created_at')
.all()
)
@@ -165,14 +183,24 @@ class Postgres(DatabaseBase, Database):
async def get_user(self, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
if user_id:
return await domain.User.get_or_none(id=user_id)
return (
await domain.User.filter(id=user_id).prefetch_related('telegram_user').first()
)
elif telegram_id:
return await domain.User.get_or_none(telegram_id=telegram_id)
return (
await domain.User.filter(telegram_user__telegram_id=telegram_id)
.prefetch_related('telegram_user')
.first()
)
raise ValueError('Either user_id or telegram_id must be provided')
async def get_user_by_username(self, username: str) -> domain.User | None:
return await domain.User.get_or_none(username__iexact=username)
return (
await domain.User.filter(telegram_user__username__iexact=username)
.prefetch_related('telegram_user')
.first()
)
async def get_login_token(self, token: str) -> domain.LoginToken | None:
return await domain.LoginToken.get_or_none(token=token)
@@ -444,9 +472,11 @@ class Postgres(DatabaseBase, Database):
await subscription.save()
async def get_subscription_by_subscriber_and_placement(
self, subscriber_id: uuid.UUID, placement_id: uuid.UUID
self, telegram_user_id: uuid.UUID, placement_id: uuid.UUID
) -> domain.Subscription | None:
return await domain.Subscription.get_or_none(subscriber_id=subscriber_id, placement_id=placement_id)
return await domain.Subscription.get_or_none(
telegram_user_id=telegram_user_id, placement_id=placement_id
)
async def update_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save()
@@ -471,28 +501,28 @@ class Postgres(DatabaseBase, Database):
return await query.all()
async def get_active_subscriptions_by_subscriber_and_project(
self, subscriber_id: uuid.UUID, project_id: uuid.UUID
self, telegram_user_id: uuid.UUID, project_id: uuid.UUID
) -> list[domain.Subscription]:
return (
await domain.Subscription.filter(
subscriber_id=subscriber_id,
telegram_user_id=telegram_user_id,
placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE,
)
.prefetch_related('placement', 'subscriber')
.prefetch_related('placement', 'telegram_user')
.all()
)
async def get_active_subscription_by_subscriber_and_project(
self, subscriber_id: uuid.UUID, project_id: uuid.UUID
self, telegram_user_id: uuid.UUID, project_id: uuid.UUID
) -> domain.Subscription | None:
return (
await domain.Subscription.filter(
subscriber_id=subscriber_id,
telegram_user_id=telegram_user_id,
placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE,
)
.prefetch_related('placement', 'subscriber')
.prefetch_related('placement', 'telegram_user')
.first()
)

View File

@@ -1,5 +1,6 @@
__all__ = (
'User',
'TelegramUser',
'Workspace',
'WorkspaceUser',
'WorkspaceInvite',
@@ -101,6 +102,7 @@ from .purchase import (
)
from .subscription import Subscription, SubscriptionStatus
from .user import User
from .telegram_user import TelegramUser
from .workspace import (
PermissionKey,
PermissionScopeType,

View File

@@ -8,7 +8,7 @@ from .base import TimestampedModel
if TYPE_CHECKING:
from .placement import Placement
from .user import User
from .telegram_user import TelegramUser
class SubscriptionStatus(str, enum.Enum):
@@ -24,14 +24,14 @@ class Subscription(TimestampedModel):
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
subscriber: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
'models.User', related_name='subscriptions', on_delete=fields.CASCADE, index=True
telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField(
'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
placement_id: UUID
subscriber_id: UUID
telegram_user_id: UUID
class Meta:
table = 'subscription'
unique_together = (('placement_id', 'subscriber_id'),)
unique_together = (('placement_id', 'telegram_user_id'),)

View File

@@ -0,0 +1,13 @@
from tortoise import fields
from .base import TimestampedModel
class TelegramUser(TimestampedModel):
telegram_id = fields.BigIntField(unique=True, index=True)
username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True)
last_name = fields.CharField(max_length=255, null=True)
class Meta:
table = 'telegram_user'

View File

@@ -1,13 +1,21 @@
from typing import TYPE_CHECKING
from uuid import UUID
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .telegram_user import TelegramUser
class User(TimestampedModel):
telegram_id = fields.BigIntField(unique=True, index=True)
username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True)
last_name = fields.CharField(max_length=255, null=True)
telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.OneToOneField(
'models.TelegramUser', related_name='user', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
telegram_user_id: UUID
class Meta:
table = 'user'

View File

@@ -49,8 +49,10 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
@classmethod
def from_domain(cls, member: domain.WorkspaceUser) -> 'WorkspaceMemberOutput':
if member.user is None:
raise ValueError('Workspace member user relation is not loaded')
if member.user is None or member.user.telegram_user is None:
raise ValueError('Workspace member user relation is not fully loaded')
telegram_user = member.user.telegram_user
permissions_map: dict[domain.PermissionKey, list[WorkspacePermissionScopeOutput]] = {}
@@ -67,8 +69,8 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
status=member.status,
user=WorkspaceMemberUserOutput(
id=member.user.id,
telegram_id=member.user.telegram_id,
username=member.user.username,
telegram_id=telegram_user.telegram_id,
username=telegram_user.username,
),
permissions=[
WorkspacePermissionOutput(key=key, scopes=scopes)
@@ -150,21 +152,26 @@ class WorkspaceInviteOutput(pydantic.BaseModel):
@classmethod
def from_domain(cls, invite: domain.WorkspaceInvite) -> 'WorkspaceInviteOutput':
if invite.user is None or invite.invited_by is None:
raise ValueError('Workspace invite relations are not loaded')
if (
invite.user is None
or invite.invited_by is None
or invite.user.telegram_user is None
or invite.invited_by.telegram_user is None
):
raise ValueError('Workspace invite relations are not fully loaded')
return cls(
id=invite.id,
status=invite.status,
user=WorkspaceMemberUserOutput(
id=invite.user.id,
telegram_id=invite.user.telegram_id,
username=invite.user.username,
telegram_id=invite.user.telegram_user.telegram_id,
username=invite.user.telegram_user.username,
),
invited_by=WorkspaceMemberUserOutput(
id=invite.invited_by.id,
telegram_id=invite.invited_by.telegram_id,
username=invite.invited_by.username,
telegram_id=invite.invited_by.telegram_user.telegram_id,
username=invite.invited_by.telegram_user.username,
),
)

View File

@@ -65,6 +65,16 @@ class Database(typing.Protocol):
async def create_user(self, user: domain.User) -> None: ...
async def get_telegram_user(
self,
telegram_user_id: UUID | None = None,
telegram_id: int | None = None,
) -> domain.TelegramUser | None: ...
async def create_telegram_user(self, telegram_user: domain.TelegramUser) -> None: ...
async def update_telegram_user(self, telegram_user: domain.TelegramUser) -> None: ...
async def create_workspace(self, workspace: domain.Workspace) -> None: ...
async def update_workspace(self, workspace: domain.Workspace) -> None: ...
@@ -222,7 +232,7 @@ class Database(typing.Protocol):
async def create_subscription(self, subscription: domain.Subscription) -> None: ...
async def get_subscription_by_subscriber_and_placement(
self, subscriber_id: UUID, placement_id: UUID
self, telegram_user_id: UUID, placement_id: UUID
) -> domain.Subscription | None: ...
async def update_subscription(self, subscription: domain.Subscription) -> None: ...
@@ -236,11 +246,11 @@ class Database(typing.Protocol):
) -> list[domain.Subscription]: ...
async def get_active_subscriptions_by_subscriber_and_project(
self, subscriber_id: UUID, project_id: UUID
self, telegram_user_id: UUID, project_id: UUID
) -> list[domain.Subscription]: ...
async def get_active_subscription_by_subscriber_and_project(
self, subscriber_id: UUID, project_id: UUID
self, telegram_user_id: UUID, project_id: UUID
) -> domain.Subscription | None: ...
# Count methods
@@ -365,7 +375,11 @@ class Usecase:
if workspace:
return workspace
workspace_name = user.username or f'Workspace {user.telegram_id}'
if not user.telegram_user:
raise domain.UserNotFound(user.id)
telegram_user = user.telegram_user
workspace_name = telegram_user.username or f'Workspace {telegram_user.telegram_id}'
workspace = domain.Workspace(name=workspace_name)
async with self.database.transaction():

View File

@@ -11,12 +11,17 @@ if typing.TYPE_CHECKING:
async def create_telegram_login_token(self: 'Usecase', telegram_id: int, username: str | None) -> str:
telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id)
if not telegram_user:
telegram_user = domain.TelegramUser(telegram_id=telegram_id, username=username)
await self.database.create_telegram_user(telegram_user)
elif username and telegram_user.username != username:
telegram_user.username = username
await self.database.update_telegram_user(telegram_user)
user = await self.database.get_user(telegram_id=telegram_id)
if not user:
user = domain.User(
telegram_id=telegram_id,
username=username,
)
user = domain.User(telegram_user=telegram_user)
await self.database.create_user(user)
token = secrets.token_urlsafe(32)

View File

@@ -13,33 +13,38 @@ async def get_jwt_by_telegram_id(
first_name: str | None = None,
last_name: str | None = None,
) -> dto.ValidateLoginTokenOutput:
user = await self.database.get_user(telegram_id=telegram_id)
if not user:
user = domain.User(
telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id)
if not telegram_user:
telegram_user = domain.TelegramUser(
telegram_id=telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
)
await self.database.create_user(user)
await self.database.create_telegram_user(telegram_user)
else:
updated = False
if username is not None and user.username != username:
user.username = username
if username is not None and telegram_user.username != username:
telegram_user.username = username
updated = True
if first_name is not None and user.first_name != first_name:
user.first_name = first_name
if first_name is not None and telegram_user.first_name != first_name:
telegram_user.first_name = first_name
updated = True
if last_name is not None and user.last_name != last_name:
user.last_name = last_name
if last_name is not None and telegram_user.last_name != last_name:
telegram_user.last_name = last_name
updated = True
if updated:
await self.database.update_user(user)
await self.database.update_telegram_user(telegram_user)
user = await self.database.get_user(telegram_id=telegram_id)
if not user:
user = domain.User(telegram_user=telegram_user)
await self.database.create_user(user)
access_token = self.jwt_encoder.encode_access_token(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
telegram_id=telegram_user.telegram_id,
username=telegram_user.username,
)
return dto.ValidateLoginTokenOutput(

View File

@@ -13,10 +13,15 @@ async def get_me(self: 'Usecase', user_id: uuid.UUID) -> dto.UserOutput:
if not user:
raise domain.UserNotFound(user_id)
if not user.telegram_user:
raise domain.UserNotFound(user_id)
telegram_user = user.telegram_user
return dto.UserOutput(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
telegram_id=telegram_user.telegram_id,
username=telegram_user.username,
first_name=telegram_user.first_name,
last_name=telegram_user.last_name,
)

View File

@@ -26,10 +26,14 @@ async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInp
await self.database.mark_token_as_used(input.token)
telegram_user = user.telegram_user
if telegram_user is None:
raise domain.UserNotFound(login_token.user_id)
access_token = self.jwt_encoder.encode_access_token(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
telegram_id=telegram_user.telegram_id,
username=telegram_user.username,
)
return dto.ValidateLoginTokenOutput(

View File

@@ -14,6 +14,9 @@ async def disconnect_project_by_tg_id(self: 'Usecase', input: dto.DisconnectProj
if not user:
log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel')
return
if user.telegram_user is None:
log.warning('User %s missing telegram profile when disconnecting channel', user.id)
return
project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
if not project:

View File

@@ -52,6 +52,18 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
input.user_telegram_id,
)
raise domain.UserNotFound()
if user.telegram_user is None:
log.warning('User %s missing telegram profile when connecting channel', user.id)
await self.telegram_bot.send_message(
'⚠️ Произошла ошибка при обработке вашего профиля. Пожалуйста, повторите авторизацию.',
input.user_telegram_id,
)
raise domain.UserNotFound()
if user.telegram_user is None:
raise domain.UserNotFound(user.id)
telegram_user = user.telegram_user
memberships = await self.database.get_user_workspaces(user.id)
workspaces: list[domain.Workspace] = []
@@ -154,10 +166,10 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
f'Бот добавлен в канал "{input.title}".\n\n'
f'У вас {len(workspaces)} рабочих пространств. '
'Нажмите кнопку ниже чтобы выбрать, где создать проект.',
user.telegram_id,
telegram_user.telegram_id,
buttons,
)
log.info('Pending channel notification sent to user %s for channel %s', user.telegram_id, channel.id)
log.info('Pending channel notification sent to user %s for channel %s', telegram_user.telegram_id, channel.id)
return None

View File

@@ -21,6 +21,9 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe
if not user:
log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
return
if user.telegram_user is None:
log.warning(f'User %s missing telegram profile when updating permissions', user.id)
return
project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
if not project:
@@ -34,7 +37,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe
await self.telegram_bot.send_message(
f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
input.user_telegram_id,
user.telegram_user.telegram_id,
)
log.info(f'Project channel {input.telegram_id} reactivated - all permissions granted')
return
@@ -49,7 +52,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe
f'⚠️ Канал "{input.chat_title}" был деактивирован.\n\n'
f'Боту убрали необходимые права:\n{missed_permissions}'
),
input.user_telegram_id,
user.telegram_user.telegram_id,
)
log.warning(
'Project channel %s deactivated due to missing permissions: %s',

View File

@@ -22,20 +22,20 @@ async def handle_subscription(
log.warning('Placement not found for invite_link: %s', invite_link)
return
subscriber = await self.database.get_user(telegram_id=user_telegram_id)
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
if not subscriber:
subscriber = domain.User(
subscriber = domain.TelegramUser(
telegram_id=user_telegram_id,
username=username,
first_name=first_name,
last_name=last_name,
)
await self.database.create_user(subscriber)
await self.database.create_telegram_user(subscriber)
else:
subscriber.username = username or subscriber.username
subscriber.first_name = first_name or subscriber.first_name
subscriber.last_name = last_name or subscriber.last_name
await self.database.update_user(subscriber)
await self.database.update_telegram_user(subscriber)
active_subscription = await self.database.get_active_subscription_by_subscriber_and_project(
subscriber.id, placement.project_id
@@ -74,7 +74,7 @@ async def handle_subscription(
subscription = domain.Subscription(
placement_id=placement.id,
subscriber_id=subscriber.id,
telegram_user_id=subscriber.id,
invite_link=invite_link,
)
await self.database.create_subscription(subscription)

View File

@@ -12,7 +12,7 @@ log = logging.getLogger(__name__)
async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None:
subscriber = await self.database.get_user(telegram_id=user_telegram_id)
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
if not subscriber:
log.warning('Subscriber not found for telegram_id: %s', user_telegram_id)
return

View File

@@ -54,6 +54,11 @@ async def create_workspace_invite(
if invited_by_user is None:
raise domain.UserNotFound(user_id)
if invited_user.telegram_user is None:
raise domain.UserNotFound(invited_user.id)
if invited_by_user.telegram_user is None:
raise domain.UserNotFound(invited_by_user.id)
invite.user = invited_user
invite.invited_by = invited_by_user
@@ -68,7 +73,7 @@ async def create_workspace_invite(
)
await self.telegram_bot.send_message_with_inline_keyboard(
invite_message,
chat_id=invited_user.telegram_id,
chat_id=invited_user.telegram_user.telegram_id,
buttons=[[accept_button]],
)

View File

@@ -24,6 +24,9 @@ async def tg_accept_workspace_invite(
if not user:
await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id)
return
if user.telegram_user is None:
await self.telegram_bot.send_message('❌ Ваш Telegram профиль не найден. Авторизуйтесь заново.', chat_id)
return
parts = callback_data.split(':', 1)
if len(parts) != 2 or parts[0] != INVITE_ACCEPT_CALLBACK_PREFIX:
@@ -67,7 +70,7 @@ async def tg_accept_workspace_invite(
await self.telegram_bot.send_message(
f'✅ Вы присоединились к рабочему пространству «{workspace_name}».\n\n'
'Администратор сможет выдать вам необходимые права в веб-интерфейсе.',
chat_id,
user.telegram_user.telegram_id,
)
if message_id: