change domen

This commit is contained in:
Artem Tsyrulnikov
2026-01-02 15:51:54 +03:00
parent c05ac84b38
commit 0577d2a00e
63 changed files with 1941 additions and 2186 deletions

View File

@@ -257,37 +257,60 @@ class Postgres(DatabaseBase, Database):
return await query.order_by('created_at').all()
async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None:
return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE)
async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
return await domain.Purchase.get_or_none(
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
)
async def get_purchase_plan(self, workspace_id: uuid.UUID, plan_id: uuid.UUID) -> domain.PurchasePlan | None:
return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id)
async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id)
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None:
await plan.save()
async def get_purchase_plan_channels(self, plan_id: uuid.UUID) -> list[domain.PurchasePlanChannel]:
return await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id).prefetch_related('channel').all()
async def get_purchase_plan_channel(
self, plan_channel_id: uuid.UUID, plan_id: uuid.UUID
) -> domain.PurchasePlanChannel | None:
async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]:
return (
await domain.PurchasePlanChannel.filter(id=plan_channel_id, purchase_plan_id=plan_id)
await domain.Purchase.filter(workspace_id=workspace_id, project_id=project_id)
.prefetch_related('channels', 'channels__channel')
.all()
)
async def create_purchase(self, purchase: domain.Purchase) -> None:
await purchase.save()
async def get_purchase_channels(self, purchase_id: uuid.UUID) -> list[domain.PurchaseChannel]:
return await domain.PurchaseChannel.filter(purchase_id=purchase_id).prefetch_related('channel').all()
async def get_purchase_channel(
self, purchase_channel_id: uuid.UUID, purchase_id: uuid.UUID
) -> domain.PurchaseChannel | None:
return (
await domain.PurchaseChannel.filter(id=purchase_channel_id, purchase_id=purchase_id)
.prefetch_related('channel')
.first()
)
async def add_channel_to_purchase_plan(
async def get_purchase_channels_by_project(self, project_id: uuid.UUID) -> list[domain.PurchaseChannel]:
return (
await domain.PurchaseChannel.filter(
purchase__project_id=project_id, purchase__status=domain.PurchaseStatus.ACTIVE
)
.prefetch_related('channel')
.all()
)
async def add_channel_to_purchase(
self,
plan_id: uuid.UUID,
purchase_id: uuid.UUID,
channel_id: uuid.UUID,
invite_link: str,
invite_link_type: domain.purchase.InviteLinkType,
*,
status: domain.PurchasePlanChannelStatus | None = None,
status: domain.PurchaseChannelStatus | None = None,
planned_cost: float | None = None,
comment: str | None = None,
) -> domain.PurchasePlanChannel:
defaults: dict[str, object | None] = {}
) -> domain.PurchaseChannel:
defaults: dict[str, object | None] = {
'invite_link': invite_link,
'invite_link_type': invite_link_type,
}
if status is not None:
defaults['status'] = status
if planned_cost is not None:
@@ -295,24 +318,24 @@ class Postgres(DatabaseBase, Database):
if comment is not None:
defaults['comment'] = comment
plan_channel, created = await domain.PurchasePlanChannel.get_or_create(
purchase_plan_id=plan_id,
purchase_channel, created = await domain.PurchaseChannel.get_or_create(
purchase_id=purchase_id,
channel_id=channel_id,
defaults=defaults,
)
if not created and defaults:
for field, value in defaults.items():
setattr(plan_channel, field, value)
await plan_channel.save()
setattr(purchase_channel, field, value)
await purchase_channel.save()
return plan_channel
return purchase_channel
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None:
await plan_channel.save()
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None:
await purchase_channel.save()
async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None:
await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id, channel_id=channel_id).delete()
async def remove_channel_from_purchase(self, purchase_id: uuid.UUID, channel_id: uuid.UUID) -> None:
await domain.PurchaseChannel.filter(purchase_id=purchase_id, channel_id=channel_id).delete()
async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related(
@@ -485,27 +508,6 @@ class Postgres(DatabaseBase, Database):
return results
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
return await domain.TelegramState.get_or_none(telegram_id=telegram_id)
async def set_telegram_state(
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
) -> domain.TelegramState:
existing = await self.get_telegram_state(telegram_id)
if existing:
existing.state = state
existing.context = context
existing.updated_at = timezone.now()
await existing.save()
return existing
new_state = await domain.TelegramState.create(telegram_id=telegram_id, state=state, context=context)
return new_state
async def clear_telegram_state(self, telegram_id: int) -> None:
await domain.TelegramState.filter(telegram_id=telegram_id).delete()
# Count methods
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int:
return await domain.Placement.filter(creative_id=creative_id).count()

View File

@@ -7,7 +7,7 @@ from src.controller.http_v1.creatives import creatives_router
from src.controller.http_v1.internal import internal_router
from src.controller.http_v1.placements import placements_router
from src.controller.http_v1.projects import projects_router
from src.controller.http_v1.purchase_plans import purchase_plan_channels_router
from src.controller.http_v1.purchases import purchase_router
from src.controller.http_v1.views import views_router
from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router
from src.controller.http_v1.workspace_members import workspace_members_router
@@ -21,7 +21,7 @@ api_v1_router.include_router(auth_router)
api_v1_router.include_router(internal_router)
api_v1_router.include_router(channels_router)
api_v1_router.include_router(projects_router)
api_v1_router.include_router(purchase_plan_channels_router)
api_v1_router.include_router(purchase_router)
api_v1_router.include_router(creatives_router)
api_v1_router.include_router(placements_router)
api_v1_router.include_router(views_router)

View File

@@ -48,40 +48,4 @@ async def get_placement(
return await deps.get_usecase().get_placement(input=input)
@placements_router.post('')
async def create_placement(
request: dto.CreatePlacementInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput:
return await deps.get_usecase().create_placement(request, current_user.user_id, workspace_id)
@placements_router.patch('/{placement_id}')
async def update_placement(
placement_id: uuid.UUID,
request: dto.UpdatePlacementInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput:
return await deps.get_usecase().update_placement(
placement_id=placement_id,
input=request,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
@placements_router.delete('/{placement_id}')
async def delete_placement(
placement_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None:
input = dto.DeletePlacementInput(
placement_id=placement_id,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
await deps.get_usecase().delete_placement(input)
# Placement creation/updates are handled by worker; API exposes only read endpoints.

View File

@@ -12,9 +12,8 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[
@projects_router.get('')
async def get_workspace_projects(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
async def get_projects(
workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)]
) -> Page[dto.ProjectOutput]:
input = dto.GetWorkspaceProjectsInput(
user_id=current_user.user_id,
@@ -32,18 +31,9 @@ async def update_project_invite_link_type(
input: dto.UpdateProjectInviteLinkTypeInput,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ProjectOutput:
project = await deps.get_usecase().update_project_invite_link_type(
return await deps.get_usecase().update_project_invite_link_type(
workspace_id=workspace_id,
project_id=project_id,
invite_link_type=input.invite_link_type,
purchase_invite_type_default=input.purchase_invite_type_default,
user_id=current_user.user_id,
)
return dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
invite_link_type=project.invite_link_type,
)

View File

@@ -1,76 +0,0 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
purchase_plan_channels_router = APIRouter(
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase-plan/channels',
tags=['purchase plan'],
)
@purchase_plan_channels_router.get('')
async def get_purchase_plan_channels(
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> Page[dto.PurchasePlanChannelOutput]:
input = dto.GetPurchasePlanChannelsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
result = await deps.get_usecase().get_purchase_plan_channels(input=input)
return paginate(result) # type: ignore[no-any-return]
@purchase_plan_channels_router.post('')
async def attach_channel_to_purchase_plan(
request: dto.AttachChannelToPurchasePlanInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchasePlanChannelOutput:
return await deps.get_usecase().attach_channel_to_purchase_plan(
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
input=request,
)
@purchase_plan_channels_router.patch('/{channel_id}')
async def update_purchase_plan_channel(
channel_id: uuid.UUID,
request: dto.UpdatePurchasePlanChannelInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchasePlanChannelOutput:
return await deps.get_usecase().update_purchase_plan_channel(
channel_id=channel_id,
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
input=request,
)
@purchase_plan_channels_router.delete('/{channel_id}')
async def remove_purchase_plan_channel(
channel_id: uuid.UUID,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None:
await deps.get_usecase().remove_purchase_plan_channel(
channel_id=channel_id,
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
)

View File

@@ -0,0 +1,60 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
purchase_router = APIRouter(
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase',
tags=['purchase'],
)
@purchase_router.post('')
async def create_purchase(
request: dto.CreatePurchaseInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchaseOutput:
return await deps.get_usecase().create_purchase(
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
input=request,
)
@purchase_router.get('')
async def get_purchases(
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> Page[dto.PurchaseOutput]:
input = dto.GetPurchasesInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
result = await deps.get_usecase().get_purchases(input=input)
return paginate(result) # type: ignore[no-any-return]
@purchase_router.get('/{purchase_id}')
async def get_purchase(
purchase_id: uuid.UUID,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchaseOutput:
input = dto.GetPurchaseInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
purchase_id=purchase_id,
)
return await deps.get_usecase().get_purchase(input=input)

View File

@@ -15,18 +15,16 @@ __all__ = (
'Channel',
'Project',
'ProjectStatus',
'PurchasePlan',
'PurchasePlanChannel',
'PurchasePlanStatus',
'PurchasePlanChannelStatus',
'Purchase',
'PurchaseChannel',
'PurchaseStatus',
'PurchaseChannelStatus',
'Creative',
'replace_invite_link_with_tag',
'Placement',
'Post',
'Subscription',
'PostViewsHistory',
'TelegramState',
'TelegramStateEnum',
'ChannelNotFound',
'ProjectNotFound',
'CreativeStatus',
@@ -45,8 +43,8 @@ __all__ = (
'LoginTokenExpired',
'LoginTokenAlreadyUsed',
'ProjectNotFound',
'PurchasePlanNotFound',
'PurchasePlanChannelNotFound',
'PurchaseNotFound',
'PurchaseChannelNotFound',
'ChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
@@ -74,8 +72,8 @@ from .error import (
LoginTokenNotFound,
PlacementNotFound,
ProjectNotFound,
PurchasePlanChannelNotFound,
PurchasePlanNotFound,
PurchaseChannelNotFound,
PurchaseNotFound,
TelegramChannelNotFound,
UserByUsernameNotFound,
UserNotFound,
@@ -89,15 +87,15 @@ from .login_token import LoginToken
from .placement import Placement, PlacementStatus, PostViewsAvailability
from .post import Post
from .post_views_history import PostViewsHistory
from .project import InviteLinkType, Project, ProjectStatus
from .purchase_plan import (
PurchasePlan,
PurchasePlanChannel,
PurchasePlanChannelStatus,
PurchasePlanStatus,
from .project import Project, ProjectStatus
from .purchase import (
InviteLinkType,
Purchase,
PurchaseChannel,
PurchaseChannelStatus,
PurchaseStatus,
)
from .subscription import Subscription, SubscriptionStatus
from .telegram_state import TelegramState, TelegramStateEnum
from .user import User
from .workspace import (
PermissionKey,

View File

@@ -43,16 +43,16 @@ def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException:
if plan_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found')
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')
def PurchasePlanChannelNotFound(plan_channel_id: uuid.UUID | None = None) -> HTTPException:
if plan_channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found')
def PurchaseChannelNotFound(purchase_channel_id: uuid.UUID | None = None) -> HTTPException:
if purchase_channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:

View File

@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from .creative import Creative
from .post import Post
from .project import Project
from .purchase import Purchase, PurchaseChannel
from .workspace import Workspace
@@ -27,35 +28,44 @@ class PostViewsAvailability(str, enum.Enum):
class Placement(TimestampedModel):
placement_date = fields.DatetimeField()
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512)
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
)
placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
)
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
)
purchase: fields.ForeignKeyRelation['Purchase'] | None = fields.ForeignKeyField(
'models.Purchase', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
)
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] | None = fields.ForeignKeyField(
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
)
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
)
if TYPE_CHECKING:
project_id: UUID
placement_channel_id: UUID
creative_id: UUID
workspace_id: UUID
project_id: UUID
creative_id: UUID
placement_channel_id: UUID
purchase_id: UUID | None
post_id: UUID | None
purchase_channel_id: UUID | None
class Meta:
table = 'placement'

View File

@@ -4,6 +4,7 @@ from uuid import UUID
from tortoise import fields
from .purchase import InviteLinkType
from .base import TimestampedModel
if TYPE_CHECKING:
@@ -17,25 +18,21 @@ class ProjectStatus(str, enum.Enum):
ARCHIVED = 'archived'
class InviteLinkType(str, enum.Enum):
PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом
class Project(TimestampedModel):
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
invite_link_type = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
purchase_invite_type_default = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True
)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
workspace_id: UUID
channel_id: UUID
workspace_id: UUID
class Meta:
table = 'project'

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

@@ -0,0 +1,74 @@
import enum
import uuid
from typing import TYPE_CHECKING
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .channel import Channel
from .creative import Creative
from .project import Project
from .workspace import Workspace
class PurchaseStatus(str, enum.Enum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class PurchaseChannelStatus(str, enum.Enum):
PLANNED = 'planned'
APPROVED = 'approved'
REJECTED = 'rejected'
IN_PROGRESS = 'in_progress'
COMPLETED = 'completed'
class Purchase(TimestampedModel):
status = fields.CharEnumField(PurchaseStatus, default=PurchaseStatus.ACTIVE)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='purchases', on_delete=fields.CASCADE, index=True
)
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True
)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='purchases', on_delete=fields.CASCADE, index=True
)
class Meta:
table = 'purchase'
indexes = (('project', 'status'),)
class InviteLinkType(str, enum.Enum):
PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом
class PurchaseChannel(TimestampedModel):
status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED)
planned_cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512)
invite_link_type = fields.CharEnumField(InviteLinkType)
purchase: fields.ForeignKeyRelation['Purchase'] = fields.ForeignKeyField(
'models.Purchase', related_name='channels', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='purchase_channels', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
purchase_id: uuid.UUID
channel_id: uuid.UUID
class Meta:
table = 'purchase_channel'
unique_together = (('purchase_id', 'channel_id'),)

View File

@@ -1,61 +0,0 @@
import enum
import uuid
from typing import TYPE_CHECKING
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .channel import Channel
from .project import Project
from .workspace import Workspace
class PurchasePlanStatus(str, enum.Enum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class PurchasePlanChannelStatus(str, enum.Enum):
PLANNED = 'planned'
APPROVED = 'approved'
REJECTED = 'rejected'
IN_PROGRESS = 'in_progress'
COMPLETED = 'completed'
class PurchasePlan(TimestampedModel):
status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='purchase_plans', on_delete=fields.CASCADE, index=True
)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='purchase_plans', on_delete=fields.CASCADE, index=True
)
class Meta:
table = 'purchase_plan'
indexes = (('project', 'status'),)
class PurchasePlanChannel(TimestampedModel):
status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED)
planned_cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
purchase_plan: fields.ForeignKeyRelation['PurchasePlan'] = fields.ForeignKeyField(
'models.PurchasePlan', related_name='channels', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='purchase_plan_channels', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
purchase_plan_id: uuid.UUID
channel_id: uuid.UUID
class Meta:
table = 'purchase_plan_channel'
unique_together = (('purchase_plan_id', 'channel_id'),)

View File

@@ -1,22 +0,0 @@
import enum
from tortoise import fields
from .base import TimestampedModel
class TelegramStateEnum(str, enum.Enum):
CREATIVE_WAITING_WORKSPACE = 'creative_waiting_workspace'
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
CREATIVE_WAITING_NAME = 'creative_waiting_name'
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
PROJECT_WAITING_WORKSPACE = 'project_waiting_workspace'
class TelegramState(TimestampedModel):
telegram_id = fields.BigIntField(unique=True, index=True)
state = fields.CharEnumField(TelegramStateEnum)
context = fields.JSONField(default=dict)
class Meta:
table = 'telegram_state'

View File

@@ -9,18 +9,19 @@ __all__ = (
'ProjectOutput',
'ValidateLoginTokenInput',
'ValidateLoginTokenOutput',
'TelegramLoginInput',
'ChannelBotPermissions',
'ChannelOutput',
'GetChannelInput',
'GetChannelsInput',
'GetChannelsOutput',
'AttachChannelToWorkspaceInput',
'PurchasePlanChannelOutput',
'GetPurchasePlanChannelsInput',
'GetPurchasePlanChannelsOutput',
'AttachChannelToPurchasePlanInput',
'UpdatePurchasePlanChannelInput',
'PurchaseOutput',
'PurchaseChannelOutput',
'CreatePurchaseInput',
'CreatePurchaseChannelInput',
'GetPurchasesInput',
'GetPurchasesOutput',
'GetPurchaseInput',
'CreativeOutput',
'GetCreativesInput',
'GetCreativesOutput',
@@ -32,9 +33,6 @@ __all__ = (
'GetPlacementsInput',
'GetPlacementsOutput',
'GetPlacementInput',
'CreatePlacementInput',
'UpdatePlacementInput',
'DeletePlacementInput',
'PostViewsHistoryOutput',
'GetViewsHistoryInput',
'GetViewsHistoryOutput',
@@ -104,15 +102,7 @@ from .creative import (
GetCreativesOutput,
UpdateCreativeInput,
)
from .placement import (
CreatePlacementInput,
DeletePlacementInput,
GetPlacementInput,
GetPlacementsInput,
GetPlacementsOutput,
PlacementOutput,
UpdatePlacementInput,
)
from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput
from .project import (
ChannelBotPermissions,
ConnectProjectInput,
@@ -124,14 +114,15 @@ from .project import (
UpdateProjectInviteLinkTypeInput,
UpdateProjectPermissionsInput,
)
from .purchase_plan import (
AttachChannelToPurchasePlanInput,
GetPurchasePlanChannelsInput,
GetPurchasePlanChannelsOutput,
PurchasePlanChannelOutput,
UpdatePurchasePlanChannelInput,
from .purchase import (
CreatePurchaseChannelInput,
CreatePurchaseInput,
GetPurchaseInput,
GetPurchasesInput,
GetPurchasesOutput,
PurchaseChannelOutput,
PurchaseOutput,
)
from .telegram_login import TelegramLoginInput
from .user import UserOutput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
from .views import (
@@ -162,4 +153,4 @@ from .workspace import (
class CreateLoginTokenRequest(BaseModel):
telegram_id: int
username: str | None
username: str | None

View File

@@ -18,10 +18,12 @@ class PlacementOutput(pydantic.BaseModel):
cost: float | None
comment: str | None
ad_post_url: str | None
invite_link_type: domain.InviteLinkType
invite_link_type: domain.purchase.InviteLinkType
invite_link: str
status: domain.PlacementStatus
subscriptions_count: int
purchase_id: uuid.UUID | None = None
purchase_channel_id: uuid.UUID | None = None
created_at: datetime.datetime

View File

@@ -32,11 +32,11 @@ class ProjectOutput(pydantic.BaseModel):
title: str
username: str | None
status: ProjectStatus
invite_link_type: domain.InviteLinkType
purchase_invite_type_default: domain.purchase.InviteLinkType
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
invite_link_type: domain.InviteLinkType
purchase_invite_type_default: domain.purchase.InviteLinkType
class GetWorkspaceProjectsInput(pydantic.BaseModel):

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

@@ -0,0 +1,53 @@
import uuid
import pydantic
from src.domain.purchase import InviteLinkType, PurchaseChannelStatus, PurchaseStatus
from .channel import ChannelOutput
class PurchaseChannelOutput(pydantic.BaseModel):
id: uuid.UUID
status: PurchaseChannelStatus
planned_cost: float | None = None
comment: str | None = None
invite_link: str
invite_link_type: InviteLinkType
channel: ChannelOutput
class PurchaseOutput(pydantic.BaseModel):
id: uuid.UUID
status: PurchaseStatus
creative_id: uuid.UUID
channels: list[PurchaseChannelOutput]
class CreatePurchaseChannelInput(pydantic.BaseModel):
username: str = pydantic.Field(pattern=r'^[^@]')
status: PurchaseChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None
class CreatePurchaseInput(pydantic.BaseModel):
creative_id: uuid.UUID
channels: list[CreatePurchaseChannelInput]
class GetPurchasesInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID
class GetPurchasesOutput(pydantic.BaseModel):
purchases: list[PurchaseOutput]
class GetPurchaseInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID
purchase_id: uuid.UUID

View File

@@ -1,38 +0,0 @@
import uuid
import pydantic
from src.domain.purchase_plan import PurchasePlanChannelStatus
from .channel import ChannelOutput
class PurchasePlanChannelOutput(pydantic.BaseModel):
id: uuid.UUID
status: PurchasePlanChannelStatus
planned_cost: float | None = None
comment: str | None = None
channel: ChannelOutput
class GetPurchasePlanChannelsInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID
class GetPurchasePlanChannelsOutput(pydantic.BaseModel):
channels: list[PurchasePlanChannelOutput]
class AttachChannelToPurchasePlanInput(pydantic.BaseModel):
username: str = pydantic.Field(pattern=r'^[^@]')
status: PurchasePlanChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None
class UpdatePurchasePlanChannelInput(pydantic.BaseModel):
status: PurchasePlanChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None

View File

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

View File

@@ -3,7 +3,6 @@ import typing
from dataclasses import dataclass
from uuid import UUID
if typing.TYPE_CHECKING:
from aiogram.types import InlineKeyboardButton
@@ -13,8 +12,8 @@ from .analytics.get_channel_analytics import get_channel_analytics
from .analytics.get_creatives_analytics import get_creatives_analytics
from .analytics.get_placements_analytics import get_placements_analytics
from .analytics.get_spending_analytics import get_spending_analytics
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
from .auth.create_telegram_login_token import create_telegram_login_token
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
from .auth.get_me import get_me
from .auth.validate_login_token import validate_login_token
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
@@ -25,22 +24,18 @@ 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 .placement.create_placement import create_placement
from .placement.delete_placement import delete_placement
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
from .placement.get_placement import get_placement
from .placement.get_placements import get_placements
from .placement.update_placement import update_placement
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
from .project.get_project import get_project
from .project.get_workspace_projects import get_workspace_projects
from .project.tg_add_project import tg_add_project
from .project.update_project_invite_link_type import update_project_invite_link_type
from .project.update_project_permissions import update_project_permissions
from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan
from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels
from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel
from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_channel
from .purchase.create_purchase import create_purchase
from .purchase.get_purchase import get_purchase
from .purchase.get_purchases import get_purchases
from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription
from .views.get_views_history import get_views_history
@@ -155,31 +150,37 @@ class Database(typing.Protocol):
self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None
) -> list[domain.Project]: ...
async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ...
async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ...
async def get_purchase_plan(self, workspace_id: UUID, plan_id: UUID) -> domain.PurchasePlan | None: ...
async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: ...
async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ...
async def get_purchase_plan_channels(self, plan_id: UUID) -> list[domain.PurchasePlanChannel]: ...
async def create_purchase(self, purchase: domain.Purchase) -> None: ...
async def get_purchase_plan_channel(
self, plan_channel_id: UUID, plan_id: UUID
) -> domain.PurchasePlanChannel | None: ...
async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ...
async def add_channel_to_purchase_plan(
async def get_purchase_channel(
self, purchase_channel_id: UUID, purchase_id: UUID
) -> domain.PurchaseChannel | None: ...
async def get_purchase_channels_by_project(self, project_id: UUID) -> list[domain.PurchaseChannel]: ...
async def add_channel_to_purchase(
self,
plan_id: UUID,
purchase_id: UUID,
channel_id: UUID,
invite_link: str,
invite_link_type: domain.purchase.InviteLinkType,
*,
status: domain.PurchasePlanChannelStatus | None = None,
status: domain.PurchaseChannelStatus | None = None,
planned_cost: float | None = None,
comment: str | None = None,
) -> domain.PurchasePlanChannel: ...
) -> domain.PurchaseChannel: ...
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: ...
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ...
async def remove_channel_from_purchase_plan(self, plan_id: UUID, channel_id: UUID) -> None: ...
async def remove_channel_from_purchase(self, purchase_id: UUID, channel_id: UUID) -> None: ...
async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
@@ -197,10 +198,6 @@ class Database(typing.Protocol):
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
async def create_placement(self, placement: domain.Placement) -> None: ...
async def update_placement(self, placement: domain.Placement) -> None: ...
async def get_workspace_placements(
self,
workspace_id: UUID,
@@ -264,14 +261,6 @@ class Database(typing.Protocol):
async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ...
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ...
async def set_telegram_state(
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
) -> domain.TelegramState: ...
async def clear_telegram_state(self, telegram_id: int) -> None: ...
class TelegramBotWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ...
@@ -362,14 +351,14 @@ class Usecase:
return workspace
async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan:
plan = await self.database.get_active_purchase_plan(project.id)
if plan:
return plan
async def ensure_active_purchase(self, project: domain.Project, creative: domain.Creative) -> domain.Purchase:
purchase = await self.database.get_active_purchase(project.id, creative.id)
if purchase:
return purchase
plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id)
await self.database.create_purchase_plan(plan)
return plan
purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id)
await self.database.create_purchase(purchase)
return purchase
validate_login_token = validate_login_token
create_telegram_login_token = create_telegram_login_token
@@ -384,10 +373,9 @@ class Usecase:
get_channels = get_channels
get_channel = get_channel
attach_channel_to_workspace = attach_channel_to_workspace
attach_channel_to_purchase_plan = attach_channel_to_purchase_plan
get_purchase_plan_channels = get_purchase_plan_channels
update_purchase_plan_channel = update_purchase_plan_channel
remove_purchase_plan_channel = remove_purchase_plan_channel
create_purchase = create_purchase
get_purchases = get_purchases
get_purchase = get_purchase
get_creatives = get_creatives
get_creative = get_creative
create_creative = create_creative
@@ -395,9 +383,6 @@ class Usecase:
delete_creative = delete_creative
get_placements = get_placements
get_placement = get_placement
create_placement = create_placement
update_placement = update_placement
delete_placement = delete_placement
fetch_placement_post_cycle = fetch_placement_post_cycle
handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription

View File

@@ -25,9 +25,8 @@ async def get_channel_analytics(
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
plan = await self.ensure_active_purchase_plan(project)
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
channels = [pc.channel for pc in plan_channels]
purchase_channels = await self.database.get_purchase_channels_by_project(project.id)
channels = [pc.channel for pc in purchase_channels]
allowed_project_filter = None
else:
allowed_project_filter = allowed_project_ids

View File

@@ -61,7 +61,7 @@ async def get_placements_analytics(
placement_channel_title=p.placement_channel.title,
creative_id=p.creative_id,
creative_name=p.creative.name,
placement_date=p.placement_date,
placement_date=p.wanted_placement_date,
cost=p.cost,
subscriptions_count=subscriptions_counts.get(p.id, 0),
views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None,

View File

@@ -56,8 +56,8 @@ async def get_spending_analytics(
filtered = [
p
for p in placements
if (not input.date_from or p.placement_date >= input.date_from)
and (not input.date_to or p.placement_date <= input.date_to)
if (not input.date_from or p.wanted_placement_date >= input.date_from)
and (not input.date_to or p.wanted_placement_date <= input.date_to)
]
total_cost = 0.0
@@ -82,7 +82,7 @@ async def get_spending_analytics(
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
for p in filtered:
period = _format_period(p.placement_date, input.grouping)
period = _format_period(p.wanted_placement_date, input.grouping)
pd = period_data[period]
if p.cost is not None:

View File

@@ -41,12 +41,10 @@ async def attach_channel_to_workspace(
await self.database.create_project(project)
log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
await self.ensure_active_purchase_plan(project)
return dto.ProjectOutput(
id=project.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
status=project.status,
)
)

View File

@@ -2,6 +2,7 @@ import logging
import uuid
from typing import TYPE_CHECKING
import domain.purchase
from src import domain, dto
if TYPE_CHECKING:
@@ -35,7 +36,7 @@ async def create_placement(
raise domain.CreativeNotFound(input.creative_id)
# Берем тип ссылки из настроек проекта
requires_approval = project.invite_link_type == domain.InviteLinkType.APPROVAL
requires_approval = project.purchase_invite_type_default == domain.purchase.InviteLinkType.APPROVAL
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
@@ -64,11 +65,11 @@ async def create_placement(
placement_channel_title=placement_channel.title,
creative_id=placement.creative_id,
creative_name=creative.name,
placement_date=placement.placement_date,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=None,
invite_link_type=project.invite_link_type,
invite_link_type=project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=0,

View File

@@ -12,52 +12,56 @@ log = logging.getLogger(__name__)
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
log.debug('Starting fetch_placement_post_cycle')
placements_without_post = (
await domain.Placement.filter(
post_id__isnull=True,
)
.prefetch_related('placement_channel')
purchase_channels = (
await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE)
.prefetch_related('channel', 'purchase')
.all()
)
if not placements_without_post:
log.debug('No placements without posts found')
if not purchase_channels:
log.debug('No active purchase channels found')
return
log.info(f'Found {len(placements_without_post)} placements without posts')
created_count = 0
updated_count = 0
for placement in placements_without_post:
try:
matching_post = await domain.Post.filter(
channel_id=placement.placement_channel_id,
text__contains=placement.invite_link,
deleted_from_channel_at__isnull=True,
).first()
if matching_post:
# Validate channel consistency
if matching_post.channel_id != placement.placement_channel_id:
log.error(
'Post %s channel_id mismatch: expected %s, got %s. Skipping placement %s',
matching_post.id,
placement.placement_channel_id,
matching_post.channel_id,
placement.id,
)
continue
placement.post = matching_post
await placement.save()
updated_count += 1
log.info(
f'Linked post {matching_post.id} (message_id={matching_post.message_id}) '
f'to placement {placement.id}'
)
except Exception as e:
log.exception(f'Error processing placement {placement.id}: {e}')
for purchase_channel in purchase_channels:
if purchase_channel.channel is None or purchase_channel.purchase is None:
log.warning('Purchase channel %s missing channel or purchase, skipping', purchase_channel.id)
continue
log.info(f'Fetch placement post cycle completed. Updated {updated_count} placements')
posts = await domain.Post.filter(
channel_id=purchase_channel.channel_id,
text__contains=purchase_channel.invite_link,
deleted_from_channel_at__isnull=True,
).all()
for post in posts:
existing = await domain.Placement.filter(post_id=post.id).first()
if existing:
continue
placement = domain.Placement(
wanted_placement_date=post.created_at,
cost=None,
comment=None,
invite_link=purchase_channel.invite_link,
workspace_id=purchase_channel.purchase.workspace_id,
project_id=purchase_channel.purchase.project_id,
creative_id=purchase_channel.purchase.creative_id,
placement_channel_id=purchase_channel.channel_id,
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
post=post,
status=domain.PlacementStatus.ACTIVE,
)
await self.database.create_placement(placement)
created_count += 1
log.info(
'Created placement %s for purchase_channel %s from post %s',
placement.id,
purchase_channel.id,
post.id,
)
log.info('Fetch placement post cycle completed. Created %s placements', created_count)

View File

@@ -33,11 +33,11 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.placement_date,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.project.invite_link_type,
invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=subscriptions_count,

View File

@@ -42,11 +42,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.placement_date,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.project.invite_link_type,
invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=subscriptions_counts.get(placement.id, 0),

View File

@@ -27,7 +27,7 @@ async def update_placement(
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
if input.placement_date is not None:
placement.placement_date = input.placement_date
placement.wanted_placement_date = input.placement_date
if input.cost is not None:
placement.cost = input.cost
if input.comment is not None:
@@ -49,11 +49,11 @@ async def update_placement(
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.placement_date,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.project.invite_link_type,
invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=subscriptions_count,

View File

@@ -20,5 +20,5 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec
title=project.channel.title,
username=project.channel.username,
status=project.status,
invite_link_type=project.invite_link_type,
purchase_invite_type_default=project.purchase_invite_type_default,
)

View File

@@ -6,9 +6,7 @@ if TYPE_CHECKING:
from .. import Usecase
async def get_workspace_projects(
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
) -> list[dto.ProjectOutput]:
async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
)
@@ -24,7 +22,7 @@ async def get_workspace_projects(
title=project.channel.title,
username=project.channel.username,
status=project.status,
invite_link_type=project.invite_link_type,
purchase_invite_type_default=project.purchase_invite_type_default,
)
for project in projects
]

View File

@@ -104,8 +104,6 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
)
await self.database.create_project(project)
await self.ensure_active_purchase_plan(project)
log.info(
'Project for channel %s connected/updated successfully in workspace %s by user %s',
input.telegram_id,

View File

@@ -2,7 +2,7 @@ import logging
import uuid
from typing import TYPE_CHECKING
from src import domain
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
@@ -14,9 +14,9 @@ async def update_project_invite_link_type(
self: 'Usecase',
workspace_id: uuid.UUID,
project_id: uuid.UUID,
invite_link_type: domain.InviteLinkType,
purchase_invite_type_default: domain.purchase.InviteLinkType,
user_id: uuid.UUID,
) -> domain.Project:
) -> dto.ProjectOutput:
await self.ensure_workspace_permission(
workspace_id,
user_id,
@@ -24,19 +24,19 @@ async def update_project_invite_link_type(
for_project_id=project_id,
)
async with self.database.transaction():
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
project.invite_link_type = invite_link_type
await project.save()
project.purchase_invite_type_default = purchase_invite_type_default
log.info(
'Project %s invite_link_type updated to %s by user %s',
project_id,
invite_link_type,
user_id,
)
await self.database.update_project(project)
return project
return dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
)

View File

@@ -0,0 +1,117 @@
import logging
import random
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
def _build_purchase_output(
purchase: domain.Purchase, purchase_channels: list[domain.PurchaseChannel]
) -> dto.PurchaseOutput:
channels_output: list[dto.PurchaseChannelOutput] = []
for purchase_channel in purchase_channels:
channel = purchase_channel.channel
if channel is None:
log.warning('Purchase channel %s has no channel prefetched', purchase_channel.id)
continue
channels_output.append(
dto.PurchaseChannelOutput(
id=purchase_channel.id,
status=purchase_channel.status,
planned_cost=purchase_channel.planned_cost,
comment=purchase_channel.comment,
invite_link=purchase_channel.invite_link,
invite_link_type=purchase_channel.invite_link_type,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
),
)
)
return dto.PurchaseOutput(
id=purchase.id,
status=purchase.status,
creative_id=purchase.creative_id,
channels=channels_output,
)
async def create_purchase(
self: 'Usecase',
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.CreatePurchaseInput,
) -> dto.PurchaseOutput:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
creative = await self.database.get_creative(workspace_id, input.creative_id)
if not creative or creative.project_id != project.id:
raise domain.CreativeNotFound(input.creative_id)
existing_purchase = await self.database.get_active_purchase(project.id, creative.id)
if existing_purchase:
channels = await self.database.get_purchase_channels(existing_purchase.id)
return _build_purchase_output(existing_purchase, channels)
purchase = domain.Purchase(workspace_id=workspace_id, project_id=project.id, creative_id=creative.id)
await self.database.create_purchase(purchase)
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
invite_link_type = project.purchase_invite_type_default
requires_approval = invite_link_type == domain.purchase.InviteLinkType.APPROVAL
purchase_channels: list[domain.PurchaseChannel] = []
for channel_input in input.channels:
channel = await self.database.get_channel(username=channel_input.username)
if not channel:
channel = domain.Channel(
username=channel_input.username,
telegram_id=random.randint(1, 10000),
title='parser_response.title',
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', channel_input.username, channel.telegram_id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
purchase_channel = await self.database.add_channel_to_purchase(
purchase.id,
channel.id,
invite_link=invite_link,
invite_link_type=invite_link_type,
status=channel_input.status,
planned_cost=channel_input.planned_cost,
comment=channel_input.comment,
)
purchase_channel.channel = channel
purchase_channels.append(purchase_channel)
log.info(
'Purchase %s created for project %s (creative %s) with %s channels',
purchase.id,
project.id,
creative.id,
len(purchase_channels),
)
return _build_purchase_output(purchase, purchase_channels)

View File

@@ -0,0 +1,30 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
from .create_purchase import _build_purchase_output
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
purchase = await self.database.get_purchase(input.workspace_id, input.purchase_id)
if not purchase or purchase.project_id != project.id:
raise domain.PurchaseNotFound(input.purchase_id)
channels = await self.database.get_purchase_channels(purchase.id)
return _build_purchase_output(purchase, channels)

View File

@@ -0,0 +1,32 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
from .create_purchase import _build_purchase_output
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> list[dto.PurchaseOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
purchases = await self.database.get_project_purchases(input.workspace_id, project.id)
log.debug('Fetched %s purchases for project %s', len(purchases), project.id)
output: list[dto.PurchaseOutput] = []
for purchase in purchases:
channels = await self.database.get_purchase_channels(purchase.id)
output.append(_build_purchase_output(purchase, channels))
return output

View File

@@ -1,67 +0,0 @@
import logging
import random
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def attach_channel_to_purchase_plan(
self: 'Usecase',
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.AttachChannelToPurchasePlanInput,
) -> dto.PurchasePlanChannelOutput:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project)
channel = await self.database.get_channel(username=input.username)
if not channel:
# parser_response = await self.parser.fetch_telegram_channel(input.username)
# if not parser_response:
# raise domain.TelegramChannelNotFound(input.username)
channel = domain.Channel(
username=input.username,
telegram_id=random.randint(1, 10000),
title='parser_response.title',
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', input.username, channel.telegram_id)
plan_channel = await self.database.add_channel_to_purchase_plan(
plan.id,
channel.id,
status=input.status,
planned_cost=input.planned_cost,
comment=input.comment,
)
log.info('Channel %s attached to purchase plan %s (project %s)', channel.id, plan.id, project.id)
return dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
),
)

View File

@@ -1,44 +0,0 @@
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_plan_channels(
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
) -> list[dto.PurchasePlanChannelOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
plan = await self.ensure_active_purchase_plan(project)
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
log.debug('Fetched %s channels for purchase plan %s (project %s)', len(plan_channels), plan.id, project.id)
return [
dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=plan_channel.channel.id,
telegram_id=plan_channel.channel.telegram_id,
title=plan_channel.channel.title,
username=plan_channel.channel.username,
),
)
for plan_channel in plan_channels
]

View File

@@ -1,35 +0,0 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def remove_purchase_plan_channel(
self: 'Usecase',
channel_id: uuid.UUID,
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project)
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
if not plan_channel:
raise domain.PurchasePlanChannelNotFound(channel_id)
await self.database.remove_channel_from_purchase_plan(plan.id, plan_channel.channel_id)
log.info('Removed channel %s from purchase plan %s (project %s)', plan_channel.channel_id, plan.id, project.id)

View File

@@ -1,72 +0,0 @@
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_plan_channel(
self: 'Usecase',
channel_id: uuid.UUID,
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.UpdatePurchasePlanChannelInput,
) -> dto.PurchasePlanChannelOutput:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project)
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
if not plan_channel:
raise domain.PurchasePlanChannelNotFound(channel_id)
updated_fields: dict[str, object | None] = {}
if input.status is not None:
plan_channel.status = input.status
updated_fields['status'] = input.status
if input.planned_cost is not None:
plan_channel.planned_cost = input.planned_cost
updated_fields['planned_cost'] = input.planned_cost
if input.comment is not None:
plan_channel.comment = input.comment
updated_fields['comment'] = input.comment
if updated_fields:
await self.database.update_purchase_plan_channel(plan_channel)
log.info(
'Updated purchase plan channel %s for project %s fields %s',
channel_id,
project.id,
', '.join(updated_fields.keys()),
)
channel: domain.Channel | None = plan_channel.channel
if channel is None:
channel = await self.database.get_channel(channel_id=plan_channel.channel_id)
if channel is None:
raise domain.ChannelNotFound(plan_channel.channel_id)
return dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
),
)