feat: creatives and external channels
This commit is contained in:
19
src/controller/http/__init__.py
Normal file
19
src/controller/http/__init__.py
Normal 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)
|
||||
17
src/controller/http/auth.py
Normal file
17
src/controller/http/auth.py
Normal 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,
|
||||
)
|
||||
)
|
||||
85
src/controller/http/creatives.py
Normal file
85
src/controller/http/creatives.py
Normal 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)
|
||||
90
src/controller/http/external_channels.py
Normal file
90
src/controller/http/external_channels.py
Normal 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)
|
||||
34
src/controller/http/target_channels.py
Normal file
34
src/controller/http/target_channels.py
Normal 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)
|
||||
@@ -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)
|
||||
@@ -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)}')
|
||||
6
src/controller/telegram_callback/__init__.py
Normal file
6
src/controller/telegram_callback/__init__.py
Normal 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
|
||||
83
src/controller/telegram_callback/my_chat_member.py
Normal file
83
src/controller/telegram_callback/my_chat_member.py
Normal 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)
|
||||
30
src/controller/telegram_callback/start_with_login.py
Normal file
30
src/controller/telegram_callback/start_with_login.py
Normal 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)
|
||||
Reference in New Issue
Block a user