feat: проверка существования канала при привязке
This commit is contained in:
63
src/adapter/parser.py
Normal file
63
src/adapter/parser.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -15,9 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class PlacementStatus(str, enum.Enum):
|
||||
PENDING = 'pending' # Ожидается публикация поста
|
||||
ACTIVE = 'active' # Пост найден и отслеживается
|
||||
DELETED = 'deleted' # Пост удален из канала
|
||||
ARCHIVED = 'archived' # Вручную архивировано
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ class GetCreativeInput(pydantic.BaseModel):
|
||||
class CreateCreativeInput(pydantic.BaseModel):
|
||||
name: str
|
||||
text: str
|
||||
project_id: uuid.UUID
|
||||
|
||||
|
||||
class UpdateCreativeInput(pydantic.BaseModel):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user