feat: проверка существования канала при привязке

This commit is contained in:
Artem Tsyrulnikov
2025-12-15 22:09:23 +03:00
parent 68edf11576
commit 83a81f46f7
22 changed files with 270 additions and 125 deletions

63
src/adapter/parser.py Normal file
View File

@@ -0,0 +1,63 @@
import logging
from typing import Any
import httpx
from pydantic import BaseModel
log = logging.getLogger(__name__)
class FetchChannelResponse(BaseModel):
id: str
telegram_id: int
username: str
title: str
access_hash: int
pts: int
class ParserClient:
def __init__(self, base_url: str, timeout: float = 5.0):
self.base_url = base_url.rstrip('/')
self.timeout = timeout
async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None:
"""
Проверяет существование канала в Telegram и получает его метаданные.
Returns:
FetchChannelResponse если канал найден
None если канал не найден (404)
Raises:
httpx.HTTPError для других ошибок (таймаут, network error, 5xx)
"""
# Убираем @ если пользователь его указал
username = username.lstrip('@')
url = f'{self.base_url}/fetch-telegram-channel'
params = {'username': username}
async with httpx.AsyncClient(timeout=self.timeout) as client:
try:
response = await client.get(url, params=params)
if response.status_code == 404:
log.info('Channel @%s not found in Telegram', username)
return None
response.raise_for_status()
data: dict[str, Any] = response.json()
return FetchChannelResponse.model_validate(data)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
log.error('Parser HTTP error for @%s: %s', username, e)
raise
except httpx.TimeoutException:
log.error('Parser timeout for @%s', username)
raise
except Exception as e:
log.error('Parser unexpected error for @%s: %s', username, e)
raise

View File

@@ -12,6 +12,10 @@ class AppConfig(BaseModel):
URL: str
class ParserConfig(BaseModel):
URL: str
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', case_sensitive=False, env_nested_delimiter='__')
@@ -20,6 +24,7 @@ class Settings(BaseSettings):
logger: LoggerConfig
telegram: TelegramConfig
jwt: JWTConfig
parser: ParserConfig
settings: Settings = load_settings(Settings)

View File

@@ -1,7 +1,7 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi import Depends, Query
from fastapi.routing import APIRouter
from src import deps, dto
@@ -44,11 +44,12 @@ async def get_creative(
@creatives_router.post('')
async def create_creative(
request: dto.CreateCreativeInput,
workspace_id: uuid.UUID,
request: dto.CreateCreativeInput,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID = Query(),
) -> dto.CreativeOutput:
return await deps.get_usecase().create_creative(request, current_user.user_id, workspace_id)
return await deps.get_usecase().create_creative(request, project_id, current_user.user_id, workspace_id)
@creatives_router.patch('/{creative_id}')

View File

@@ -49,6 +49,7 @@ __all__ = (
'ChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
'TelegramChannelNotFound',
'CreativeNotFound',
'CreativeInUse',
'PlacementNotFound',
@@ -70,6 +71,7 @@ from .error import (
ProjectNotFound,
PurchasePlanChannelNotFound,
PurchasePlanNotFound,
TelegramChannelNotFound,
UserByUsernameNotFound,
UserNotFound,
WorkspaceAccessDenied,

View File

@@ -4,9 +4,9 @@ from .base import TimestampedModel
class Channel(TimestampedModel):
telegram_id = fields.BigIntField(null=True, unique=True, index=True)
title = fields.CharField(null=True, max_length=255)
username = fields.CharField(null=True, max_length=255, index=True)
telegram_id = fields.BigIntField(unique=True, index=True)
title = fields.CharField(max_length=255)
username = fields.CharField(max_length=255, unique=True, index=True)
access_hash = fields.BigIntField(null=True)
pts = fields.IntField(null=True)

View File

@@ -31,6 +31,12 @@ def ChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, f'Channel {channel_id} not found')
def TelegramChannelNotFound(username: str) -> HTTPException:
return HTTPException(
status.HTTP_404_NOT_FOUND, f'Telegram channel @{username} not found or is not a public channel'
)
def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
if project_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Project not found')

View File

@@ -15,9 +15,7 @@ if TYPE_CHECKING:
class PlacementStatus(str, enum.Enum):
PENDING = 'pending' # Ожидается публикация поста
ACTIVE = 'active' # Пост найден и отслеживается
DELETED = 'deleted' # Пост удален из канала
ARCHIVED = 'archived' # Вручную архивировано

View File

@@ -37,7 +37,6 @@ class GetCreativeInput(pydantic.BaseModel):
class CreateCreativeInput(pydantic.BaseModel):
name: str
text: str
project_id: uuid.UUID
class UpdateCreativeInput(pydantic.BaseModel):

View File

@@ -22,9 +22,6 @@ class PlacementOutput(pydantic.BaseModel):
invite_link: str
status: domain.PlacementStatus
subscriptions_count: int
views_count: int | None
views_availability: domain.PostViewsAvailability
last_views_fetch_at: datetime.datetime | None
created_at: datetime.datetime

View File

@@ -26,7 +26,7 @@ class GetPurchasePlanChannelsOutput(pydantic.BaseModel):
class AttachChannelToPurchasePlanInput(pydantic.BaseModel):
username: str
username: str = pydantic.Field(pattern=r'^[^@]')
status: PurchasePlanChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None

View File

@@ -8,6 +8,7 @@ from shared import logger
from shared.worker_base import WorkerConfig
from src import deps
from src.adapter.jwt import JWT
from src.adapter.parser import ParserClient
from src.adapter.postgres import Postgres
from src.adapter.telegram_bot import TelegramBot
from src.config import settings
@@ -36,11 +37,13 @@ logger.init(settings.logger)
postgres = Postgres(settings.db)
telegram = TelegramBot(settings.telegram, telegram_callback_router)
jwt_encoder = JWT(settings.jwt)
parser_client = ParserClient(base_url=settings.parser.URL)
usecase = Usecase(
database=postgres,
telegram_bot=telegram,
jwt_encoder=jwt_encoder,
parser_client=parser_client,
)
deps.set_usecase(usecase)

View File

@@ -279,11 +279,22 @@ class JWTEncoder(typing.Protocol):
def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ...
class FetchChannelResponse(typing.Protocol):
telegram_id: int
username: str
title: str
class ParserClient(typing.Protocol):
async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ...
@dataclass
class Usecase:
database: Database
telegram_bot: TelegramBotWriter
jwt_encoder: JWTEncoder
parser_client: ParserClient
async def ensure_workspace_access(self, workspace_id: UUID, user_id: UUID) -> domain.Workspace:
workspace = await self.database.get_workspace_for_user(workspace_id, user_id)

View File

@@ -11,19 +11,19 @@ log = logging.getLogger(__name__)
async def create_creative(
self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID, workspace_id: uuid.UUID
self: 'Usecase', input: dto.CreateCreativeInput, project_id: uuid.UUID, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.CreativeOutput:
await self.ensure_workspace_permission(
workspace_id,
user_id,
domain.PermissionKey.PROJECTS_WRITE,
for_project_id=input.project_id,
for_project_id=project_id,
)
project = await self.database.get_project(workspace_id, project_id=input.project_id)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
log.warning('User %s attempted to create creative for unavailable project %s', user_id, input.project_id)
raise domain.ProjectNotFound(input.project_id)
log.warning('User %s attempted to create creative for unavailable project %s', user_id, project_id)
raise domain.ProjectNotFound(project_id)
creative = domain.Creative(
name=input.name,

View File

@@ -53,7 +53,7 @@ async def create_placement(
comment=input.comment,
invite_link_type=input.invite_link_type,
invite_link=invite_link,
status=domain.PlacementStatus.PENDING,
status=domain.PlacementStatus.ACTIVE,
)
async with self.database.transaction():
@@ -78,8 +78,5 @@ async def create_placement(
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
views_count=None,
views_availability=domain.PostViewsAvailability.UNKNOWN,
last_views_fetch_at=None,
created_at=placement.created_at,
)

View File

@@ -10,7 +10,7 @@ log = logging.getLogger(__name__)
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
log.info('Starting fetch_placement_post_cycle')
log.debug('Starting fetch_placement_post_cycle')
# Получаем все placement без привязанного поста
placements_without_post = (

View File

@@ -54,8 +54,5 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
views_count=views_count,
views_availability=views_availability,
last_views_fetch_at=last_views_fetch_at,
created_at=placement.created_at,
)

View File

@@ -28,25 +28,12 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
allowed_project_ids=allowed_project_ids,
)
# Batch fetch views data for all posts
post_ids = [p.post.id for p in placements if p.post]
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
# Build output DTOs
placement_outputs = []
for placement in placements:
# Compute post-related fields
ad_post_url = None
views_count = None
views_availability = domain.PostViewsAvailability.UNKNOWN
last_views_fetch_at = None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
views_data = views_map.get(placement.post.id)
if views_data:
views_count, last_views_fetch_at = views_data
views_availability = domain.PostViewsAvailability.AVAILABLE
placement_outputs.append(
dto.PlacementOutput(
@@ -65,9 +52,6 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
views_count=views_count,
views_availability=views_availability,
last_views_fetch_at=last_views_fetch_at,
created_at=placement.created_at,
)
)

View File

@@ -39,21 +39,11 @@ async def update_placement(
await self.database.update_placement(placement)
# Compute post-related fields
ad_post_url = None
views_count = None
views_availability = domain.PostViewsAvailability.UNKNOWN
last_views_fetch_at = None
if placement.post:
ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id)
# Get latest views data
views_data = await self.database.get_latest_views_data(placement.post.id)
if views_data:
views_count, last_views_fetch_at = views_data
views_availability = domain.PostViewsAvailability.AVAILABLE
return dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
@@ -70,8 +60,5 @@ async def update_placement(
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=placement.subscriptions_count,
views_count=views_count,
views_availability=views_availability,
last_views_fetch_at=last_views_fetch_at,
created_at=placement.created_at,
)

View File

@@ -27,16 +27,20 @@ async def attach_channel_to_purchase_plan(
plan = await self.ensure_active_purchase_plan(project)
# Поиск канала по username
channel = await self.database.get_channel(username=input.username)
if not channel:
# Создать канал с nullable telegram_id/title (заполнятся парсером позже)
channel = domain.Channel(username=input.username)
parser_response = await self.parser_client.fetch_telegram_channel(input.username)
if not parser_response:
raise domain.TelegramChannelNotFound(input.username)
channel = domain.Channel(
username=input.username,
telegram_id=parser_response.telegram_id,
title=parser_response.title,
)
await self.database.create_channel(channel)
log.info('Created channel with username @%s (pending parser update)', input.username)
else:
log.info('Found existing channel %s for username @%s', channel.id, input.username)
log.info('Created channel @%s with telegram_id=%s', input.username, parser_response.telegram_id)
plan_channel = await self.database.add_channel_to_purchase_plan(
plan.id,