This commit is contained in:
Artem Tsyrulnikov
2026-01-15 22:02:16 +03:00
parent 6ec26c96e3
commit 049024ebe8
72 changed files with 2417 additions and 2054 deletions

View File

@@ -26,9 +26,9 @@ 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.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.fetch_publication_post_cycle import fetch_publication_post_cycle
from .placement.get_publication import get_publication
from .placement.get_publications import get_publications
from .project.archive_project import archive_project
from .project.delete_project import delete_project
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
@@ -38,9 +38,9 @@ from .project.move_project_to_workspace import move_project_to_workspace
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.create_purchase import create_purchase
from .purchase.get_purchase import get_purchase
from .purchase.get_purchases import get_purchases
from .purchase.create_placements import create_placements
from .purchase.get_placement import get_placement_user
from .purchase.get_placements import get_placements
from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription
from .views.get_views_history import get_views_history
@@ -171,43 +171,18 @@ class Database(typing.Protocol):
async def check_channel_exists_in_workspace(self, channel_id: UUID, workspace_id: UUID) -> bool: ...
async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ...
# Placement methods (user-managed, бывший PurchaseChannel)
async def create_placement(self, placement: domain.Placement) -> None: ...
async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
async def create_placements(self, placements: list[domain.Placement]) -> None: ...
async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ...
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
async def create_purchase(self, purchase: domain.Purchase) -> None: ...
async def get_project_placements(
self, workspace_id: UUID, project_id: UUID, include_archived: bool = False
) -> list[domain.Placement]: ...
async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ...
async def get_purchase_channel(
self, purchase_channel_id: UUID, purchase_id: UUID
) -> domain.PurchaseChannel | None: ...
async def get_purchase_channel_by_id(self, purchase_channel_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,
purchase_id: UUID,
channel_id: UUID,
invite_link: str,
invite_link_type: domain.purchase.InviteLinkType,
*,
status: domain.PurchaseChannelStatus | None = None,
comment: str | None = None,
placement_at: datetime.datetime | None = None,
cost_type: domain.purchase.CostType | None = None,
cost_value: float | None = None,
cost_before_bargain: float | None = None,
format: str | None = None,
) -> domain.PurchaseChannel: ...
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ...
async def remove_channel_from_purchase(self, purchase_id: UUID, channel_id: UUID) -> None: ...
async def update_placement_user(self, placement: domain.Placement) -> None: ...
async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
@@ -223,9 +198,10 @@ class Database(typing.Protocol):
async def delete_creative(self, creative_id: UUID) -> None: ...
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
# Publication methods (system-managed, бывший Placement)
async def get_publication(self, workspace_id: UUID, publication_id: UUID) -> domain.Publication | None: ...
async def get_workspace_placements(
async def get_workspace_publications(
self,
workspace_id: UUID,
project_id: UUID | None = None,
@@ -235,27 +211,28 @@ class Database(typing.Protocol):
allowed_project_ids: set[UUID] | None = None,
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.Placement]: ...
) -> list[domain.Publication]: ...
async def create_placement(self, placement: domain.Placement) -> None: ...
async def create_publication(self, publication: domain.Publication) -> None: ...
async def update_placement(self, placement: domain.Placement) -> None: ...
async def update_publication(self, publication: domain.Publication) -> None: ...
async def delete_placement(self, placement_id: UUID) -> None: ...
async def delete_publication(self, publication_id: UUID) -> None: ...
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ...
async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None: ...
# Subscription methods
async def create_subscription(self, subscription: domain.Subscription) -> None: ...
async def get_subscription_by_subscriber_and_placement(
self, telegram_user_id: UUID, placement_id: UUID
async def get_subscription_by_subscriber_and_publication(
self, telegram_user_id: UUID, publication_id: UUID
) -> domain.Subscription | None: ...
async def update_subscription(self, subscription: domain.Subscription) -> None: ...
async def get_subscriptions_for_placements(
async def get_subscriptions_for_publications(
self,
placement_ids: list[UUID],
publication_ids: list[UUID],
*,
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
@@ -270,15 +247,17 @@ class Database(typing.Protocol):
) -> domain.Subscription | None: ...
# Count methods
async def count_placements_by_creative(self, creative_id: UUID) -> int: ...
async def count_publications_by_creative(self, creative_id: UUID) -> int: ...
async def count_subscriptions_by_placement(self, placement_id: UUID) -> int: ...
async def count_subscriptions_by_publication(self, publication_id: UUID) -> int: ...
async def count_placements_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ...
async def count_publications_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ...
async def count_subscriptions_by_placement_batch(self, placement_ids: list[UUID]) -> dict[UUID, int]: ...
async def count_subscriptions_by_publication_batch(
self, publication_ids: list[UUID]
) -> dict[UUID, int]: ...
async def has_placements_for_creative(self, creative_id: UUID) -> bool: ...
async def has_publications_for_creative(self, creative_id: UUID) -> bool: ...
# Post methods
async def create_post(self, post: domain.Post) -> None: ...
@@ -409,15 +388,6 @@ class Usecase:
return workspace
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
purchase = domain.Purchase(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
get_jwt_by_telegram_id = get_jwt_by_telegram_id
@@ -434,17 +404,20 @@ class Usecase:
get_channels = get_channels
get_channel = get_channel
attach_channel_to_workspace = attach_channel_to_workspace
create_purchase = create_purchase
get_purchases = get_purchases
get_purchase = get_purchase
# Placement (user-managed) use cases
create_placements = create_placements
get_placements = get_placements
get_placement_user = get_placement_user
# Creative use cases
get_creatives = get_creatives
get_creative = get_creative
create_creative = create_creative
update_creative = update_creative
delete_creative = delete_creative
get_placements = get_placements
get_placement = get_placement
fetch_placement_post_cycle = fetch_placement_post_cycle
# Publication (system-managed) use cases
get_publications = get_publications
get_publication = get_publication
fetch_publication_post_cycle = fetch_publication_post_cycle
handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription
get_views_history = get_views_history

View File

@@ -25,32 +25,24 @@ async def get_channel_analytics(
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
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
if allowed_project_ids is None:
# Получить каналы через Projects
projects = await self.database.get_workspace_projects(input.workspace_id)
channels = [p.channel for p in projects]
else:
channels = []
placements = await self.database.get_workspace_placements(
placements = await self.database.get_workspace_publications(
input.workspace_id,
input.project_id,
include_archived=False,
allowed_project_ids=allowed_project_filter,
)
if input.project_id is None and allowed_project_ids is not None:
channel_map: dict[UUID, domain.Channel] = {}
for placement in placements:
purchase_channel = placement.purchase_channel
if purchase_channel and purchase_channel.channel:
channel_map[purchase_channel.channel_id] = purchase_channel.channel
channels = list(channel_map.values())
# Collect unique channels from placements
channel_map: dict[UUID, domain.Channel] = {}
for publication in placements:
placement = publication.placement
if placement and placement.channel:
channel_map[placement.channel_id] = placement.channel
channels = list(channel_map.values())
@dataclass
class ChannelStats:
@@ -67,22 +59,22 @@ async def get_channel_analytics(
# Batch fetch subscriptions counts
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
for p in placements:
purchase_channel = p.purchase_channel
if not purchase_channel:
for publication in placements:
placement = publication.placement
if not placement:
continue
stats = channel_stats.get(purchase_channel.channel_id)
stats = channel_stats.get(placement.channel_id)
if stats is None:
continue
stats.total_cost += p.cost if p.cost is not None else 0
stats.total_subscriptions += subscriptions_counts.get(p.id, 0)
stats.total_cost += publication.cost if publication.cost is not None else 0
stats.total_subscriptions += subscriptions_counts.get(publication.id, 0)
# Get views from batch data
if p.post and p.post.id in views_map:
views_count = views_map[p.post.id][0]
if publication.post and publication.post.id in views_map:
views_count = views_map[publication.post.id][0]
stats.total_views += views_count
stats.placements_count += 1

View File

@@ -33,7 +33,7 @@ async def get_creatives_analytics(
include_archived=False,
allowed_project_ids=allowed_project_ids,
)
placements = await self.database.get_workspace_placements(
placements = await self.database.get_workspace_publications(
input.workspace_id,
input.project_id,
include_archived=False,
@@ -55,7 +55,7 @@ async def get_creatives_analytics(
# Batch fetch subscriptions counts
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
for p in placements:
stats = creative_stats.get(p.creative_id)

View File

@@ -55,7 +55,7 @@ async def get_overview_analytics(
period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1)
previous_period_start = input.date_from - period_length
placements = await self.database.get_workspace_placements(
placements = await self.database.get_workspace_publications(
input.workspace_id,
project_id=input.project_id,
include_archived=False,
@@ -64,18 +64,18 @@ async def get_overview_analytics(
date_to=input.date_to,
)
current_placements: list[domain.Placement] = []
previous_placements: list[domain.Placement] = []
current_placements: list[domain.Publication] = []
previous_placements: list[domain.Publication] = []
for placement in placements:
placement_date = placement.wanted_placement_date
for publication in placements:
placement_date = publication.wanted_placement_date
if placement_date >= input.date_from and placement_date <= input.date_to:
current_placements.append(placement)
current_placements.append(publication)
elif placement_date >= previous_period_start and placement_date < input.date_from:
previous_placements.append(placement)
previous_placements.append(publication)
placement_ids = [p.id for p in placements]
subscriptions = await self.database.get_subscriptions_for_placements(
subscriptions = await self.database.get_subscriptions_for_publications(
placement_ids, date_from=previous_period_start, date_to=input.date_to
)
@@ -89,30 +89,30 @@ async def get_overview_analytics(
continue
if created_at >= input.date_from and created_at <= input.date_to:
subs_per_placement_current[sub.placement_id] += 1
subs_per_placement_current[sub.publication_id] += 1
subs_per_day_current[created_at.date()] += 1
elif created_at >= previous_period_start and created_at < input.date_from:
subs_per_placement_previous[sub.placement_id] += 1
subs_per_placement_previous[sub.publication_id] += 1
post_ids = [p.post.id for p in placements if p.post]
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
def _aggregate_totals(
placement_list: list[domain.Placement], subs_per_placement: dict[UUID, int]
placement_list: list[domain.Publication], subs_per_placement: dict[UUID, int]
) -> tuple[float, int, int]:
total_cost = 0.0
total_subscriptions = 0
total_views = 0
for placement in placement_list:
if placement.cost is not None:
total_cost += placement.cost
for publication in placement_list:
if publication.cost is not None:
total_cost += publication.cost
subs = subs_per_placement.get(placement.id, 0)
subs = subs_per_placement.get(publication.id, 0)
total_subscriptions += subs
if placement.post and placement.post.id in views_map:
total_views += views_map[placement.post.id][0]
if publication.post and publication.post.id in views_map:
total_views += views_map[publication.post.id][0]
return total_cost, total_subscriptions, total_views
@@ -130,20 +130,20 @@ async def get_overview_analytics(
project_spending_map: dict[UUID, float] = defaultdict(float)
project_meta: dict[UUID, domain.Project] = {}
for placement in current_placements:
placement_day = placement.wanted_placement_date.date()
project_meta[placement.project_id] = placement.project
for publication in current_placements:
placement_day = publication.wanted_placement_date.date()
project_meta[publication.project_id] = publication.project
cost_value = placement.cost or 0.0
cost_value = publication.cost or 0.0
cost_per_day[placement_day] += cost_value
project_spending_map[placement.project_id] += cost_value
subs = subs_per_placement_current.get(placement.id, 0)
project_spending_map[publication.project_id] += cost_value
subs = subs_per_placement_current.get(publication.id, 0)
purchase_channel = placement.purchase_channel
if purchase_channel:
channel_id = purchase_channel.channel_id
placement = publication.placement
if placement:
channel_id = placement.channel_id
if channel_id not in channel_stats:
channel_stats[channel_id] = ChannelAggregate(channel=purchase_channel.channel)
channel_stats[channel_id] = ChannelAggregate(channel=placement.channel)
channel_stats[channel_id].total_cost += cost_value
channel_stats[channel_id].total_subs += subs

View File

@@ -37,7 +37,7 @@ async def get_placements_analytics(
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
allowed_project_ids = None
placements = await self.database.get_workspace_placements(
placements = await self.database.get_workspace_publications(
input.workspace_id,
input.project_id,
include_archived=False,
@@ -50,15 +50,15 @@ async def get_placements_analytics(
# Batch fetch subscriptions counts
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
return [
dto.PlacementAnalyticsOutput(
id=p.id,
project_id=p.project_id,
project_channel_title=p.project.channel.title,
placement_channel_id=p.purchase_channel.channel_id,
placement_channel_title=p.purchase_channel.channel.title,
placement_channel_id=p.placement.channel_id,
placement_channel_title=p.placement.channel.title,
creative_id=p.creative_id,
creative_name=p.creative.name,
placement_date=p.wanted_placement_date,

View File

@@ -60,20 +60,20 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
raise ValueError('Invalid date grouping')
def _get_grouping_date(placement: domain.Placement, date_grouping: dto.DateGroupingType) -> datetime.datetime:
def _get_grouping_date(publication: domain.Publication, date_grouping: dto.DateGroupingType) -> datetime.datetime:
match date_grouping:
case dto.DateGroupingType.PLACEMENT_DATE:
return placement.wanted_placement_date
return publication.wanted_placement_date
case dto.DateGroupingType.PURCHASE_DATE:
if placement.purchase_channel and placement.purchase_channel.purchase:
return placement.purchase_channel.purchase.created_at
return placement.wanted_placement_date # Fallback
if publication.placement:
return publication.placement.created_at
return publication.wanted_placement_date # Fallback
case dto.DateGroupingType.LINK_DATE:
if placement.purchase_channel:
return placement.purchase_channel.created_at
return placement.wanted_placement_date # Fallback
if publication.placement:
return publication.placement.created_at
return publication.wanted_placement_date # Fallback
case _:
return placement.wanted_placement_date
return publication.wanted_placement_date
@dataclass
@@ -96,7 +96,7 @@ def _calculate_metrics(
all_metrics = requested_metrics is None or len(requested_metrics) == 0
def should_include(metric: dto.ProjectMetrics) -> bool:
return all_metrics or metric in requested_metrics
return all_metrics or (requested_metrics is not None and metric in requested_metrics)
metrics = dto.ProjectMetricsData()
@@ -183,7 +183,7 @@ async def get_projects_analytics(
else:
allowed_project_ids = set(input.project_ids)
placements = await self.database.get_workspace_placements(
placements = await self.database.get_workspace_publications(
input.workspace_id,
project_id=None,
include_archived=False,
@@ -194,28 +194,28 @@ async def get_projects_analytics(
# Фильтрация по датам на основе date_grouping
filtered_placements = []
for placement in placements:
grouping_date = _get_grouping_date(placement, input.date_grouping)
for publication in placements:
grouping_date = _get_grouping_date(publication, input.date_grouping)
if input.date_from and grouping_date < input.date_from:
continue
if input.date_to and grouping_date > input.date_to:
continue
filtered_placements.append(placement)
filtered_placements.append(publication)
# Batch fetch views data
post_ids = [p.post.id for p in filtered_placements if p.post]
views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
# Batch fetch subscriptions counts
placement_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
publication_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids)
# Группировка по периодам
period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
total_metrics = PeriodMetrics()
for placement in filtered_placements:
grouping_date = _get_grouping_date(placement, input.date_grouping)
for publication in filtered_placements:
grouping_date = _get_grouping_date(publication, input.date_grouping)
period = _format_period(grouping_date, input.grouping)
pd = period_data[period]
@@ -223,29 +223,29 @@ async def get_projects_analytics(
pd.purchases_count += 1
total_metrics.purchases_count += 1
cost = placement.cost or 0.0
cost = publication.cost or 0.0
pd.total_cost += cost
total_metrics.total_cost += cost
subs_count = subscriptions_counts.get(placement.id, 0)
subs_count = subscriptions_counts.get(publication.id, 0)
pd.total_subscriptions += subs_count
pd.clicks_count += subs_count
total_metrics.total_subscriptions += subs_count
total_metrics.clicks_count += subs_count
views_count = 0
if placement.post and placement.post.id in views_map:
views_count = views_map[placement.post.id][0]
if publication.post and publication.post.id in views_map:
views_count = views_map[publication.post.id][0]
pd.total_views += views_count
pd.reach_volume += views_count
total_metrics.total_views += views_count
total_metrics.reach_volume += views_count
# Расчет скидок
purchase_channel = placement.purchase_channel
if purchase_channel and purchase_channel.cost_before_bargain and purchase_channel.cost_before_bargain > cost:
discount = purchase_channel.cost_before_bargain - cost
discount_percent = (discount / purchase_channel.cost_before_bargain) * 100 if purchase_channel.cost_before_bargain > 0 else 0.0
placement = publication.placement
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
discount = placement.cost_before_bargain - cost
discount_percent = (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0
pd.total_discounts += discount
pd.discount_count += 1

View File

@@ -46,7 +46,7 @@ async def get_spending_analytics(
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
allowed_project_ids = None
placements = await self.database.get_workspace_placements(
placements = await self.database.get_workspace_publications(
input.workspace_id,
input.project_id,
include_archived=False,
@@ -76,7 +76,7 @@ async def get_spending_analytics(
# Batch fetch subscriptions counts
placement_ids = [p.id for p in filtered]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids)
# Группировка по периодам
period_data: dict[str, PeriodData] = defaultdict(PeriodData)

View File

@@ -22,9 +22,9 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
has_placements = await self.database.has_placements_for_creative(creative.id)
if has_placements:
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
has_publications = await self.database.has_publications_for_creative(creative.id)
if has_publications:
log.warning('Creative %s is used in publications and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(input.creative_id)
media_key = creative.media_s3_key

View File

@@ -20,7 +20,7 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
placements_count = await self.database.count_placements_by_creative(creative.id)
placements_count = await self.database.count_publications_by_creative(creative.id)
return dto.CreativeOutput(
id=creative.id,

View File

@@ -25,7 +25,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
)
creative_ids = [c.id for c in creatives]
placements_counts = await self.database.count_placements_by_creative_batch(creative_ids)
placements_counts = await self.database.count_publications_by_creative_batch(creative_ids)
return [
dto.CreativeOutput(

View File

@@ -69,7 +69,7 @@ async def update_creative(
await self.database.update_creative(creative)
placements_count = await self.database.count_placements_by_creative(creative.id)
placements_count = await self.database.count_publications_by_creative(creative.id)
return dto.CreativeOutput(
id=creative.id,

View File

@@ -0,0 +1,5 @@
from .fetch_publication_post_cycle import fetch_publication_post_cycle
from .get_publication import get_publication
from .get_publications import get_publications
__all__ = ['fetch_publication_post_cycle', 'get_publication', 'get_publications']

View File

@@ -1,63 +0,0 @@
import logging
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
log.debug('Starting fetch_placement_post_cycle')
purchase_channels = (
await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE)
.prefetch_related('channel', 'purchase')
.all()
)
if not purchase_channels:
log.debug('No active purchase channels found')
return
created_count = 0
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
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,
project_id=purchase_channel.purchase.project_id,
creative_id=purchase_channel.purchase.creative_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.debug('Fetch placement post cycle completed. Created %s placements', created_count)

View File

@@ -0,0 +1,64 @@
import logging
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def fetch_publication_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
"""Находит посты в каналах по invite_link из Placement и создает Publication"""
log.debug('Starting fetch_publication_post_cycle')
# Получаем все approved Placements с invite_link
placements = (
await domain.Placement.filter(
status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]
)
.prefetch_related('channel', 'project')
.all()
)
if not placements:
log.debug('No active placements found')
return
created_count = 0
for placement in placements:
if placement.channel is None or placement.invite_link is None:
log.warning('Placement %s missing channel or invite_link, skipping', placement.id)
continue
# Ищем посты в канале, содержащие invite_link из placement
posts = await domain.Post.filter(
channel_id=placement.channel_id,
text__contains=placement.invite_link,
deleted_from_channel_at__isnull=True,
).all()
for post in posts:
# Проверяем, не создана ли уже публикация для этого поста
existing = await domain.Publication.filter(post_id=post.id).first()
if existing:
continue
publication = domain.Publication(
placement_id=placement.id,
post_id=post.id,
status=domain.PublicationStatus.ACTIVE,
)
await self.database.create_publication(publication)
created_count += 1
log.info(
'Created publication %s for placement %s from post %s',
publication.id,
placement.id,
post.id,
)
log.debug('Fetch publication post cycle completed. Created %s publications', created_count)

View File

@@ -1,51 +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_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
if not placement:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
raise domain.PlacementNotFound(input.placement_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
ad_post_url = placement.post.url if placement.post else None
purchase_channel = placement.purchase_channel
if not purchase_channel or not purchase_channel.channel:
log.warning('Placement %s missing purchase channel data', placement.id)
raise domain.PurchaseChannelNotFound()
subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id)
return dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title,
placement_channel_id=purchase_channel.channel_id,
placement_channel_title=purchase_channel.channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=purchase_channel.invite_link_type,
invite_link=purchase_channel.invite_link,
status=placement.status,
subscriptions_count=subscriptions_count,
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
created_at=placement.created_at,
)

View File

@@ -1,66 +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_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list[dto.PlacementOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ)
if input.project_id is not None:
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
allowed_project_ids = None
placements = await self.database.get_workspace_placements(
input.workspace_id,
project_id=input.project_id,
placement_channel_id=input.placement_channel_id,
creative_id=input.creative_id,
include_archived=input.include_archived,
allowed_project_ids=allowed_project_ids,
)
placement_ids = [p.id for p in placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
placement_outputs = []
for placement in placements:
ad_post_url = placement.post.url if placement.post else None
purchase_channel = placement.purchase_channel
if not purchase_channel or not purchase_channel.channel:
log.warning('Placement %s missing purchase channel data', placement.id)
continue
placement_outputs.append(
dto.PlacementOutput(
id=placement.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title,
placement_channel_id=purchase_channel.channel_id,
placement_channel_title=purchase_channel.channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=purchase_channel.invite_link_type,
invite_link=purchase_channel.invite_link,
status=placement.status,
subscriptions_count=subscriptions_counts.get(placement.id, 0),
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
created_at=placement.created_at,
)
)
return placement_outputs

View File

@@ -0,0 +1,55 @@
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_publication(self: 'Usecase', input: dto.GetPublicationInput) -> dto.PublicationOutput:
"""Получить одну публикацию по ID (system-managed post)"""
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
publication = await self.database.get_publication(input.workspace_id, input.publication_id)
if not publication:
log.warning('Publication %s not found for user %s', input.publication_id, input.user_id)
raise domain.PublicationNotFound(input.publication_id)
placement = publication.placement
if not placement:
log.error('Publication %s missing placement data', publication.id)
raise domain.PlacementNotFound(publication.placement_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
if not placement.channel or not placement.project or not placement.creative:
log.error('Publication %s placement missing related data', publication.id)
raise domain.PlacementNotFound(placement.id)
ad_post_url = publication.post.url if publication.post else None
subscriptions_count = await self.database.count_subscriptions_by_publication(publication.id)
return dto.PublicationOutput(
id=publication.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title if placement.project.channel else None,
placement_channel_id=placement.channel_id,
placement_channel_title=placement.channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.placement_at or publication.created_at,
cost=placement.cost_value,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link,
status=publication.status,
subscriptions_count=subscriptions_count,
placement_id=placement.id,
created_at=publication.created_at,
)

View File

@@ -0,0 +1,71 @@
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_publications(self: 'Usecase', input: dto.GetPublicationsInput) -> dto.GetPublicationsOutput:
"""Получить список публикаций (system-managed posts)"""
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ)
if input.project_id is not None:
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
allowed_project_ids = None
publications = await self.database.get_workspace_publications(
input.workspace_id,
project_id=input.project_id,
placement_channel_id=input.placement_channel_id,
creative_id=input.creative_id,
include_archived=input.include_archived,
allowed_project_ids=allowed_project_ids,
)
publication_ids = [p.id for p in publications]
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids)
publication_outputs = []
for publication in publications:
placement = publication.placement
if not placement:
log.warning('Publication %s missing placement data', publication.id)
continue
if not placement.channel or not placement.project or not placement.creative:
log.warning('Publication %s placement missing related data', publication.id)
continue
ad_post_url = publication.post.url if publication.post else None
publication_outputs.append(
dto.PublicationOutput(
id=publication.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title if placement.project.channel else None,
placement_channel_id=placement.channel_id,
placement_channel_title=placement.channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
placement_date=placement.placement_at or publication.created_at,
cost=placement.cost_value,
comment=placement.comment,
ad_post_url=ad_post_url,
invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link,
status=publication.status,
subscriptions_count=subscriptions_counts.get(publication.id, 0),
placement_id=placement.id,
created_at=publication.created_at,
)
)
return dto.GetPublicationsOutput(publications=publication_outputs)

View File

@@ -1,4 +1,3 @@
import uuid
from typing import TYPE_CHECKING
from src import domain, dto

View File

@@ -22,7 +22,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe
log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
return
if user.telegram_user is None:
log.warning(f'User %s missing telegram profile when updating permissions', user.id)
log.warning('User %s missing telegram profile when updating permissions', user.id)
return
project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)

View File

@@ -0,0 +1,5 @@
from .create_placements import create_placements
from .get_placement import get_placement_user
from .get_placements import get_placements
__all__ = ['create_placements', 'get_placement_user', 'get_placements']

View File

@@ -0,0 +1,148 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
if cost_type is None or cost_value is None:
return None
return dto.CostInfo(type=cost_type, value=cost_value)
def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetails | None:
details = dto.PlacementDetails(
placement_at=placement.placement_at,
payment_at=placement.payment_at,
cost=_build_cost_info(placement.cost_type, placement.cost_value),
cost_before_bargain=placement.cost_before_bargain,
placement_type=placement.placement_type,
format=placement.format,
comment=placement.comment,
)
if details.model_dump(exclude_none=True):
return details
return None
def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput:
channel = placement.channel
if channel is None:
log.error('Placement %s has no channel prefetched', placement.id)
raise ValueError(f'Placement {placement.id} has no channel')
return dto.PlacementOutput(
id=placement.id,
status=placement.status,
comment=placement.comment,
invite_link=placement.invite_link,
invite_link_type=placement.invite_link_type,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
),
details=_build_placement_details(placement),
)
async def create_placements(
self: 'Usecase',
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.CreatePlacementsInput,
) -> dto.GetPlacementsOutput:
"""Create multiple placements for different channels (bulk creation, бывший create_purchase)"""
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)
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.InviteLinkType.APPROVAL
details = input.details
placements: list[domain.Placement] = []
for channel_input in input.channels:
channel = await self.database.get_channel(username=channel_input.username)
if not channel:
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
if not parser_response:
raise domain.TelegramChannelNotFound(channel_input.username)
channel = domain.Channel(
username=parser_response.username,
telegram_id=parser_response.telegram_id,
title=parser_response.title,
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
log.info(
'Created invite link for placement channel_telegram_id=%s requires_approval=%s invite_link=%s',
project.channel.telegram_id,
requires_approval,
invite_link,
)
# Объединяем общие детали с деталями конкретного канала
channel_details = channel_input.details
placement = domain.Placement(
project_id=project.id,
creative_id=creative.id,
channel_id=channel.id,
invite_link=invite_link,
invite_link_type=invite_link_type,
status=channel_input.status or domain.PlacementStatus.PLANNED,
comment=channel_input.comment,
# Приоритет: channel details > общие details
placement_at=(channel_details.placement_at if channel_details else None) or (
details.placement_at if details else None
),
payment_at=details.payment_at if details else None,
cost_type=(channel_details.cost.type if channel_details and channel_details.cost else None) or (
details.cost.type if details and details.cost else None
),
cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None) or (
details.cost.value if details and details.cost else None
),
cost_before_bargain=(channel_details.cost_before_bargain if channel_details else None) or (
details.cost_before_bargain if details else None
),
placement_type=details.placement_type if details else None,
format=(channel_details.format if channel_details else None) or (details.format if details else None),
)
await self.database.create_placement(placement)
placement.channel = channel
placements.append(placement)
log.info(
'Created %s placements for project %s (creative %s)',
len(placements),
project.id,
creative.id,
)
return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])

View File

@@ -1,173 +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__)
def _build_cost_info(cost_type: domain.purchase.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
if cost_type is None or cost_value is None:
return None
return dto.CostInfo(type=cost_type, value=cost_value)
def _build_purchase_details(purchase: domain.Purchase) -> dto.PurchaseDetails | None:
details = dto.PurchaseDetails(
placement_at=purchase.placement_at,
payment_at=purchase.payment_at,
cost=_build_cost_info(purchase.cost_type, purchase.cost_value),
cost_before_bargain=purchase.cost_before_bargain,
purchase_type=purchase.purchase_type,
format=purchase.format,
comment=purchase.comment,
)
if details.model_dump(exclude_none=True):
return details
return None
def _build_purchase_channel_details(purchase_channel: domain.PurchaseChannel) -> dto.PurchaseChannelDetails | None:
details = dto.PurchaseChannelDetails(
placement_at=purchase_channel.placement_at,
cost=_build_cost_info(purchase_channel.cost_type, purchase_channel.cost_value),
cost_before_bargain=purchase_channel.cost_before_bargain,
format=purchase_channel.format,
)
if details.model_dump(exclude_none=True):
return details
return None
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,
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,
),
details=_build_purchase_channel_details(purchase_channel),
)
)
return dto.PurchaseOutput(
id=purchase.id,
status=purchase.status,
creative_id=purchase.creative_id,
channels=channels_output,
details=_build_purchase_details(purchase),
)
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)
details = input.details
purchase = domain.Purchase(
project_id=project.id,
creative_id=creative.id,
placement_at=details.placement_at if details else None,
payment_at=details.payment_at if details else None,
cost_type=details.cost.type if details and details.cost else None,
cost_value=details.cost.value if details and details.cost else None,
cost_before_bargain=details.cost_before_bargain if details else None,
purchase_type=details.purchase_type if details else None,
format=details.format if details else None,
comment=details.comment if details else None,
)
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:
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
if not parser_response:
raise domain.TelegramChannelNotFound(channel_input.username)
channel = domain.Channel(
username=parser_response.username,
telegram_id=parser_response.telegram_id,
title=parser_response.title,
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
log.info(
'Created invite link for purchase %s channel_telegram_id=%s requires_approval=%s invite_link=%s',
purchase.id,
project.channel.telegram_id,
requires_approval,
invite_link,
)
channel_details = channel_input.details
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,
comment=channel_input.comment,
placement_at=channel_details.placement_at if channel_details else None,
cost_type=channel_details.cost.type if channel_details and channel_details.cost else None,
cost_value=channel_details.cost.value if channel_details and channel_details.cost else None,
cost_before_bargain=channel_details.cost_before_bargain if channel_details else None,
format=channel_details.format if channel_details else None,
)
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,35 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
from .create_placements import _build_placement_output
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
"""Get single placement by ID (user-managed)"""
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)
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
if not placement or placement.project_id != project.id:
raise domain.PlacementNotFound(input.placement_id)
# Prefetch channel for output building
if placement.channel is None:
channel = await self.database.get_channel(channel_id=placement.channel_id)
placement.channel = channel
return _build_placement_output(placement)

View File

@@ -2,7 +2,8 @@ import logging
from typing import TYPE_CHECKING
from src import domain, dto
from .create_purchase import _build_purchase_output
from .create_placements import _build_placement_output
if TYPE_CHECKING:
from .. import Usecase
@@ -10,7 +11,8 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput:
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
"""Get all placements for a project (formerly get_purchases)"""
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
@@ -21,10 +23,7 @@ async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.Purc
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)
placements = await self.database.get_project_placements(input.workspace_id, project.id)
log.debug('Fetched %s placements for project %s', len(placements), project.id)
channels = await self.database.get_purchase_channels(purchase.id)
return _build_purchase_output(purchase, channels)
return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])

View File

@@ -1,32 +0,0 @@
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

@@ -17,9 +17,13 @@ async def handle_subscription(
first_name: str | None = None,
last_name: str | None = None,
) -> None:
placement = await self.database.get_placement_by_invite_link(invite_link)
if not placement:
log.warning('Placement not found for invite_link: %s', invite_link)
publication = await self.database.get_publication_by_invite_link(invite_link)
if not publication:
log.warning('Publication not found for invite_link: %s', invite_link)
return
if not publication.placement:
log.error('Publication %s has no placement', publication.id)
return
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
@@ -38,26 +42,26 @@ async def handle_subscription(
await self.database.update_telegram_user(subscriber)
active_subscription = await self.database.get_active_subscription_by_subscriber_and_project(
subscriber.id, placement.project_id
subscriber.id, publication.placement.project_id
)
if active_subscription:
# Пользователь уже подписан на канал через другой placement
# Пользователь уже подписан на канал через другую публикацию
# Это не должно случиться (Telegram не даст подписаться дважды),
# но если случилось - логируем и игнорируем
log.warning(
'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, '
'ignoring new subscription attempt via placement %s',
'User %s (telegram_id: %s) already has active subscription to channel %s via publication %s, '
'ignoring new subscription attempt via publication %s',
subscriber.id,
user_telegram_id,
placement.project_id,
active_subscription.placement_id,
placement.id,
publication.placement.project_id,
active_subscription.publication_id,
publication.id,
)
return
# Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации)
existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id)
# Проверяем, была ли раньше подписка через ЭТУ публикацию (для реактивации)
existing_sub = await self.database.get_subscription_by_subscriber_and_publication(subscriber.id, publication.id)
if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
existing_sub.status = domain.SubscriptionStatus.ACTIVE
@@ -65,24 +69,24 @@ async def handle_subscription(
await self.database.update_subscription(existing_sub)
log.info(
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s',
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same publication %s',
subscriber.id,
user_telegram_id,
placement.id,
publication.id,
)
return
subscription = domain.Subscription(
placement_id=placement.id,
publication_id=publication.id,
telegram_user_id=subscriber.id,
invite_link=invite_link,
)
await self.database.create_subscription(subscription)
log.info(
'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)',
'Subscription created: subscriber %s (telegram_id: %s) subscribed via publication %s (invite_link: %s)',
subscriber.id,
user_telegram_id,
placement.id,
publication.id,
invite_link,
)

View File

@@ -39,8 +39,8 @@ async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_
await self.database.update_subscription(subscription)
log.info(
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s',
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from publication %s',
subscriber.id,
user_telegram_id,
subscription.placement_id,
subscription.publication_id,
)

View File

@@ -14,18 +14,22 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
if not placement:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
raise domain.PlacementNotFound(input.placement_id)
publication = await self.database.get_publication(input.workspace_id, input.publication_id)
if not publication:
log.warning('Publication %s not found for user %s', input.publication_id, input.user_id)
raise domain.PublicationNotFound(input.publication_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
if not publication.placement:
log.error('Publication %s has no placement', publication.id)
raise domain.PlacementNotFound(publication.placement_id)
if not placement.post:
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, publication.placement.project_id)
if not publication.post:
return []
histories = await self.database.get_views_history(
placement.post.id,
publication.post.id,
from_date=input.from_date,
to_date=input.to_date,
)