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

@@ -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,
),
)