feat: creatives and external channels

This commit is contained in:
Artem Tsyrulnikov
2025-11-10 15:13:38 +03:00
parent 3800c72662
commit cd167fdb43
73 changed files with 3024 additions and 947 deletions

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""tgex backend package."""

65
src/adapter/jwt.py Normal file
View File

@@ -0,0 +1,65 @@
import datetime
import uuid
import jwt
import pydantic
class JWTConfig(pydantic.BaseModel):
SECRET_KEY: str
ALGORITHM: str = 'HS256'
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
class JWTPayload(pydantic.BaseModel):
user_id: uuid.UUID
telegram_id: int
username: str | None
class JWT:
def __init__(self, config: JWTConfig) -> None:
self.config = config
def encode_access_token(
self,
user_id: uuid.UUID,
telegram_id: int,
username: str | None = None,
) -> str:
expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
minutes=self.config.ACCESS_TOKEN_EXPIRE_MINUTES
)
payload = {
'sub': str(user_id),
'telegram_id': telegram_id,
'username': username,
'exp': expire,
'type': 'access',
}
encoded: str = jwt.encode(payload, self.config.SECRET_KEY, algorithm=self.config.ALGORITHM)
return encoded
def decode_access_token(self, token: str) -> JWTPayload:
try:
payload = jwt.decode(token, self.config.SECRET_KEY, algorithms=[self.config.ALGORITHM])
if payload.get('type') != 'access':
raise ValueError('Invalid token type')
user_id = payload.get('sub')
if not user_id:
raise ValueError('Token missing subject')
return JWTPayload(
user_id=uuid.UUID(user_id),
telegram_id=payload['telegram_id'],
username=payload.get('username'),
)
except jwt.ExpiredSignatureError as e:
raise ValueError('Token has expired') from e
except jwt.InvalidTokenError as e:
raise ValueError('Invalid token') from e

210
src/adapter/postgres.py Normal file
View File

@@ -0,0 +1,210 @@
import datetime
import uuid
from sqlalchemy import delete, select
from sqlalchemy.orm import selectinload
from shared.datebase_base import DatabaseBase
from src import domain
class Postgres(DatabaseBase):
async def get_user(self, *, user_id: uuid.UUID | None = None, telegram_id: int | None = None) -> domain.User | None:
if user_id:
return await self.session.get(domain.User, user_id)
elif telegram_id:
stmt = select(domain.User).where(domain.User.telegram_id == telegram_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
raise ValueError('Either user_id or telegram_id must be provided')
async def create_user(self, user: domain.User) -> domain.User:
self.session.add(user)
await self.session.flush()
await self.session.refresh(user)
return user
async def create_login_token(
self, user_id: uuid.UUID, token: str, expires_at: datetime.datetime
) -> domain.LoginToken:
login_token = domain.LoginToken(user_id=user_id, token=token, expires_at=expires_at)
self.session.add(login_token)
await self.session.flush()
await self.session.refresh(login_token)
return login_token
async def get_login_token(self, token: str) -> domain.LoginToken | None:
stmt = select(domain.LoginToken).where(domain.LoginToken.token == token)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def mark_token_as_used(self, token: str) -> None:
stmt = select(domain.LoginToken).where(domain.LoginToken.token == token)
result = await self.session.execute(stmt)
login_token = result.scalar_one_or_none()
if login_token:
login_token.used_at = datetime.datetime.now(datetime.UTC)
await self.session.flush()
async def get_target_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
) -> domain.TargetChannel | None:
if not channel_id and not telegram_id:
raise ValueError('Either channel_id or telegram_id must be provided')
stmt = select(domain.TargetChannel).where(domain.TargetChannel.user_id == user_id)
if channel_id:
stmt = stmt.where(domain.TargetChannel.id == channel_id)
if telegram_id:
stmt = stmt.where(domain.TargetChannel.telegram_id == telegram_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
self.session.add(channel)
await self.session.flush()
await self.session.refresh(channel)
return channel
async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
existing = await self.get_target_channel(user_id=channel.user_id, telegram_id=channel.telegram_id)
if not existing:
self.session.add(channel)
await self.session.flush()
await self.session.refresh(channel)
return channel
existing.title = channel.title
existing.username = channel.username
existing.user_id = channel.user_id
existing.is_active = channel.is_active
existing.deleted_at = None
existing.updated_at = datetime.datetime.now(datetime.UTC)
await self.session.flush()
await self.session.refresh(existing)
return existing
async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]:
stmt = select(domain.TargetChannel).where(
domain.TargetChannel.status == domain.TargetChannelStatus.ACTIVE,
domain.TargetChannel.user_id == user_id,
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel:
await self.session.flush()
await self.session.refresh(channel)
return channel
async def get_external_channel(
self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | None:
stmt = select(domain.ExternalChannel).where(domain.ExternalChannel.user_id == user_id)
if channel_id:
stmt = stmt.where(domain.ExternalChannel.id == channel_id)
if telegram_id:
stmt = stmt.where(domain.ExternalChannel.telegram_id == telegram_id)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel:
self.session.add(channel)
await self.session.flush()
await self.session.refresh(channel)
return channel
async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]:
stmt = (
select(domain.ExternalChannel)
.join(domain.ExternalChannel.target_channels)
.where(domain.TargetChannel.id == target_channel_id)
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def add_external_channel_to_targets(
self, external_channel_id: uuid.UUID, target_channel_ids: list[uuid.UUID]
) -> None:
external_channel = await self.session.get(domain.ExternalChannel, external_channel_id)
if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found')
for target_id in target_channel_ids:
target_channel = await self.session.get(domain.TargetChannel, target_id)
if target_channel and target_channel not in external_channel.target_channels:
external_channel.target_channels.append(target_channel)
await self.session.flush()
async def remove_external_channel_from_target(
self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID
) -> None:
external_channel = await self.session.get(domain.ExternalChannel, external_channel_id)
if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found')
target_channel = await self.session.get(domain.TargetChannel, target_channel_id)
if target_channel and target_channel in external_channel.target_channels:
external_channel.target_channels.remove(target_channel)
await self.session.flush()
async def delete_external_channel(self, channel_id: uuid.UUID) -> None:
stmt = delete(domain.ExternalChannel).where(domain.ExternalChannel.id == channel_id)
await self.session.execute(stmt)
await self.session.flush()
async def get_creative(self, creative_id: uuid.UUID, user_id: uuid.UUID) -> domain.Creative | None:
stmt = (
select(domain.Creative)
.options(selectinload(domain.Creative.target_channel))
.where(domain.Creative.id == creative_id, domain.Creative.user_id == user_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_creative(self, creative: domain.Creative) -> domain.Creative:
self.session.add(creative)
await self.session.flush()
await self.session.refresh(creative)
return creative
async def update_creative(self, creative: domain.Creative) -> domain.Creative:
await self.session.flush()
await self.session.refresh(creative)
return creative
async def get_user_creatives(
self, user_id: uuid.UUID, *, target_channel_id: uuid.UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]:
stmt = (
select(domain.Creative)
.options(selectinload(domain.Creative.target_channel))
.where(domain.Creative.user_id == user_id)
)
if target_channel_id:
stmt = stmt.where(domain.Creative.target_channel_id == target_channel_id)
if not include_archived:
stmt = stmt.where(domain.Creative.status == domain.CreativeStatus.ACTIVE)
stmt = stmt.order_by(domain.Creative.created_at.desc())
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def delete_creative(self, creative_id: uuid.UUID) -> None:
stmt = delete(domain.Creative).where(domain.Creative.id == creative_id)
await self.session.execute(stmt)
await self.session.flush()

View File

@@ -1,13 +0,0 @@
import uuid
from shared.postgres.postgres_helper import DatabaseConfig, DatabaseHelper
from src import domain
class Postgres(DatabaseHelper):
def __init__(self, config: DatabaseConfig, migrations_path: str = 'migration/versions') -> None:
super().__init__(config, migrations_path)
async def get_user(self, user_id: uuid.UUID) -> domain.User | None:
async with self.session_getter() as session:
return await session.get(domain.User, user_id)

21
src/adapter/telegram.py Normal file
View File

@@ -0,0 +1,21 @@
import logging
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from shared.telegram_base import TelegramBase
log = logging.getLogger(__name__)
class Telegram(TelegramBase):
async def send_message(self, text: str, chat_id: int) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None:
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=button_text, url=button_url)]])
await self.bot.send_message(chat_id=chat_id, text=text, reply_markup=keyboard)
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, Any]:
member = await self.bot.get_chat_member(chat_id=chat_id, user_id=user_id)
return member.model_dump()

View File

@@ -1,6 +0,0 @@
from shared.telegram import TelegramHelper
class Telegram(TelegramHelper):
async def send_message(self, chat_id: int, text: str) -> None:
await self.bot.send_message(chat_id=chat_id, text=text)

View File

@@ -1,19 +1,25 @@
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
from shared.config_helper import load_settings
from shared.datebase_base import DatabaseConfig
from shared.logger import LoggerConfig
from shared.postgres import DatabaseConfig
from shared.telegram.telegram_bot import TelegramConfig
from shared.telegram_base import TelegramConfig
from src.adapter.jwt import JWTConfig
class AppConfig(BaseModel):
URL: str
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file='.env', case_sensitive=False, env_nested_delimiter='__'
)
model_config = SettingsConfigDict(env_file='.env', case_sensitive=False, env_nested_delimiter='__')
app: AppConfig
db: DatabaseConfig
logger: LoggerConfig
telegram: TelegramConfig
jwt: JWTConfig
settings = load_settings(Settings)
settings: Settings = load_settings(Settings)

View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter
from src.controller.http.auth import auth_router
from src.controller.http.creatives import creatives_router
from src.controller.http.external_channels import external_channels_router
from src.controller.http.target_channels import target_channels_router
api_router = APIRouter()
# Public auth endpoints (no prefix)
api_router.include_router(auth_router)
# API v1 endpoints
api_v1_router = APIRouter(prefix='/api/v1')
api_v1_router.include_router(target_channels_router)
api_v1_router.include_router(external_channels_router)
api_v1_router.include_router(creatives_router)
api_router.include_router(api_v1_router)

View File

@@ -0,0 +1,17 @@
from fastapi import Depends
from fastapi.routing import APIRouter
from src import dto
from src.dependencies import get_usecase
from src.usecase import Usecase
auth_router = APIRouter(prefix='/auth', tags=['auth'])
@auth_router.get('/complete')
async def complete_auth(token: str, usecase: Usecase = Depends(get_usecase)) -> dto.ValidateLoginTokenOutput:
return await usecase.validate_login_token(
input=dto.ValidateLoginTokenInput(
token=token,
)
)

View File

@@ -0,0 +1,85 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import dependencies, dto
from src.adapter.jwt import JWTPayload
creatives_router = APIRouter(prefix='/creatives', tags=['creatives'])
@creatives_router.get('')
async def list_creatives(
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
target_channel_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> dto.GetCreativesOutput:
input = dto.GetCreativesInput(
user_id=current_user.user_id,
target_channel_id=target_channel_id,
include_archived=include_archived,
)
return await dependencies.get_usecase().get_creatives(input=input)
@creatives_router.get('/{creative_id}')
async def get_creative(
creative_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
input = dto.GetCreativeInput(
creative_id=creative_id,
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_creative(input=input)
@creatives_router.post('')
async def create_creative(
request: dto.CreateCreativeInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
return await dependencies.get_usecase().create_creative(request, current_user.user_id)
@creatives_router.patch('/{creative_id}')
async def update_creative(
creative_id: uuid.UUID,
request: dto.UpdateCreativeInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
return await dependencies.get_usecase().update_creative(
creative_id=creative_id,
input=request,
user_id=current_user.user_id,
)
@creatives_router.post('/{creative_id}/archive')
async def archive_creative(
creative_id: uuid.UUID,
request: dto.ArchiveCreativeInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.CreativeOutput:
return await dependencies.get_usecase().archive_creative(
creative_id=creative_id,
input=request,
user_id=current_user.user_id,
)
@creatives_router.delete('/{creative_id}')
async def delete_creative(
creative_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
input = dto.DeleteCreativeInput(
creative_id=creative_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().delete_creative(input)

View File

@@ -0,0 +1,90 @@
import uuid
from typing import Annotated
from fastapi import Depends, File, UploadFile
from fastapi.routing import APIRouter
from src import dependencies, dto
from src.adapter.jwt import JWTPayload
external_channels_router = APIRouter(prefix='/external_channels', tags=['external_channels'])
@external_channels_router.get('/target/{target_channel_id}')
async def get_external_channels(
target_channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.GetExternalChannelsOutput:
"""Get all external channels for a specific target channel."""
input = dto.GetExternalChannelsInput(
target_channel_id=target_channel_id,
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_external_channels(input=input)
@external_channels_router.post('')
async def create_external_channel(
request: dto.CreateExternalChannelInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.ExternalChannelOutput:
"""Create a new external channel and link it to target channels."""
return await dependencies.get_usecase().create_external_channel(
input=request,
user_id=current_user.user_id,
)
@external_channels_router.patch('/{channel_id}/links')
async def update_external_channel_links(
channel_id: uuid.UUID,
request: dto.UpdateExternalChannelLinksInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.ExternalChannelOutput:
"""
Update target channel links for an existing external channel.
You can add new target channels, remove existing ones, or both in a single request.
"""
return await dependencies.get_usecase().update_external_channel_links(
channel_id=channel_id,
input=request,
user_id=current_user.user_id,
)
@external_channels_router.delete('/{channel_id}')
async def delete_external_channel(
channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
"""Delete an external channel."""
input = dto.DeleteExternalChannelInput(
channel_id=channel_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().delete_external_channel(input=input)
@external_channels_router.post('/import/{target_channel_id}')
async def import_external_channels(
target_channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
file: UploadFile = File(...),
) -> dto.ImportExternalChannelsOutput:
"""
Import external channels from Excel file (Tgstat export).
Note: This is a mock endpoint until Excel structure is clarified.
"""
file_content = await file.read()
input = dto.ImportExternalChannelsInput(
target_channel_id=target_channel_id,
user_id=current_user.user_id,
file_content=file_content,
)
return await dependencies.get_usecase().import_external_channels_from_excel(input=input)

View File

@@ -0,0 +1,34 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import dependencies, dto
from src.adapter.jwt import JWTPayload
target_channels_router = APIRouter(prefix='/target_channels', tags=['target_channels'])
@target_channels_router.get('')
async def get_user_target_chans(
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.GetUserTargetChansOutput:
input = dto.GetUserTargetChansInput(
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_user_target_chans(input=input)
@target_channels_router.delete('/{channel_id}')
async def disconnect_target_chan(
channel_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
input = dto.DisconnectTargetChanInput(
channel_id=channel_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().disconnect_target_chan(input=input)

View File

@@ -1,17 +0,0 @@
import uuid
from fastapi import APIRouter, Depends
from src import dto
from src.dependencies import get_usecase
from src.usecase.usecase import Usecase
user_router = APIRouter(prefix='/users', dependencies=[Depends(get_usecase)])
@user_router.get('/{user_id}')
async def get_user(
user_id: uuid.UUID,
usecase: Usecase = Depends(get_usecase),
) -> dto.GetUserOutput:
return await usecase.get_user(user_id)

View File

@@ -1,83 +0,0 @@
import uuid
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from src.dependencies import get_usecase
telegram_router = Router()
@telegram_router.message(Command('start'))
async def cmd_start(message: Message) -> None:
await message.answer(
'Привет! Я бот для работы с пользователями.\n\n'
'Доступные команды:\n'
'/getuser <user_id> - получить информацию о пользователе\n'
'/notify <user_id> <message> - отправить уведомление пользователю'
)
@telegram_router.message(Command('getuser'))
async def cmd_get_user(message: Message) -> None:
usecase = get_usecase()
if not message.text:
await message.answer('Ошибка: не удалось прочитать текст команды')
return
args = message.text.split()
if len(args) < 2:
await message.answer(
'Использование: /getuser <user_id>\n' 'Пример: /getuser 123e4567-e89b-12d3-a456-426614174000'
)
return
try:
user_id = uuid.UUID(args[1])
except ValueError:
await message.answer('Ошибка: неверный формат UUID')
return
try:
user = await usecase.get_user(user_id)
await message.answer(
f'Пользователь найден:\n'
f'ID: {user.id}\n'
f'Имя: {user.name}\n'
f'Создан: {user.created_at}\n'
f'Обновлён: {user.updated_at}'
)
except Exception as e:
await message.answer(f'Ошибка при получении пользователя: {str(e)}')
@telegram_router.message(Command('notify'))
async def cmd_notify_user(message: Message) -> None:
usecase = get_usecase()
if not message.text or not message.from_user:
await message.answer('Ошибка: не удалось прочитать команду')
return
args = message.text.split(maxsplit=2)
if len(args) < 3:
await message.answer(
'Использование: /notify <user_id> <message>\n'
'Пример: /notify 123e4567-e89b-12d3-a456-426614174000 Привет!'
)
return
try:
user_id = uuid.UUID(args[1])
notification_text = args[2]
except ValueError:
await message.answer('Ошибка: неверный формат UUID')
return
try:
await usecase.notify_user(user_id, message.from_user.id, notification_text)
await message.answer('Уведомление отправлено!')
except Exception as e:
await message.answer(f'Ошибка при отправке уведомления: {str(e)}')

View File

@@ -0,0 +1,6 @@
from aiogram import Router
telegram_callback_router = Router()
# Import handlers to register them
from . import my_chat_member, start_with_login # noqa: E402, F401

View File

@@ -0,0 +1,83 @@
import logging
from aiogram.types import ChatMemberAdministrator, ChatMemberUpdated
from fastapi import HTTPException
from src import dependencies, dto
from src.controller.telegram_callback import telegram_callback_router
log = logging.getLogger(__name__)
@telegram_callback_router.my_chat_member()
async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
if event.chat.type not in {'channel', 'supergroup'}:
return
if not event.from_user:
log.error('Failed to get user data from chat member update')
return
usecase = dependencies.get_usecase()
old_status = event.old_chat_member.status
new_status = event.new_chat_member.status
# Проверяем что бот был удален
was_member = old_status in ('administrator', 'member')
is_now_not_member = new_status in ('left', 'kicked')
bot_removed = was_member and is_now_not_member
if bot_removed:
disconnect_input = dto.DisconnectTargetChanByTgIdInput(
telegram_id=event.chat.id,
)
try:
await usecase.disconnect_target_chan_by_tg_id(input=disconnect_input)
except HTTPException as e:
log.error(e)
return
# Проверяем изменение прав администратора
permissions_changed = old_status == 'administrator' and new_status == 'administrator'
# Извлекаем права бота из события
new_member = event.new_chat_member
bot_permissions = dto.ChannelBotPermissions(
is_admin=isinstance(new_member, ChatMemberAdministrator),
can_invite_users=getattr(new_member, 'can_invite_users', False),
can_restrict_members=getattr(new_member, 'can_restrict_members', False),
)
if permissions_changed:
permissions_input = dto.UpdateTargetChanPermissionsInput(
telegram_id=event.chat.id,
permissions=bot_permissions,
chat_title=event.chat.title or f'Channel {event.chat.id}',
user_telegram_id=event.from_user.id,
)
try:
await usecase.update_target_chan_permissions(input=permissions_input)
except HTTPException as e:
log.error(e)
return
# Проверяем что бот был добавлен
was_not_member = old_status in ('left', 'kicked')
is_now_member = new_status in ('administrator', 'member')
bot_added = was_not_member and is_now_member
if bot_added:
connect_input = dto.ConnectTargetChanInput(
telegram_id=event.chat.id,
title=event.chat.title or f'Channel {event.chat.id}',
username=event.chat.username,
user_telegram_id=event.from_user.id,
bot_permissions=bot_permissions,
)
try:
await usecase.connect_target_chan(input=connect_input)
except HTTPException as e:
log.error(e)

View File

@@ -0,0 +1,30 @@
import logging
from aiogram.filters import CommandStart
from aiogram.types import Message
from src import dependencies, dto
from src.controller.telegram_callback import telegram_callback_router
log = logging.getLogger(__name__)
@telegram_callback_router.message(CommandStart(deep_link=True))
async def cmd_start_with_deeplink(message: Message) -> None:
if not message.from_user:
log.error('Failed to get user data from tg_message')
return
args = message.text.split() if message.text else []
deeplink_param = args[1].split('_')[0] if len(args) > 1 else None
if deeplink_param != 'login':
return
input = dto.TelegramLoginInput(
telegram_id=message.from_user.id,
username=message.from_user.username,
chat_id=message.chat.id,
)
await dependencies.get_usecase().telegram_login(input=input)

View File

@@ -1,36 +1,41 @@
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager
from typing import Annotated
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src.usecase.usecase import Usecase
from src.adapter.jwt import JWT, JWTPayload
from src.config import settings
from src.usecase import Usecase
_usecase_instance: Usecase | None = None
_session_context_manager: Callable[[], AbstractAsyncContextManager[AsyncSession]] | None = None
_usecase_template: Usecase | None = None
security = HTTPBearer()
def set_usecase(usecase: Usecase) -> None:
global _usecase_instance
_usecase_instance = usecase
global _usecase_template
_usecase_template = usecase
def get_usecase() -> Usecase:
if _usecase_instance is None:
if _usecase_template is None:
raise RuntimeError('Usecase not initialized. Call set_usecase() first')
return _usecase_instance
return _usecase_template
def set_get_session(
session_context_manager: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> None:
global _session_context_manager
_session_context_manager = session_context_manager
def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> JWTPayload:
token = credentials.credentials
jwt_decoder = JWT(settings.jwt)
async def get_session() -> AsyncGenerator[AsyncSession]:
if _session_context_manager is None:
raise RuntimeError('get_session not initialized. Call set_get_session() first')
try:
payload: JWTPayload = jwt_decoder.decode_access_token(token)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={'WWW-Authenticate': 'Bearer'},
) from e
async with _session_context_manager() as session:
yield session
return payload

View File

@@ -1,5 +1,41 @@
__all__ = ('Base', 'User', 'UserNotFound')
__all__ = (
'Base',
'User',
'TargetChannel',
'ExternalChannel',
'Creative',
'TargetChannelStatus',
'CreativeStatus',
'LoginToken',
'UserNotFound',
'LoginTokenNotFound',
'LoginTokenExpired',
'LoginTokenAlreadyUsed',
'TargetChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
'ExternalChannelNotFound',
'ExternalChannelAlreadyExists',
'CreativeNotFound',
'CreativeInUse',
)
from .base import Base
from .error import UserNotFound
from .creative import Creative, CreativeStatus
from .error import (
ChannelAlreadyExists,
ChannelNoAdminRights,
CreativeInUse,
CreativeNotFound,
ExternalChannelAlreadyExists,
ExternalChannelNotFound,
LoginTokenAlreadyUsed,
LoginTokenExpired,
LoginTokenNotFound,
TargetChannelNotFound,
UserNotFound,
)
from .external_channel import ExternalChannel
from .login_token import LoginToken
from .target_channel import TargetChannel, TargetChannelStatus
from .user import User

View File

@@ -3,11 +3,17 @@ import uuid
from sqlalchemy import func
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
from sqlalchemy.types import DateTime
class Base(DeclarativeBase):
__abstract__ = True
# Use timezone-aware datetime (Postgres TIMESTAMP WITH TIME ZONE)
type_annotation_map = {
datetime.datetime: DateTime(timezone=True),
}
# Авто имя таблиц по названию класса
@declared_attr.directive
def __tablename__(cls) -> str:
@@ -15,7 +21,5 @@ class Base(DeclarativeBase):
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
updated_at: Mapped[datetime.datetime] = mapped_column(
server_default=func.now(), onupdate=func.now()
)
updated_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now(), onupdate=func.now())
deleted_at: Mapped[datetime.datetime | None]

27
src/domain/creative.py Normal file
View File

@@ -0,0 +1,27 @@
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from .target_channel import TargetChannel
class CreativeStatus(StrEnum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class Creative(domain.Base):
name: Mapped[str]
text: Mapped[str]
status: Mapped[CreativeStatus]
purchases_count: Mapped[int] = mapped_column(default=0, server_default='0')
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True)
target_channel: Mapped['TargetChannel'] = relationship(back_populates='creatives')

View File

@@ -3,5 +3,58 @@ import uuid
from fastapi import HTTPException, status
def UserNotFound(user_id: uuid.UUID) -> HTTPException:
def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException:
if user_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
def LoginTokenNotFound() -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
def LoginTokenExpired() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has expired')
def LoginTokenAlreadyUsed() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used')
def TargetChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Target channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Target channel {channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'Channel {telegram_id} already exists in the system')
def ChannelNoAdminRights() -> HTTPException:
return HTTPException(
status.HTTP_403_FORBIDDEN, 'Bot must be added as administrator with invite link creation rights'
)
def ExternalChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'External channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'External channel {channel_id} not found')
def ExternalChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'External channel {telegram_id} already exists')
def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
if creative_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Creative {creative_id} not found')
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException(
status.HTTP_400_BAD_REQUEST,
f'Creative {creative_id} is used in active purchases and cannot be deleted',
)

View File

@@ -0,0 +1,33 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, Column, ForeignKey, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
# M2M association table between TargetChannel and ExternalChannel
target_channel_external_channel = Table(
'target_channel_external_channels',
domain.Base.metadata,
Column('target_channel_id', ForeignKey('targetchannels.id', ondelete='CASCADE'), primary_key=True),
Column('external_channel_id', ForeignKey('externalchannels.id', ondelete='CASCADE'), primary_key=True),
)
class ExternalChannel(domain.Base):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
title: Mapped[str]
username: Mapped[str | None] = mapped_column(index=True)
description: Mapped[str | None]
subscribers_count: Mapped[int | None]
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
# M2M relationship with target channels
target_channels: Mapped[list['TargetChannel']] = relationship(
secondary=target_channel_external_channel,
back_populates='external_channels',
)

14
src/domain/login_token.py Normal file
View File

@@ -0,0 +1,14 @@
import datetime
import uuid
from sqlalchemy import ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from src import domain
class LoginToken(domain.Base):
token: Mapped[str] = mapped_column(unique=True, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'))
expires_at: Mapped[datetime.datetime]
used_at: Mapped[datetime.datetime | None]

View File

@@ -0,0 +1,44 @@
import uuid
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import ExternalChannel, creative, user
class TargetChannelStatus(StrEnum):
ACTIVE = 'active'
INACTIVE = 'inactive'
ARCHIVED = 'archived'
class TargetChannel(domain.Base):
telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
title: Mapped[str]
username: Mapped[str | None] = mapped_column(index=True)
status: Mapped[TargetChannelStatus]
# Relationship with user
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
user: Mapped['user.User'] = relationship(back_populates='target_channels')
# M2M relationship with external channels
external_channels: Mapped[list['ExternalChannel']] = relationship(
secondary='target_channel_external_channels',
back_populates='target_channels',
)
creatives: Mapped[list['creative.Creative']] = relationship(back_populates='target_channel')
@property
def is_active(self) -> bool:
return self.status == domain.TargetChannelStatus.ACTIVE
@is_active.setter
def is_active(self, value: bool) -> None:
self.status = domain.TargetChannelStatus.ACTIVE if value else domain.TargetChannelStatus.INACTIVE

View File

@@ -1,7 +1,16 @@
from sqlalchemy.orm import Mapped
from typing import TYPE_CHECKING
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src import domain
if TYPE_CHECKING:
from . import TargetChannel
class User(domain.Base):
name: Mapped[str]
telegram_id: Mapped[int] = mapped_column(unique=True, index=True)
username: Mapped[str | None]
# Relationship with channels
target_channels: Mapped[list['TargetChannel']] = relationship(back_populates='user')

View File

@@ -1,3 +1,62 @@
__all__ = ('GetUserInput', 'GetUserOutput')
__all__ = (
'UpdateTargetChanPermissionsInput',
'GetUserTargetChansInput',
'GetUserTargetChansOutput',
'DisconnectTargetChanInput',
'DisconnectTargetChanByTgIdInput',
'ConnectTargetChanInput',
'ConnectTargetChanOutput',
'ValidateLoginTokenInput',
'ValidateLoginTokenOutput',
'TelegramLoginInput',
'ChannelBotPermissions',
'ExternalChannelOutput',
'GetExternalChannelsInput',
'GetExternalChannelsOutput',
'CreateExternalChannelInput',
'DeleteExternalChannelInput',
'UpdateExternalChannelLinksInput',
'ImportExternalChannelsInput',
'ImportExternalChannelsOutput',
'CreativeOutput',
'GetCreativesInput',
'GetCreativesOutput',
'GetCreativeInput',
'CreateCreativeInput',
'UpdateCreativeInput',
'ArchiveCreativeInput',
'DeleteCreativeInput',
)
from .get_user import GetUserInput, GetUserOutput
from .creative import (
ArchiveCreativeInput,
CreateCreativeInput,
CreativeOutput,
DeleteCreativeInput,
GetCreativeInput,
GetCreativesInput,
GetCreativesOutput,
UpdateCreativeInput,
)
from .external_channel import (
CreateExternalChannelInput,
DeleteExternalChannelInput,
ExternalChannelOutput,
GetExternalChannelsInput,
GetExternalChannelsOutput,
ImportExternalChannelsInput,
ImportExternalChannelsOutput,
UpdateExternalChannelLinksInput,
)
from .target_channel import (
ChannelBotPermissions,
ConnectTargetChanInput,
ConnectTargetChanOutput,
DisconnectTargetChanByTgIdInput,
DisconnectTargetChanInput,
GetUserTargetChansInput,
GetUserTargetChansOutput,
UpdateTargetChanPermissionsInput,
)
from .telegram_login import TelegramLoginInput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput

48
src/dto/creative.py Normal file
View File

@@ -0,0 +1,48 @@
import datetime
import uuid
import pydantic
from src import domain
class CreativeOutput(pydantic.BaseModel):
id: uuid.UUID
name: str
text: str
target_channel_id: uuid.UUID
target_channel_title: str
created_at: datetime.datetime
status: domain.CreativeStatus
purchases_count: int
class GetCreativesInput(pydantic.BaseModel):
user_id: uuid.UUID
target_channel_id: uuid.UUID | None = None
include_archived: bool = False
class GetCreativesOutput(pydantic.BaseModel):
creatives: list[CreativeOutput]
class GetCreativeInput(pydantic.BaseModel):
creative_id: uuid.UUID
user_id: uuid.UUID
class CreateCreativeInput(pydantic.BaseModel):
name: str
text: str
target_channel_id: uuid.UUID
class UpdateCreativeInput(pydantic.BaseModel):
name: str | None = None
text: str | None = None
class DeleteCreativeInput(pydantic.BaseModel):
creative_id: uuid.UUID
user_id: uuid.UUID

View File

@@ -0,0 +1,54 @@
import uuid
import pydantic
class ExternalChannelOutput(pydantic.BaseModel):
id: uuid.UUID
telegram_id: int
title: str
username: str | None
description: str | None
subscribers_count: int | None
class GetExternalChannelsInput(pydantic.BaseModel):
target_channel_id: uuid.UUID
user_id: uuid.UUID
class GetExternalChannelsOutput(pydantic.BaseModel):
external_channels: list[ExternalChannelOutput]
class CreateExternalChannelInput(pydantic.BaseModel):
telegram_id: int
title: str
username: str | None = None
description: str | None = None
subscribers_count: int | None = None
target_channel_ids: list[uuid.UUID]
class DeleteExternalChannelInput(pydantic.BaseModel):
channel_id: uuid.UUID
user_id: uuid.UUID
class UpdateExternalChannelLinksInput(pydantic.BaseModel):
"""Request body for PATCH endpoint - links to add or remove."""
add_target_channel_ids: list[uuid.UUID] = []
remove_target_channel_ids: list[uuid.UUID] = []
class ImportExternalChannelsInput(pydantic.BaseModel):
target_channel_id: uuid.UUID
user_id: uuid.UUID
file_content: bytes # Excel file content
class ImportExternalChannelsOutput(pydantic.BaseModel):
created_count: int
skipped_count: int
errors: list[str]

View File

@@ -1,12 +0,0 @@
import uuid
import pydantic
class GetUserInput(pydantic.BaseModel):
user_id: uuid.UUID
class GetUserOutput(pydantic.BaseModel):
user_id: uuid.UUID
name: str

56
src/dto/target_channel.py Normal file
View File

@@ -0,0 +1,56 @@
import uuid
import pydantic
class ChannelBotPermissions(pydantic.BaseModel):
is_admin: bool
can_invite_users: bool
can_restrict_members: bool
can_manage_chat: bool | None = None
can_delete_messages: bool | None = None
can_manage_video_chats: bool | None = None
can_post_messages: bool | None = None
can_edit_messages: bool | None = None
can_pin_messages: bool | None = None
class ConnectTargetChanInput(pydantic.BaseModel):
telegram_id: int
title: str
username: str | None
user_telegram_id: int
bot_permissions: ChannelBotPermissions
class ConnectTargetChanOutput(pydantic.BaseModel):
id: uuid.UUID
telegram_id: int
title: str
username: str | None
is_active: bool
class GetUserTargetChansInput(pydantic.BaseModel):
user_id: uuid.UUID
class GetUserTargetChansOutput(pydantic.BaseModel):
target_channels: list[ConnectTargetChanOutput]
class DisconnectTargetChanInput(pydantic.BaseModel):
channel_id: uuid.UUID
user_id: uuid.UUID
class DisconnectTargetChanByTgIdInput(pydantic.BaseModel):
telegram_id: int
user_id: uuid.UUID
class UpdateTargetChanPermissionsInput(pydantic.BaseModel):
telegram_id: int
permissions: ChannelBotPermissions
chat_title: str
user_telegram_id: int

View File

@@ -0,0 +1,7 @@
import pydantic
class TelegramLoginInput(pydantic.BaseModel):
telegram_id: int
username: str | None
chat_id: int

View File

@@ -0,0 +1,9 @@
import pydantic
class ValidateLoginTokenInput(pydantic.BaseModel):
token: str
class ValidateLoginTokenOutput(pydantic.BaseModel):
access_token: str

View File

@@ -2,16 +2,16 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.routing import APIRouter
from shared import logger
from src import dependencies
from src.adapter.postgres.postgres import Postgres
from src.adapter.telegram.telegram import Telegram
from src.adapter.jwt import JWT
from src.adapter.postgres import Postgres
from src.adapter.telegram import Telegram
from src.config import settings
from src.controller.http.user import user_router
from src.controller.telegram.bot import telegram_router
from src.usecase.usecase import Usecase
from src.controller.http import api_router
from src.controller.telegram_callback import telegram_callback_router
from src.usecase import Usecase
@asynccontextmanager
@@ -29,23 +29,15 @@ logger.init(settings.logger)
# Dependencies
postgres = Postgres(settings.db)
dependencies.set_get_session(postgres.session_getter)
telegram = Telegram(settings.telegram, telegram_router)
telegram = Telegram(settings.telegram, telegram_callback_router)
jwt_encoder = JWT(settings.jwt)
usecase = Usecase(
database=postgres,
telegram_writer=telegram,
jwt_encoder=jwt_encoder,
)
dependencies.set_usecase(usecase)
app: FastAPI = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
# Routing
api_router = APIRouter(prefix='/api')
v1_router = APIRouter(prefix='/v1')
v1_router.include_router(user_router)
api_router.include_router(v1_router)
app = FastAPI(lifespan=lifespan, version=settings.logger.APP_VERSION)
app.include_router(api_router)

123
src/usecase/__init__.py Normal file
View File

@@ -0,0 +1,123 @@
import datetime
import typing
from dataclasses import dataclass
from uuid import UUID
from src import domain
from .creative.archive_creative import archive_creative
from .creative.create_creative import create_creative
from .creative.delete_creative import delete_creative
from .creative.get_creative import get_creative
from .creative.get_creatives import get_creatives
from .creative.update_creative import update_creative
from .external_channel.create_external_channel import create_external_channel
from .external_channel.delete_external_channel import delete_external_channel
from .external_channel.get_external_channels import get_external_channels
from .external_channel.import_external_channels_from_excel import import_external_channels_from_excel
from .external_channel.update_external_channel_links import update_external_channel_links
from .target_channel.connect_target_chan import connect_target_chan
from .target_channel.disconnect_target_chan import disconnect_target_chan
from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_chan_by_tg_id
from .target_channel.get_user_target_chans import get_user_target_chans
from .target_channel.update_target_chan_permissions import update_target_chan_permissions
from .telegram_login import telegram_login
from .validate_login_token import validate_login_token
class Database(typing.Protocol):
def transaction(self) -> typing.AsyncContextManager[None]: ...
async def get_user(self, *, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ...
async def create_user(self, user: domain.User) -> domain.User: ...
async def create_login_token(
self, user_id: UUID, token: str, expires_at: datetime.datetime
) -> domain.LoginToken: ...
async def get_login_token(self, token: str) -> domain.LoginToken | None: ...
async def mark_token_as_used(self, token: str) -> None: ...
async def get_target_channel(
self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
) -> domain.TargetChannel | None: ...
async def create_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ...
async def upsert_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ...
async def get_user_target_channels(self, user_id: UUID) -> list[domain.TargetChannel]: ...
async def update_target_channel(self, channel: domain.TargetChannel) -> domain.TargetChannel: ...
async def get_external_channel(
self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | None: ...
async def create_external_channel(self, channel: domain.ExternalChannel) -> domain.ExternalChannel: ...
async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ...
async def add_external_channel_to_targets(
self, external_channel_id: UUID, target_channel_ids: list[UUID]
) -> None: ...
async def remove_external_channel_from_target(self, external_channel_id: UUID, target_channel_id: UUID) -> None: ...
async def delete_external_channel(self, channel_id: UUID) -> None: ...
async def get_creative(self, user_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
async def create_creative(self, creative: domain.Creative) -> domain.Creative: ...
async def update_creative(self, creative: domain.Creative) -> domain.Creative: ...
async def get_user_creatives(
self,
user_id: UUID,
*,
target_channel_id: UUID | None = None,
include_archived: bool = False,
) -> list[domain.Creative]: ...
async def delete_creative(self, creative_id: UUID) -> None: ...
class TelegramWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ...
async def send_message_with_button(self, text: str, chat_id: int, button_text: str, button_url: str) -> None: ...
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, typing.Any]: ...
class JWTEncoder(typing.Protocol):
def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ...
@dataclass
class Usecase:
database: Database
telegram_writer: TelegramWriter
jwt_encoder: JWTEncoder
validate_login_token = validate_login_token
telegram_login = telegram_login
connect_target_chan = connect_target_chan
get_user_target_chans = get_user_target_chans
disconnect_target_chan = disconnect_target_chan
disconnect_target_chan_by_tg_id = disconnect_target_chan_by_tg_id
update_target_chan_permissions = update_target_chan_permissions
get_external_channels = get_external_channels
create_external_channel = create_external_channel
delete_external_channel = delete_external_channel
update_external_channel_links = update_external_channel_links
import_external_channels_from_excel = import_external_channels_from_excel
get_creatives = get_creatives
get_creative = get_creative
create_creative = create_creative
update_creative = update_creative
archive_creative = archive_creative
delete_creative = delete_creative

View File

@@ -0,0 +1,32 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def archive_creative(self: 'Usecase', creative_id: uuid.UUID, user_id: uuid.UUID) -> dto.CreativeOutput:
async with self.database.transaction():
creative = await self.database.get_creative(user_id, creative_id)
if not creative:
log.warning('User %s attempted to change archive status for unavailable creative %s', user_id, creative_id)
raise domain.CreativeNotFound(creative_id)
creative.status = domain.CreativeStatus.ARCHIVED
updated = await self.database.update_creative(creative)
return dto.CreativeOutput(
id=updated.id,
name=updated.name,
text=updated.text,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
created_at=updated.created_at,
status=updated.status,
purchases_count=updated.purchases_count,
)

View File

@@ -0,0 +1,40 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID) -> dto.CreativeOutput:
async with self.database.transaction():
target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id)
if not target_channel:
log.warning(
'User %s attempted to create creative for unavailable target %s', user_id, input.target_channel_id
)
raise domain.TargetChannelNotFound(input.target_channel_id)
creative = domain.Creative(
name=input.name,
text=input.text,
target_channel_id=target_channel.id,
user_id=user_id,
)
created = await self.database.create_creative(creative)
return dto.CreativeOutput(
id=created.id,
name=created.name,
text=created.text,
target_channel_id=created.target_channel_id,
target_channel_title=created.target_channel.title,
created_at=created.created_at,
status=created.status,
purchases_count=created.purchases_count,
)

View File

@@ -0,0 +1,23 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
async with self.database.transaction():
creative = await self.database.get_creative(input.user_id, input.creative_id)
if not creative:
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
raise domain.CreativeNotFound(input.creative_id)
if creative.purchases_count > 0:
log.warning('Creative %s is used in purchases and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(input.creative_id)
await self.database.delete_creative(input.creative_id)

View File

@@ -0,0 +1,28 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
async with self.database.transaction():
creative = await self.database.get_creative(input.user_id, input.creative_id)
if not creative:
log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id)
raise domain.CreativeNotFound(input.creative_id)
return dto.CreativeOutput(
id=creative.id,
name=creative.name,
text=creative.text,
target_channel_id=creative.target_channel_id,
target_channel_title=creative.target_channel.title,
created_at=creative.created_at,
status=creative.status,
purchases_count=creative.purchases_count,
)

View File

@@ -0,0 +1,29 @@
from typing import TYPE_CHECKING
from src import dto
if TYPE_CHECKING:
from .. import Usecase
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput:
async with self.database.transaction():
creatives = await self.database.get_user_creatives(
input.user_id, target_channel_id=input.target_channel_id, include_archived=input.include_archived
)
return dto.GetCreativesOutput(
creatives=[
dto.CreativeOutput(
id=creative.id,
name=creative.name,
text=creative.text,
target_channel_id=creative.target_channel_id,
target_channel_title=creative.target_channel.title,
created_at=creative.created_at,
status=creative.status,
purchases_count=creative.purchases_count,
)
for creative in creatives
]
)

View File

@@ -0,0 +1,38 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_creative(
self: 'Usecase', creative_id: uuid.UUID, input: dto.UpdateCreativeInput, user_id: uuid.UUID
) -> dto.CreativeOutput:
async with self.database.transaction():
creative = await self.database.get_creative(user_id, creative_id)
if not creative:
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
raise domain.CreativeNotFound(creative_id)
if input.name is not None:
creative.name = input.name
if input.text is not None:
creative.text = input.text
updated = await self.database.update_creative(creative)
return dto.CreativeOutput(
id=updated.id,
name=updated.name,
text=updated.text,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
created_at=updated.created_at,
status=updated.status,
purchases_count=updated.purchases_count,
)

View File

@@ -0,0 +1,47 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def create_external_channel(
self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID
) -> dto.ExternalChannelOutput:
async with self.database.transaction():
for target_id in input.target_channel_ids:
target_channel = await self.database.get_target_channel(user_id, channel_id=target_id)
if not target_channel:
log.warning('User %s attempted to add external channel to unavailable target %s', user_id, target_id)
raise domain.TargetChannelNotFound(target_id)
channel = domain.ExternalChannel(
telegram_id=input.telegram_id,
title=input.title,
username=input.username,
description=input.description,
subscribers_count=input.subscribers_count,
user_id=user_id,
)
created_channel = await self.database.create_external_channel(channel)
if input.target_channel_ids:
await self.database.add_external_channel_to_targets(created_channel.id, input.target_channel_ids)
log.info(
'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids)
)
return dto.ExternalChannelOutput(
id=created_channel.id,
telegram_id=created_channel.telegram_id,
title=created_channel.title,
username=created_channel.username,
description=created_channel.description,
subscribers_count=created_channel.subscribers_count,
)

View File

@@ -0,0 +1,20 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None:
async with self.database.transaction():
channel = await self.database.get_external_channel(input.user_id, channel_id=input.channel_id)
if not channel:
log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id)
raise domain.ExternalChannelNotFound(input.channel_id)
await self.database.delete_external_channel(input.channel_id)
log.info('User %s deleted external channel %s', input.user_id, input.channel_id)

View File

@@ -0,0 +1,35 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput:
async with self.database.transaction():
target_channel = await self.database.get_target_channel(input.user_id, channel_id=input.target_channel_id)
if not target_channel:
log.warning(
'Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id
)
raise domain.TargetChannelNotFound(input.target_channel_id)
channels = await self.database.get_external_channels_for_target(input.target_channel_id)
return dto.GetExternalChannelsOutput(
external_channels=[
dto.ExternalChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
description=channel.description,
subscribers_count=channel.subscribers_count,
)
for channel in channels
]
)

View File

@@ -0,0 +1,41 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def import_external_channels_from_excel(
self: 'Usecase', input: dto.ImportExternalChannelsInput
) -> dto.ImportExternalChannelsOutput:
async with self.database.transaction():
target_channel = await self.database.get_target_channel(
user_id=input.user_id, channel_id=input.target_channel_id
)
if not target_channel:
log.warning(
'User %s attempted to import to unavailable target channel %s', input.user_id, input.target_channel_id
)
raise domain.TargetChannelNotFound(input.target_channel_id)
created_count = 0
skipped_count = 0
errors: list[str] = []
log.info(
'Excel import completed for target %s: %s created, %s skipped, %s errors',
input.target_channel_id,
created_count,
skipped_count,
len(errors),
)
return dto.ImportExternalChannelsOutput(
created_count=created_count,
skipped_count=skipped_count,
errors=errors,
)

View File

@@ -0,0 +1,50 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_external_channel_links(
self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelLinksInput, user_id: uuid.UUID
) -> dto.ExternalChannelOutput:
async with self.database.transaction():
external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id)
if not external_channel:
log.warning('External channel %s not found', channel_id)
raise domain.ExternalChannelNotFound(channel_id)
for target_id in input.add_target_channel_ids:
target_channel = await self.database.get_target_channel(user_id, channel_id=target_id)
if not target_channel:
log.warning(
'User %s attempted to link external channel to target %s they do not own', user_id, target_id
)
raise domain.TargetChannelNotFound(target_id)
if input.add_target_channel_ids:
await self.database.add_external_channel_to_targets(channel_id, input.add_target_channel_ids)
for target_id in input.remove_target_channel_ids:
await self.database.remove_external_channel_from_target(channel_id, target_id)
log.info(
'External channel %s links updated: %s added, %s removed',
channel_id,
len(input.add_target_channel_ids),
len(input.remove_target_channel_ids),
)
return dto.ExternalChannelOutput(
id=external_channel.id,
telegram_id=external_channel.telegram_id,
title=external_channel.title,
username=external_channel.username,
description=external_channel.description,
subscribers_count=external_channel.subscribers_count,
)

View File

@@ -1,18 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from src.usecase.usecase import Usecase
async def get_user(self: 'Usecase', user_id: uuid.UUID) -> dto.GetUserOutput:
user: domain.User | None = await self.database.get_user(user_id=user_id)
if user is None:
raise domain.UserNotFound(user_id)
return dto.GetUserOutput(
user_id=user.id,
name=user.name,
)

View File

@@ -1,22 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from src.usecase.usecase import Usecase
async def notify_user(self: 'Usecase', user_id: uuid.UUID, chat_id: int, message: str) -> None:
"""Send notification to user via Telegram"""
if self.telegram_writer is None:
raise RuntimeError('TelegramWriter not configured')
# Get user from database
user = await self.database.get_user(user_id)
if user is None:
raise domain.UserNotFound(user_id)
# Send personalized message
text = f'Привет, {user.name}!\n\n{message}'
await self.telegram_writer.send_message(chat_id=chat_id, text=text)

View File

@@ -0,0 +1,77 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def connect_target_chan(self: 'Usecase', input: dto.ConnectTargetChanInput) -> dto.ConnectTargetChanOutput:
permissions = input.bot_permissions
if not permissions.is_admin:
log.warning(f'Bot is not admin in channel {input.telegram_id}. Attempted by user {input.user_telegram_id}')
await self.telegram_writer.send_message(
f'⚠️ Бот был добавлен в канал "{input.title}", но не является админом.\n\n'
'Пожалуйста, сделайте бота администратором канала.',
chat_id=input.user_telegram_id,
)
raise domain.ChannelNoAdminRights()
missing_permissions = []
if not permissions.can_invite_users:
missing_permissions.append('Создание инвайт-ссылок')
if not permissions.can_restrict_members:
missing_permissions.append('Управление пользователями (видеть вступления)')
if missing_permissions:
log.warning(
f'Bot lacks required permissions in channel {input.telegram_id}: {missing_permissions}. '
f'Attempted by user {input.user_telegram_id}'
)
permissions_text = '\n'.join(f'{p}' for p in missing_permissions)
await self.telegram_writer.send_message(
f'⚠️ Бот был добавлен в канал "{input.title}", но не имеет необходимых прав.\n\n'
f'Отсутствующие права:\n{permissions_text}\n\n'
'Пожалуйста, предоставьте эти права боту в настройках канала.',
chat_id=input.user_telegram_id,
)
raise domain.ChannelNoAdminRights()
async with self.database.transaction():
user = await self.database.get_user(telegram_id=input.user_telegram_id)
if not user:
log.warning(f'User {input.user_telegram_id} not found when trying to connect channel {input.telegram_id}')
await self.telegram_writer.send_message(
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
'Вы должны сначала авторизоваться в веб-панели перед подключением каналов.',
chat_id=input.user_telegram_id,
)
raise domain.UserNotFound()
channel = domain.TargetChannel(
telegram_id=input.telegram_id,
title=input.title,
username=input.username,
user_id=user.id,
is_active=True,
)
upserted_channel = await self.database.upsert_target_channel(channel)
log.info(f'Channel {input.telegram_id} connected/updated successfully by user {input.user_telegram_id}')
await self.telegram_writer.send_message(
f'✅ Канал "{input.title}" успешно подключен!',
chat_id=input.user_telegram_id,
)
return dto.ConnectTargetChanOutput(
id=upserted_channel.id,
telegram_id=upserted_channel.telegram_id,
title=upserted_channel.title,
username=upserted_channel.username,
is_active=upserted_channel.is_active,
)

View File

@@ -0,0 +1,21 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def disconnect_target_chan(self: 'Usecase', input: dto.DisconnectTargetChanInput) -> None:
async with self.database.transaction():
channel = await self.database.get_target_channel(channel_id=input.channel_id, user_id=input.user_id)
if not channel:
raise domain.TargetChannelNotFound(input.channel_id)
channel.is_active = False
await self.database.update_target_channel(channel)
log.info(f'Target channel {input.channel_id} deactivated')

View File

@@ -0,0 +1,22 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.DisconnectTargetChanByTgIdInput) -> None:
async with self.database.transaction():
channel = await self.database.get_target_channel(input.user_id, telegram_id=input.telegram_id)
if not channel:
log.warning(f'Target channel {input.telegram_id} not found')
raise domain.TargetChannelNotFound()
channel.is_active = False
await self.database.update_target_channel(channel)
log.info(f'Target channel {input.telegram_id} deactivated')

View File

@@ -0,0 +1,24 @@
from typing import TYPE_CHECKING
from src import dto
if TYPE_CHECKING:
from .. import Usecase
async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput:
async with self.database.transaction():
channels = await self.database.get_user_target_channels(input.user_id)
return dto.GetUserTargetChansOutput(
target_channels=[
dto.ConnectTargetChanOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
is_active=channel.is_active,
)
for channel in channels
]
)

View File

@@ -0,0 +1,49 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTargetChanPermissionsInput) -> None:
missing_permissions = []
if not input.permissions.can_invite_users:
missing_permissions.append('Создание инвайт-ссылок')
if not input.permissions.can_restrict_members:
missing_permissions.append('Управление пользователями')
async with self.database.transaction():
channel = await self.database.get_target_channel(user_id, telegram_id=input.telegram_id)
if not channel:
log.warning(f'Target channel {input.telegram_id} not found when permissions changed')
raise domain.TargetChannelNotFound()
if not missing_permissions:
if not channel.is_active:
channel.is_active = True
await self.database.update_target_channel(channel)
await self.telegram_writer.send_message(
f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
chat_id=input.user_telegram_id,
)
log.info(f'Target channel {input.telegram_id} reactivated - all permissions granted')
return
if channel.is_active:
channel.is_active = False
await self.database.update_target_channel(channel)
missed_permissions = '\n'.join(f'{p}' for p in missing_permissions)
await self.telegram_writer.send_message(
f'⚠️ Канал "{input.chat_title}" был деактивирован.\
\n\nБоту убрали необходимые права:\n{missed_permissions}',
chat_id=input.user_telegram_id,
)
log.warning(
f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}'
)

View File

@@ -0,0 +1,36 @@
import datetime
import secrets
import typing
from src import domain, dto
from src.config import settings
if typing.TYPE_CHECKING:
from . import Usecase
async def telegram_login(self: 'Usecase', input: dto.TelegramLoginInput) -> None:
async with self.database.transaction():
user: domain.User | None = await self.database.get_user(telegram_id=input.telegram_id)
if not user:
user = domain.User(
telegram_id=input.telegram_id,
username=input.username,
)
user = await self.database.create_user(user)
token = secrets.token_urlsafe(32)
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=10)
await self.database.create_login_token(user.id, token, expires_at)
login_url = f'{settings.app.URL}/auth/complete?token={token}'
username_display = f'@{user.username}' if user.username else 'пользователь'
await self.telegram_writer.send_message_with_button(
f'Привет, {username_display}!\n\nНажми кнопку ниже для входа на сайт.',
chat_id=input.chat_id,
button_text='Войти на сайт',
button_url=login_url,
)

View File

@@ -1,25 +0,0 @@
import uuid
from dataclasses import dataclass
from typing import Protocol
from src import domain
from .get_user import get_user
from .notify_user import notify_user
class Database(Protocol):
async def get_user(self, user_id: uuid.UUID) -> domain.User | None: ...
class TelegramWriter(Protocol):
async def send_message(self, chat_id: int, text: str) -> None: ...
@dataclass
class Usecase:
database: Database
telegram_writer: TelegramWriter
get_user = get_user
notify_user = notify_user

View File

@@ -0,0 +1,38 @@
import datetime
import typing
from src import domain, dto
if typing.TYPE_CHECKING:
from . import Usecase
async def validate_login_token(self: 'Usecase', input: dto.ValidateLoginTokenInput) -> dto.ValidateLoginTokenOutput:
async with self.database.transaction():
login_token = await self.database.get_login_token(input.token)
if not login_token:
raise domain.LoginTokenNotFound()
if login_token.used_at:
raise domain.LoginTokenAlreadyUsed()
if login_token.expires_at < datetime.datetime.now(datetime.UTC):
raise domain.LoginTokenExpired()
user = await self.database.get_user(user_id=login_token.user_id)
if not user:
raise domain.UserNotFound(login_token.user_id)
await self.database.mark_token_as_used(input.token)
# Генерируем JWT токен с информацией о пользователе
access_token = self.jwt_encoder.encode_access_token(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
)
return dto.ValidateLoginTokenOutput(
access_token=access_token,
)