Files
tgex-backend/src/adapter/postgres.py
Artem Tsyrulnikov 5473b26787 refactor
2026-01-19 12:50:36 +03:00

611 lines
23 KiB
Python

import datetime
import logging
import typing
import uuid
from tortoise import timezone
from tortoise.transactions import in_transaction
from shared.datebase_base import DatabaseBase
from src import domain
log = logging.getLogger(__name__)
class Postgres(DatabaseBase):
@staticmethod
def transaction() -> typing.AsyncContextManager[None]:
return in_transaction()
@staticmethod
async def create_user(user: domain.User) -> None:
await user.save()
@staticmethod
async def get_telegram_user(
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')
@staticmethod
async def create_telegram_user(telegram_user: domain.TelegramUser) -> None:
await telegram_user.save()
@staticmethod
async def update_telegram_user(telegram_user: domain.TelegramUser) -> None:
await telegram_user.save()
@staticmethod
async def create_workspace(workspace: domain.Workspace) -> None:
await workspace.save()
@staticmethod
async def update_workspace(workspace: domain.Workspace) -> None:
await workspace.save()
@staticmethod
async def delete_workspace(workspace_id: uuid.UUID) -> None:
await domain.Workspace.filter(id=workspace_id).delete()
@staticmethod
async def add_user_to_workspace(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser:
return await domain.WorkspaceUser.create(
workspace_id=workspace_id,
user_id=user_id,
status=domain.WorkspaceUserStatus.ACTIVE,
)
@staticmethod
async def update_workspace_user(workspace_user: domain.WorkspaceUser) -> None:
await workspace_user.save()
@staticmethod
async def get_user_workspaces(user_id: uuid.UUID) -> list[domain.WorkspaceUser]:
return (
await domain.WorkspaceUser.filter(user_id=user_id)
.prefetch_related('workspace')
.order_by('created_at')
.all()
)
@staticmethod
async def get_workspace(workspace_id: uuid.UUID) -> domain.Workspace | None:
return await domain.Workspace.get_or_none(id=workspace_id)
@staticmethod
async def get_workspace_for_user(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.Workspace | None:
membership = (
await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id)
.prefetch_related('workspace')
.first()
)
return membership.workspace if membership else None
@staticmethod
async def get_default_workspace_for_user(user_id: uuid.UUID) -> domain.Workspace | None:
membership = (
await domain.WorkspaceUser.filter(user_id=user_id)
.prefetch_related('workspace')
.order_by('created_at')
.first()
)
return membership.workspace if membership else None
@staticmethod
async def get_workspace_membership(workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser | None:
return (
await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id)
.prefetch_related('workspace', 'user', 'user__telegram_user', 'permissions', 'permission_scopes')
.first()
)
@staticmethod
async def get_workspace_members(workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]:
return (
await domain.WorkspaceUser.filter(workspace_id=workspace_id)
.prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes')
.order_by('created_at')
.all()
)
@staticmethod
async def get_workspace_member(workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None:
return (
await domain.WorkspaceUser.filter(id=workspace_user_id)
.prefetch_related('workspace', 'user__telegram_user', 'permissions', 'permission_scopes')
.first()
)
@staticmethod
async def create_workspace_invite(invite: domain.WorkspaceInvite) -> None:
await invite.save()
@staticmethod
async def update_workspace_invite(invite: domain.WorkspaceInvite) -> None:
await invite.save()
@staticmethod
async def get_workspace_invite(invite_id: uuid.UUID) -> domain.WorkspaceInvite | None:
return (
await domain.WorkspaceInvite.filter(id=invite_id)
.prefetch_related('workspace', 'user__telegram_user', 'invited_by__telegram_user')
.first()
)
@staticmethod
async def get_workspace_invite_by_user(
workspace_id: uuid.UUID, user_id: uuid.UUID
) -> domain.WorkspaceInvite | None:
return await domain.WorkspaceInvite.get_or_none(workspace_id=workspace_id, user_id=user_id)
@staticmethod
async def get_workspace_invites(workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]:
return (
await domain.WorkspaceInvite.filter(workspace_id=workspace_id)
.prefetch_related('user__telegram_user', 'invited_by__telegram_user')
.order_by('created_at')
.all()
)
@staticmethod
async def set_workspace_user_permissions(
workspace_user_id: uuid.UUID,
global_permissions: set[domain.PermissionKey],
scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]],
) -> None:
await domain.WorkspaceUserPermission.filter(workspace_user_id=workspace_user_id).delete()
await domain.WorkspaceUserPermissionScope.filter(workspace_user_id=workspace_user_id).delete()
if global_permissions:
await domain.WorkspaceUserPermission.bulk_create(
[
domain.WorkspaceUserPermission(workspace_user_id=workspace_user_id, permission=permission)
for permission in global_permissions
]
)
if scoped_permissions:
scope_objects = []
for permission, scope_type, scope_id in scoped_permissions:
kwargs = {
'workspace_user_id': workspace_user_id,
'permission': permission,
}
if scope_type == domain.PermissionScopeType.PROJECT:
kwargs['project_id'] = scope_id
elif scope_type == domain.PermissionScopeType.CREATIVE:
kwargs['creative_id'] = scope_id
elif scope_type == domain.PermissionScopeType.PLACEMENT:
kwargs['placement_id'] = scope_id
elif scope_type == domain.PermissionScopeType.CHANNEL:
kwargs['channel_id'] = scope_id
scope_objects.append(domain.WorkspaceUserPermissionScope(**kwargs))
await domain.WorkspaceUserPermissionScope.bulk_create(scope_objects)
@staticmethod
async def create_login_token(login_token: domain.LoginToken) -> None:
await login_token.save()
@staticmethod
async def create_creative(creative: domain.Creative) -> None:
await creative.save()
@staticmethod
async def get_user(user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
if user_id:
return await domain.User.filter(id=user_id).prefetch_related('telegram_user').first()
elif 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')
@staticmethod
async def get_user_by_username(username: str) -> domain.User | None:
return (
await domain.User.filter(telegram_user__username__iexact=username).prefetch_related('telegram_user').first()
)
@staticmethod
async def get_login_token(token: str) -> domain.LoginToken | None:
return await domain.LoginToken.get_or_none(token=token)
@staticmethod
async def mark_token_as_used(token: str) -> None:
updated = await domain.LoginToken.filter(token=token, used_at__isnull=True).update(used_at=timezone.now())
if updated == 0:
raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован
@staticmethod
async def get_channel(
channel_id: uuid.UUID | None = None, telegram_id: int | None = None, username: str | None = None
) -> domain.Channel | None:
if channel_id:
return await domain.Channel.get_or_none(id=channel_id)
if telegram_id:
return await domain.Channel.get_or_none(telegram_id=telegram_id)
if username:
return await domain.Channel.filter(username__iexact=username).first()
return None
@staticmethod
async def create_channel(channel: domain.Channel) -> None:
await channel.save()
@staticmethod
async def update_channel(channel: domain.Channel) -> None:
await channel.save()
@staticmethod
async def search_channels(username_query: str | None = None) -> list[domain.Channel]:
query = domain.Channel.all()
if username_query:
# Частичный поиск (case-insensitive)
query = query.filter(username__icontains=username_query)
return await query.all()
@staticmethod
async def get_project(
workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None
) -> domain.Project | None:
query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel')
if project_id:
query = query.filter(id=project_id)
if channel_id:
query = query.filter(channel_id=channel_id)
return await query.first()
@staticmethod
async def get_project_for_user_by_telegram(user_id: uuid.UUID, channel_telegram_id: int) -> domain.Project | None:
return (
await domain.Project.filter(
channel__telegram_id=channel_telegram_id, workspace__workspace_users__user_id=user_id
)
.prefetch_related('channel')
.first()
)
@staticmethod
async def get_project_by_channel_telegram(channel_telegram_id: int) -> domain.Project | None:
return await domain.Project.filter(channel__telegram_id=channel_telegram_id).prefetch_related('channel').first()
@staticmethod
async def create_project(project: domain.Project) -> None:
await project.save()
@staticmethod
async def update_project(project: domain.Project) -> None:
await project.save()
@staticmethod
async def get_workspace_projects(
workspace_id: uuid.UUID,
allowed_project_ids: set[uuid.UUID] | None = None,
include_archived: bool = False,
) -> list[domain.Project]:
query = domain.Project.filter(workspace_id=workspace_id, deleted_at__isnull=True).prefetch_related('channel')
if allowed_project_ids is not None:
if not allowed_project_ids:
return []
query = query.filter(id__in=list(allowed_project_ids))
if not include_archived:
query = query.filter(status=domain.ProjectStatus.ACTIVE)
return await query.order_by('created_at').all()
@staticmethod
async def archive_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None:
project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
if not project:
raise domain.ProjectNotFound()
project.status = domain.ProjectStatus.ARCHIVED
await project.save()
@staticmethod
async def delete_project(workspace_id: uuid.UUID, project_id: uuid.UUID) -> None:
project = await domain.Project.get_or_none(id=project_id, workspace_id=workspace_id)
if not project:
raise domain.ProjectNotFound()
import datetime
project.deleted_at = datetime.datetime.now()
await project.save()
@staticmethod
async def check_channel_exists_in_workspace(channel_id: uuid.UUID, workspace_id: uuid.UUID) -> bool:
project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id)
return project is not None
@staticmethod
async def get_creative(workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
return await domain.Creative.get_or_none(id=creative_id, project__workspace_id=workspace_id).prefetch_related(
'project', 'project__channel'
)
@staticmethod
async def update_creative(creative: domain.Creative) -> None:
await creative.save()
@staticmethod
async def get_workspace_creatives(
workspace_id: uuid.UUID,
project_id: uuid.UUID | None = None,
include_archived: bool = False,
allowed_project_ids: set[uuid.UUID] | None = None,
) -> list[domain.Creative]:
query = domain.Creative.filter(project__workspace_id=workspace_id)
if project_id:
query = query.filter(project_id=project_id)
elif allowed_project_ids is not None:
if not allowed_project_ids:
return []
query = query.filter(project_id__in=list(allowed_project_ids))
if not include_archived:
query = query.filter(status=domain.CreativeStatus.ACTIVE)
return await query.prefetch_related('project', 'project__channel').order_by('-created_at').all()
@staticmethod
async def delete_creative(creative_id: uuid.UUID) -> None:
await domain.Creative.filter(id=creative_id).delete()
@staticmethod
async def create_placement(placement: domain.Placement) -> None:
await placement.save()
@staticmethod
async def get_placement(workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
return await domain.Placement.get_or_none(id=placement_id, project__workspace_id=workspace_id).prefetch_related(
'project', 'project__channel', 'channel', 'creative'
)
@staticmethod
async def get_project_placements(
workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False
) -> list[domain.Placement]:
query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id)
if not include_archived:
query = query.filter(
status__in=[
domain.PlacementStatus.PLANNED,
domain.PlacementStatus.APPROVED,
domain.PlacementStatus.IN_PROGRESS,
domain.PlacementStatus.COMPLETED,
]
)
return (
await query.prefetch_related('project', 'project__channel', 'channel', 'creative')
.order_by('-created_at')
.all()
)
@staticmethod
async def get_placement_post(workspace_id: uuid.UUID, placement_post_id: uuid.UUID) -> domain.PlacementPost | None:
return await domain.PlacementPost.get_or_none(
id=placement_post_id, placement__project__workspace_id=workspace_id
).prefetch_related(
'placement',
'placement__project',
'placement__project__channel',
'placement__channel',
'placement__creative',
'post',
'post__channel',
)
@staticmethod
async def get_workspace_placement_posts(
workspace_id: uuid.UUID,
project_id: uuid.UUID | None = None,
placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None,
include_archived: bool = False,
allowed_project_ids: set[uuid.UUID] | None = None,
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.PlacementPost]:
query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id)
if project_id:
query = query.filter(placement__project_id=project_id)
elif allowed_project_ids is not None:
if not allowed_project_ids:
return []
query = query.filter(placement__project_id__in=list(allowed_project_ids))
if placement_channel_id:
query = query.filter(placement__channel_id=placement_channel_id)
if creative_id:
query = query.filter(placement__creative_id=creative_id)
if date_from:
query = query.filter(created_at__gte=date_from)
if date_to:
query = query.filter(created_at__lte=date_to)
if not include_archived:
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
return (
await query.prefetch_related(
'placement',
'placement__project',
'placement__project__channel',
'placement__channel',
'placement__creative',
'post',
'post__channel',
)
.order_by('-created_at')
.all()
)
@staticmethod
async def create_placement_post(placement_post: domain.PlacementPost) -> None:
await placement_post.save()
@staticmethod
async def get_placement_post_by_invite_link(invite_link: str) -> domain.PlacementPost | None:
return await domain.PlacementPost.get_or_none(placement__invite_link=invite_link).prefetch_related(
'placement',
'placement__project',
'placement__project__channel',
'placement__channel',
'placement__creative',
)
# Subscription methods
@staticmethod
async def create_subscription(subscription: domain.Subscription) -> None:
await subscription.save()
@staticmethod
async def get_subscription_by_subscriber_and_placement_post(
telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID
) -> domain.Subscription | None:
return await domain.Subscription.get_or_none(
telegram_user_id=telegram_user_id, placement_post_id=placement_post_id
)
@staticmethod
async def update_subscription(subscription: domain.Subscription) -> None:
await subscription.save()
@staticmethod
async def get_subscriptions_for_placement_posts(
placement_post_ids: list[uuid.UUID],
*,
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.Subscription]:
if not placement_post_ids:
return []
query = domain.Subscription.filter(placement_post_id__in=placement_post_ids)
if date_from:
query = query.filter(created_at__gte=date_from)
if date_to:
query = query.filter(created_at__lte=date_to)
return await query.all()
@staticmethod
async def get_active_subscriptions_by_subscriber_and_project(
telegram_user_id: uuid.UUID, project_id: uuid.UUID
) -> list[domain.Subscription]:
return (
await domain.Subscription.filter(
telegram_user_id=telegram_user_id,
placement_post__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE,
)
.prefetch_related('placement_post', 'telegram_user')
.all()
)
@staticmethod
async def get_active_subscription_by_subscriber_and_project(
telegram_user_id: uuid.UUID, project_id: uuid.UUID
) -> domain.Subscription | None:
return (
await domain.Subscription.filter(
telegram_user_id=telegram_user_id,
placement_post__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE,
)
.prefetch_related('placement_post', 'telegram_user')
.first()
)
@staticmethod
async def get_views_history(
post_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None
) -> list[domain.PostViewsHistory]:
query = domain.PostViewsHistory.filter(post_id=post_id)
if from_date:
query = query.filter(fetched_at__gte=from_date)
if to_date:
query = query.filter(fetched_at__lte=to_date)
return await query.order_by('fetched_at').all()
@staticmethod
async def get_latest_views_data_batch(post_ids: list[uuid.UUID]) -> dict[uuid.UUID, tuple[int, datetime.datetime]]:
if not post_ids:
return {}
results: dict[uuid.UUID, tuple[int, datetime.datetime]] = {}
for post_id in post_ids:
latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first()
if latest:
results[post_id] = (latest.views_count, latest.fetched_at)
return results
# Count methods
@staticmethod
async def count_placement_posts_by_creative(creative_id: uuid.UUID) -> int:
return await domain.PlacementPost.filter(placement__creative_id=creative_id).count()
@staticmethod
async def count_subscriptions_by_placement_post(placement_post_id: uuid.UUID) -> int:
return await domain.Subscription.filter(placement_post_id=placement_post_id).count()
@staticmethod
async def count_placement_posts_by_creative_batch(creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not creative_ids:
return {}
from tortoise.functions import Count
results = (
await domain.PlacementPost.filter(placement__creative_id__in=creative_ids)
.group_by('placement__creative_id')
.annotate(count=Count('id'))
.values('placement__creative_id', 'count')
)
counts = {row['placement__creative_id']: row['count'] for row in results}
return {cid: counts.get(cid, 0) for cid in creative_ids}
@staticmethod
async def count_subscriptions_by_placement_post_batch(placement_post_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not placement_post_ids:
return {}
from tortoise.functions import Count
results = (
await domain.Subscription.filter(placement_post_id__in=placement_post_ids)
.group_by('placement_post_id')
.annotate(count=Count('id'))
.values('placement_post_id', 'count')
)
counts = {row['placement_post_id']: row['count'] for row in results}
return {pid: counts.get(pid, 0) for pid in placement_post_ids}
@staticmethod
async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool:
return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists()