feat: purchases added

This commit is contained in:
Artem Tsyrulnikov
2025-11-10 16:43:20 +03:00
parent f8edd81395
commit 2549242b06
20 changed files with 1429 additions and 8 deletions

View File

@@ -222,3 +222,80 @@ class Postgres(DatabaseBase):
stmt = delete(domain.Creative).where(domain.Creative.id == creative_id)
await self.session.execute(stmt)
await self.session.flush()
async def get_purchase(self, user_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
stmt = (
select(domain.Purchase)
.options(
selectinload(domain.Purchase.target_channel),
selectinload(domain.Purchase.external_channel),
selectinload(domain.Purchase.creative),
)
.where(domain.Purchase.id == purchase_id, domain.Purchase.user_id == user_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def create_purchase(self, purchase: domain.Purchase) -> domain.Purchase:
self.session.add(purchase)
await self.session.flush()
await self.session.refresh(
purchase,
['target_channel', 'external_channel', 'creative'],
)
return purchase
async def update_purchase(self, purchase: domain.Purchase) -> domain.Purchase:
await self.session.flush()
await self.session.refresh(
purchase,
['target_channel', 'external_channel', 'creative'],
)
return purchase
async def get_user_purchases(
self,
user_id: uuid.UUID,
*,
target_channel_id: uuid.UUID | None = None,
external_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> list[domain.Purchase]:
stmt = (
select(domain.Purchase)
.options(
selectinload(domain.Purchase.target_channel),
selectinload(domain.Purchase.external_channel),
selectinload(domain.Purchase.creative),
)
.where(domain.Purchase.user_id == user_id)
)
if target_channel_id:
stmt = stmt.where(domain.Purchase.target_channel_id == target_channel_id)
if external_channel_id:
stmt = stmt.where(domain.Purchase.external_channel_id == external_channel_id)
if creative_id:
stmt = stmt.where(domain.Purchase.creative_id == creative_id)
if not include_archived:
stmt = stmt.where(domain.Purchase.status == domain.PurchaseStatus.ACTIVE)
stmt = stmt.order_by(domain.Purchase.placement_date.desc())
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def delete_purchase(self, purchase_id: uuid.UUID) -> None:
stmt = delete(domain.Purchase).where(domain.Purchase.id == purchase_id)
await self.session.execute(stmt)
await self.session.flush()
async def check_creative_in_use(self, creative_id: uuid.UUID) -> bool:
stmt = select(domain.Purchase).where(
domain.Purchase.creative_id == creative_id,
domain.Purchase.status == domain.PurchaseStatus.ACTIVE,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none() is not None

View File

@@ -19,3 +19,10 @@ class Telegram(TelegramBase):
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()
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
invite_link = await self.bot.create_chat_invite_link(
chat_id=chat_id,
creates_join_request=requires_approval,
)
return invite_link.invite_link

View File

@@ -3,6 +3,7 @@ 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.purchases import purchases_router
from src.controller.http.target_channels import target_channels_router
api_router = APIRouter()
@@ -15,5 +16,6 @@ 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_v1_router.include_router(purchases_router)
api_router.include_router(api_v1_router)

View File

@@ -0,0 +1,87 @@
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
purchases_router = APIRouter(prefix='/purchases', tags=['purchases'])
@purchases_router.get('')
async def list_purchases(
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
target_channel_id: uuid.UUID | None = None,
external_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> dto.GetPurchasesOutput:
input = dto.GetPurchasesInput(
user_id=current_user.user_id,
target_channel_id=target_channel_id,
external_channel_id=external_channel_id,
creative_id=creative_id,
include_archived=include_archived,
)
return await dependencies.get_usecase().get_purchases(input=input)
@purchases_router.get('/{purchase_id}')
async def get_purchase(
purchase_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.PurchaseOutput:
input = dto.GetPurchaseInput(
purchase_id=purchase_id,
user_id=current_user.user_id,
)
return await dependencies.get_usecase().get_purchase(input=input)
@purchases_router.post('')
async def create_purchase(
request: dto.CreatePurchaseInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.PurchaseOutput:
return await dependencies.get_usecase().create_purchase(request, current_user.user_id)
@purchases_router.patch('/{purchase_id}')
async def update_purchase(
purchase_id: uuid.UUID,
request: dto.UpdatePurchaseInput,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.PurchaseOutput:
return await dependencies.get_usecase().update_purchase(
purchase_id=purchase_id,
input=request,
user_id=current_user.user_id,
)
@purchases_router.post('/{purchase_id}/archive')
async def archive_purchase(
purchase_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> dto.PurchaseOutput:
return await dependencies.get_usecase().archive_purchase(
purchase_id=purchase_id,
user_id=current_user.user_id,
)
@purchases_router.delete('/{purchase_id}')
async def delete_purchase(
purchase_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(dependencies.get_current_user)],
) -> None:
input = dto.DeletePurchaseInput(
purchase_id=purchase_id,
user_id=current_user.user_id,
)
await dependencies.get_usecase().delete_purchase(input)

View File

@@ -4,8 +4,11 @@ __all__ = (
'TargetChannel',
'ExternalChannel',
'Creative',
'Purchase',
'TargetChannelStatus',
'CreativeStatus',
'PurchaseStatus',
'InviteLinkType',
'LoginToken',
'UserNotFound',
'LoginTokenNotFound',
@@ -18,6 +21,7 @@ __all__ = (
'ExternalChannelAlreadyExists',
'CreativeNotFound',
'CreativeInUse',
'PurchaseNotFound',
)
from .base import Base
@@ -32,10 +36,12 @@ from .error import (
LoginTokenAlreadyUsed,
LoginTokenExpired,
LoginTokenNotFound,
PurchaseNotFound,
TargetChannelNotFound,
UserNotFound,
)
from .external_channel import ExternalChannel
from .login_token import LoginToken
from .purchase import InviteLinkType, Purchase, PurchaseStatus
from .target_channel import TargetChannel, TargetChannelStatus
from .user import User

View File

@@ -58,3 +58,9 @@ def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
status.HTTP_400_BAD_REQUEST,
f'Creative {creative_id} is used in active purchases and cannot be deleted',
)
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
if purchase_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found')

52
src/domain/purchase.py Normal file
View File

@@ -0,0 +1,52 @@
import datetime
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 .creative import Creative
from .external_channel import ExternalChannel
from .target_channel import TargetChannel
class PurchaseStatus(StrEnum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class InviteLinkType(StrEnum):
PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом
class Purchase(domain.Base):
# Основные поля
target_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('targetchannels.id'), index=True)
external_channel_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('externalchannels.id'), index=True)
creative_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('creatives.id'), index=True)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey('users.id'), index=True)
# Данные закупа
placement_date: Mapped[datetime.datetime] # дата размещения (планируемая или фактическая)
cost: Mapped[float | None] # стоимость закупа
comment: Mapped[str | None] # комментарий
ad_post_url: Mapped[str | None] # ссылка на рекламное сообщение во внешнем канале
invite_link_type: Mapped[InviteLinkType] # тип пригласительной ссылки
invite_link: Mapped[str] # уникальная пригласительная ссылка
# Статус
status: Mapped[PurchaseStatus] = mapped_column(default=PurchaseStatus.ACTIVE)
# Метрики (будут обновляться из Telegram API)
subscriptions_count: Mapped[int] = mapped_column(default=0, server_default='0')
views_count: Mapped[int | None] # просмотры рекламного поста (если доступны)
# Relationships
target_channel: Mapped['TargetChannel'] = relationship()
external_channel: Mapped['ExternalChannel'] = relationship()
creative: Mapped['Creative'] = relationship()

View File

@@ -26,6 +26,14 @@ __all__ = (
'UpdateCreativeInput',
'ArchiveCreativeInput',
'DeleteCreativeInput',
'PurchaseOutput',
'GetPurchasesInput',
'GetPurchasesOutput',
'GetPurchaseInput',
'CreatePurchaseInput',
'UpdatePurchaseInput',
'ArchivePurchaseInput',
'DeletePurchaseInput',
)
from .creative import (
@@ -48,6 +56,16 @@ from .external_channel import (
ImportExternalChannelsOutput,
UpdateExternalChannelLinksInput,
)
from .purchase import (
ArchivePurchaseInput,
CreatePurchaseInput,
DeletePurchaseInput,
GetPurchaseInput,
GetPurchasesInput,
GetPurchasesOutput,
PurchaseOutput,
UpdatePurchaseInput,
)
from .target_channel import (
ChannelBotPermissions,
ConnectTargetChanInput,

71
src/dto/purchase.py Normal file
View File

@@ -0,0 +1,71 @@
import datetime
import uuid
import pydantic
from src import domain
class PurchaseOutput(pydantic.BaseModel):
id: uuid.UUID
target_channel_id: uuid.UUID
target_channel_title: str
external_channel_id: uuid.UUID
external_channel_title: str
creative_id: uuid.UUID
creative_name: str
placement_date: datetime.datetime
cost: float | None
comment: str | None
ad_post_url: str | None
invite_link_type: domain.InviteLinkType
invite_link: str
status: domain.PurchaseStatus
subscriptions_count: int
views_count: int | None
created_at: datetime.datetime
class GetPurchasesInput(pydantic.BaseModel):
user_id: uuid.UUID
target_channel_id: uuid.UUID | None = None
external_channel_id: uuid.UUID | None = None
creative_id: uuid.UUID | None = None
include_archived: bool = False
class GetPurchasesOutput(pydantic.BaseModel):
purchases: list[PurchaseOutput]
class GetPurchaseInput(pydantic.BaseModel):
purchase_id: uuid.UUID
user_id: uuid.UUID
class CreatePurchaseInput(pydantic.BaseModel):
target_channel_id: uuid.UUID
external_channel_id: uuid.UUID
creative_id: uuid.UUID
placement_date: datetime.datetime
cost: float | None = None
comment: str | None = None
ad_post_url: str | None = None
invite_link_type: domain.InviteLinkType = domain.InviteLinkType.PUBLIC
class UpdatePurchaseInput(pydantic.BaseModel):
placement_date: datetime.datetime | None = None
cost: float | None = None
comment: str | None = None
ad_post_url: str | None = None
class ArchivePurchaseInput(pydantic.BaseModel):
purchase_id: uuid.UUID
user_id: uuid.UUID
class DeletePurchaseInput(pydantic.BaseModel):
purchase_id: uuid.UUID
user_id: uuid.UUID

View File

@@ -16,6 +16,12 @@ 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 .purchase.archive_purchase import archive_purchase
from .purchase.create_purchase import create_purchase
from .purchase.delete_purchase import delete_purchase
from .purchase.get_purchase import get_purchase
from .purchase.get_purchases import get_purchases
from .purchase.update_purchase import update_purchase
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
@@ -84,6 +90,26 @@ class Database(typing.Protocol):
async def delete_creative(self, creative_id: UUID) -> None: ...
async def get_purchase(self, user_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
async def create_purchase(self, purchase: domain.Purchase) -> domain.Purchase: ...
async def update_purchase(self, purchase: domain.Purchase) -> domain.Purchase: ...
async def get_user_purchases(
self,
user_id: UUID,
*,
target_channel_id: UUID | None = None,
external_channel_id: UUID | None = None,
creative_id: UUID | None = None,
include_archived: bool = False,
) -> list[domain.Purchase]: ...
async def delete_purchase(self, purchase_id: UUID) -> None: ...
async def check_creative_in_use(self, creative_id: UUID) -> bool: ...
class TelegramWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ...
@@ -92,6 +118,8 @@ class TelegramWriter(typing.Protocol):
async def get_chat_member(self, chat_id: int, user_id: int) -> dict[str, typing.Any]: ...
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...
class JWTEncoder(typing.Protocol):
def encode_access_token(self, user_id: UUID, telegram_id: int, username: str | None = None) -> str: ...
@@ -121,3 +149,9 @@ class Usecase:
update_creative = update_creative
archive_creative = archive_creative
delete_creative = delete_creative
get_purchases = get_purchases
get_purchase = get_purchase
create_purchase = create_purchase
update_purchase = update_purchase
archive_purchase = archive_purchase
delete_purchase = delete_purchase

View File

@@ -0,0 +1,41 @@
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_purchase(self: 'Usecase', purchase_id: uuid.UUID, user_id: uuid.UUID) -> dto.PurchaseOutput:
async with self.database.transaction():
purchase = await self.database.get_purchase(user_id, purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', purchase_id, user_id)
raise domain.PurchaseNotFound(purchase_id)
purchase.status = domain.PurchaseStatus.ARCHIVED
updated = await self.database.update_purchase(purchase)
return dto.PurchaseOutput(
id=updated.id,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
external_channel_id=updated.external_channel_id,
external_channel_title=updated.external_channel.title,
creative_id=updated.creative_id,
creative_name=updated.creative.name,
placement_date=updated.placement_date,
cost=updated.cost,
comment=updated.comment,
ad_post_url=updated.ad_post_url,
invite_link_type=updated.invite_link_type,
invite_link=updated.invite_link,
status=updated.status,
subscriptions_count=updated.subscriptions_count,
views_count=updated.views_count,
created_at=updated.created_at,
)

View File

@@ -0,0 +1,83 @@
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_purchase(self: 'Usecase', input: dto.CreatePurchaseInput, user_id: uuid.UUID) -> dto.PurchaseOutput:
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 purchase for unavailable target %s', user_id, input.target_channel_id
)
raise domain.TargetChannelNotFound(input.target_channel_id)
# Проверяем существование внешнего канала
external_channel = await self.database.get_external_channel(user_id, channel_id=input.external_channel_id)
if not external_channel:
log.warning(
'User %s attempted to create purchase for unavailable external %s', user_id, input.external_channel_id
)
raise domain.ExternalChannelNotFound(input.external_channel_id)
# Проверяем существование креатива
creative = await self.database.get_creative(user_id, input.creative_id)
if not creative:
log.warning('User %s attempted to create purchase for unavailable creative %s', user_id, input.creative_id)
raise domain.CreativeNotFound(input.creative_id)
# Создаём уникальную пригласительную ссылку через Telegram Bot API
requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL
invite_link = await self.telegram_writer.create_chat_invite_link(
chat_id=target_channel.telegram_id,
requires_approval=requires_approval,
)
# Создаём закуп
purchase = domain.Purchase(
target_channel_id=input.target_channel_id,
external_channel_id=input.external_channel_id,
creative_id=input.creative_id,
user_id=user_id,
placement_date=input.placement_date,
cost=input.cost,
comment=input.comment,
ad_post_url=input.ad_post_url,
invite_link_type=input.invite_link_type,
invite_link=invite_link,
status=domain.PurchaseStatus.ACTIVE,
)
created = await self.database.create_purchase(purchase)
# Увеличиваем счётчик закупов у креатива
creative.purchases_count += 1
await self.database.update_creative(creative)
return dto.PurchaseOutput(
id=created.id,
target_channel_id=created.target_channel_id,
target_channel_title=created.target_channel.title,
external_channel_id=created.external_channel_id,
external_channel_title=created.external_channel.title,
creative_id=created.creative_id,
creative_name=created.creative.name,
placement_date=created.placement_date,
cost=created.cost,
comment=created.comment,
ad_post_url=created.ad_post_url,
invite_link_type=created.invite_link_type,
invite_link=created.invite_link,
status=created.status,
subscriptions_count=created.subscriptions_count,
views_count=created.views_count,
created_at=created.created_at,
)

View File

@@ -0,0 +1,26 @@
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_purchase(self: 'Usecase', input: dto.DeletePurchaseInput) -> None:
async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id)
# Уменьшаем счётчик закупов у креатива, если закуп был активным
if purchase.status == domain.PurchaseStatus.ACTIVE:
creative = await self.database.get_creative(input.user_id, purchase.creative_id)
if creative and creative.purchases_count > 0:
creative.purchases_count -= 1
await self.database.update_creative(creative)
await self.database.delete_purchase(input.purchase_id)

View File

@@ -0,0 +1,37 @@
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_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput:
async with self.database.transaction():
purchase = await self.database.get_purchase(input.user_id, input.purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', input.purchase_id, input.user_id)
raise domain.PurchaseNotFound(input.purchase_id)
return dto.PurchaseOutput(
id=purchase.id,
target_channel_id=purchase.target_channel_id,
target_channel_title=purchase.target_channel.title,
external_channel_id=purchase.external_channel_id,
external_channel_title=purchase.external_channel.title,
creative_id=purchase.creative_id,
creative_name=purchase.creative.name,
placement_date=purchase.placement_date,
cost=purchase.cost,
comment=purchase.comment,
ad_post_url=purchase.ad_post_url,
invite_link_type=purchase.invite_link_type,
invite_link=purchase.invite_link,
status=purchase.status,
subscriptions_count=purchase.subscriptions_count,
views_count=purchase.views_count,
created_at=purchase.created_at,
)

View File

@@ -0,0 +1,42 @@
from typing import TYPE_CHECKING
from src import dto
if TYPE_CHECKING:
from .. import Usecase
async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> dto.GetPurchasesOutput:
async with self.database.transaction():
purchases = await self.database.get_user_purchases(
input.user_id,
target_channel_id=input.target_channel_id,
external_channel_id=input.external_channel_id,
creative_id=input.creative_id,
include_archived=input.include_archived,
)
return dto.GetPurchasesOutput(
purchases=[
dto.PurchaseOutput(
id=purchase.id,
target_channel_id=purchase.target_channel_id,
target_channel_title=purchase.target_channel.title,
external_channel_id=purchase.external_channel_id,
external_channel_title=purchase.external_channel.title,
creative_id=purchase.creative_id,
creative_name=purchase.creative.name,
placement_date=purchase.placement_date,
cost=purchase.cost,
comment=purchase.comment,
ad_post_url=purchase.ad_post_url,
invite_link_type=purchase.invite_link_type,
invite_link=purchase.invite_link,
status=purchase.status,
subscriptions_count=purchase.subscriptions_count,
views_count=purchase.views_count,
created_at=purchase.created_at,
)
for purchase in purchases
]
)

View File

@@ -0,0 +1,52 @@
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_purchase(
self: 'Usecase', purchase_id: uuid.UUID, input: dto.UpdatePurchaseInput, user_id: uuid.UUID
) -> dto.PurchaseOutput:
async with self.database.transaction():
purchase = await self.database.get_purchase(user_id, purchase_id)
if not purchase:
log.warning('Purchase %s not found for user %s', purchase_id, user_id)
raise domain.PurchaseNotFound(purchase_id)
# Обновляем только те поля, которые были переданы
if input.placement_date is not None:
purchase.placement_date = input.placement_date
if input.cost is not None:
purchase.cost = input.cost
if input.comment is not None:
purchase.comment = input.comment
if input.ad_post_url is not None:
purchase.ad_post_url = input.ad_post_url
updated = await self.database.update_purchase(purchase)
return dto.PurchaseOutput(
id=updated.id,
target_channel_id=updated.target_channel_id,
target_channel_title=updated.target_channel.title,
external_channel_id=updated.external_channel_id,
external_channel_title=updated.external_channel.title,
creative_id=updated.creative_id,
creative_name=updated.creative.name,
placement_date=updated.placement_date,
cost=updated.cost,
comment=updated.comment,
ad_post_url=updated.ad_post_url,
invite_link_type=updated.invite_link_type,
invite_link=updated.invite_link,
status=updated.status,
subscriptions_count=updated.subscriptions_count,
views_count=updated.views_count,
created_at=updated.created_at,
)