This commit is contained in:
Artem Tsyrulnikov
2026-01-18 13:35:46 +03:00
parent c304ba4ec7
commit 68e7dc4158
44 changed files with 383 additions and 387 deletions

View File

@@ -392,9 +392,9 @@ class Postgres(DatabaseBase):
)
@staticmethod
async def get_publication(workspace_id: uuid.UUID, publication_id: uuid.UUID) -> domain.Publication | None:
return await domain.Publication.get_or_none(
id=publication_id, placement__project__workspace_id=workspace_id
async def get_placement_post(workspace_id: uuid.UUID, placement_post_id: uuid.UUID) -> domain.PlacementPost | None:
return await domain.PlacementPost.get_or_none(
id=placement_post_id, placement__project__workspace_id=workspace_id
).prefetch_related(
'placement',
'placement__project',
@@ -406,7 +406,7 @@ class Postgres(DatabaseBase):
)
@staticmethod
async def get_workspace_publications(
async def get_workspace_placement_posts(
workspace_id: uuid.UUID,
project_id: uuid.UUID | None = None,
placement_channel_id: uuid.UUID | None = None,
@@ -415,8 +415,8 @@ class Postgres(DatabaseBase):
allowed_project_ids: set[uuid.UUID] | None = None,
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.Publication]:
query = domain.Publication.filter(placement__project__workspace_id=workspace_id)
) -> list[domain.PlacementPost]:
query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id)
if project_id:
query = query.filter(placement__project_id=project_id)
@@ -435,7 +435,7 @@ class Postgres(DatabaseBase):
query = query.filter(created_at__lte=date_to)
if not include_archived:
query = query.filter(status=domain.PublicationStatus.ACTIVE)
query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
return (
await query.prefetch_related(
@@ -452,12 +452,12 @@ class Postgres(DatabaseBase):
)
@staticmethod
async def create_publication(publication: domain.Publication) -> None:
await publication.save()
async def create_placement_post(placement_post: domain.PlacementPost) -> None:
await placement_post.save()
@staticmethod
async def get_publication_by_invite_link(invite_link: str) -> domain.Publication | None:
return await domain.Publication.get_or_none(placement__invite_link=invite_link).prefetch_related(
async def get_placement_post_by_invite_link(invite_link: str) -> domain.PlacementPost | None:
return await domain.PlacementPost.get_or_none(placement__invite_link=invite_link).prefetch_related(
'placement',
'placement__project',
'placement__project__channel',
@@ -471,26 +471,28 @@ class Postgres(DatabaseBase):
await subscription.save()
@staticmethod
async def get_subscription_by_subscriber_and_publication(
telegram_user_id: uuid.UUID, publication_id: uuid.UUID
async def get_subscription_by_subscriber_and_placement_post(
telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID
) -> domain.Subscription | None:
return await domain.Subscription.get_or_none(telegram_user_id=telegram_user_id, publication_id=publication_id)
return await domain.Subscription.get_or_none(
telegram_user_id=telegram_user_id, placement_post_id=placement_post_id
)
@staticmethod
async def update_subscription(subscription: domain.Subscription) -> None:
await subscription.save()
@staticmethod
async def get_subscriptions_for_publications(
publication_ids: list[uuid.UUID],
async def get_subscriptions_for_placement_posts(
placement_post_ids: list[uuid.UUID],
*,
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.Subscription]:
if not publication_ids:
if not placement_post_ids:
return []
query = domain.Subscription.filter(publication_id__in=publication_ids)
query = domain.Subscription.filter(placement_post_id__in=placement_post_ids)
if date_from:
query = query.filter(created_at__gte=date_from)
@@ -506,10 +508,10 @@ class Postgres(DatabaseBase):
return (
await domain.Subscription.filter(
telegram_user_id=telegram_user_id,
publication__placement__project_id=project_id,
placement_post__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE,
)
.prefetch_related('publication', 'telegram_user')
.prefetch_related('placement_post', 'telegram_user')
.all()
)
@@ -520,10 +522,10 @@ class Postgres(DatabaseBase):
return (
await domain.Subscription.filter(
telegram_user_id=telegram_user_id,
publication__placement__project_id=project_id,
placement_post__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE,
)
.prefetch_related('publication', 'telegram_user')
.prefetch_related('placement_post', 'telegram_user')
.first()
)
@@ -555,22 +557,22 @@ class Postgres(DatabaseBase):
# Count methods
@staticmethod
async def count_publications_by_creative(creative_id: uuid.UUID) -> int:
return await domain.Publication.filter(placement__creative_id=creative_id).count()
async def count_placement_posts_by_creative(creative_id: uuid.UUID) -> int:
return await domain.PlacementPost.filter(placement__creative_id=creative_id).count()
@staticmethod
async def count_subscriptions_by_publication(publication_id: uuid.UUID) -> int:
return await domain.Subscription.filter(publication_id=publication_id).count()
async def count_subscriptions_by_placement_post(placement_post_id: uuid.UUID) -> int:
return await domain.Subscription.filter(placement_post_id=placement_post_id).count()
@staticmethod
async def count_publications_by_creative_batch(creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
async def count_placement_posts_by_creative_batch(creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not creative_ids:
return {}
from tortoise.functions import Count
results = (
await domain.Publication.filter(placement__creative_id__in=creative_ids)
await domain.PlacementPost.filter(placement__creative_id__in=creative_ids)
.group_by('placement__creative_id')
.annotate(count=Count('id'))
.values('placement__creative_id', 'count')
@@ -580,22 +582,22 @@ class Postgres(DatabaseBase):
return {cid: counts.get(cid, 0) for cid in creative_ids}
@staticmethod
async def count_subscriptions_by_publication_batch(publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not publication_ids:
async def count_subscriptions_by_placement_post_batch(placement_post_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not placement_post_ids:
return {}
from tortoise.functions import Count
results = (
await domain.Subscription.filter(publication_id__in=publication_ids)
.group_by('publication_id')
await domain.Subscription.filter(placement_post_id__in=placement_post_ids)
.group_by('placement_post_id')
.annotate(count=Count('id'))
.values('publication_id', 'count')
.values('placement_post_id', 'count')
)
counts = {row['publication_id']: row['count'] for row in results}
return {pid: counts.get(pid, 0) for pid in publication_ids}
counts = {row['placement_post_id']: row['count'] for row in results}
return {pid: counts.get(pid, 0) for pid in placement_post_ids}
@staticmethod
async def has_publications_for_creative(creative_id: uuid.UUID) -> bool:
return await domain.Publication.filter(placement__creative_id=creative_id).exists()
async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool:
return await domain.PlacementPost.filter(placement__creative_id=creative_id).exists()

View File

@@ -5,7 +5,7 @@ from src.controller.http_v1.auth import auth_router
from src.controller.http_v1.channels import channels_router
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 publications_router
from src.controller.http_v1.placements import placement_posts_router
from src.controller.http_v1.projects import projects_router
from src.controller.http_v1.purchases import placements_user_router
from src.controller.http_v1.views import views_router
@@ -23,7 +23,7 @@ api_v1_router.include_router(channels_router)
api_v1_router.include_router(projects_router)
api_v1_router.include_router(placements_user_router) # User-managed placements
api_v1_router.include_router(creatives_router)
api_v1_router.include_router(publications_router) # System-managed publications
api_v1_router.include_router(placement_posts_router) # System-managed placement_posts
api_v1_router.include_router(views_router)
api_v1_router.include_router(analytics_router)
api_v1_router.include_router(workspaces_router)

View File

@@ -7,21 +7,21 @@ from fastapi.routing import APIRouter
from src import deps, dto
from src.adapter.jwt import JWTPayload
# Publications router - System-managed actual published posts
publications_router = APIRouter(prefix='/workspaces/{workspace_id}/publications', tags=['publications'])
# PlacementPosts router - System-managed actual published posts
placement_posts_router = APIRouter(prefix='/workspaces/{workspace_id}/placement_posts', tags=['placement_posts'])
@publications_router.get('')
async def list_publications(
@placement_posts_router.get('')
async def list_placement_posts(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None,
placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None,
include_archived: bool = False,
) -> dto.GetPublicationsOutput:
"""List all publications (system-managed actual posts) in workspace"""
input = dto.GetPublicationsInput(
) -> dto.GetPlacementPostsOutput:
"""List all placement_posts (system-managed actual posts) in workspace"""
input = dto.GetPlacementPostsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
@@ -30,24 +30,24 @@ async def list_publications(
include_archived=include_archived,
)
result = await deps.get_usecase().get_publications(input=input)
result = await deps.get_usecase().get_placement_posts(input=input)
return result
@publications_router.get('/{publication_id}')
async def get_publication(
publication_id: uuid.UUID,
@placement_posts_router.get('/{placement_post_id}')
async def get_placement_post(
placement_post_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PublicationOutput:
"""Get single publication by ID"""
input = dto.GetPublicationInput(
publication_id=publication_id,
) -> dto.PlacementPostOutput:
"""Get single placement_post by ID"""
input = dto.GetPlacementPostInput(
placement_post_id=placement_post_id,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
return await deps.get_usecase().get_publication(input=input)
return await deps.get_usecase().get_placement_post(input=input)
# Publication creation/updates are handled by worker; API exposes only read endpoints.
# PlacementPost creation/updates are handled by worker; API exposes only read endpoints.

View File

@@ -21,7 +21,7 @@ async def get_views_history(
to_date: datetime.datetime | None = None,
) -> Page[dto.PostViewsHistoryOutput]:
input_data = dto.GetViewsHistoryInput(
publication_id=placement_id,
placement_post_id=placement_id,
user_id=current_user.user_id,
workspace_id=workspace_id,
from_date=from_date,

View File

@@ -10,8 +10,8 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
class FetchPublicationPostWorker(WorkerBase):
"""Worker that fetches posts from channels and creates Publications from Placements"""
class FetchPlacementPostWorker(WorkerBase):
"""Worker that fetches posts from channels and creates PlacementPosts from Placements"""
async def _cycle_func(self) -> None:
await deps.get_usecase().fetch_publication_post_cycle(self.config.INTERVAL_SECONDS)
await deps.get_usecase().fetch_placement_post_cycle(self.config.INTERVAL_SECONDS)

View File

@@ -19,8 +19,8 @@ __all__ = (
'Placement',
'PlacementStatus',
'PlacementType',
'Publication',
'PublicationStatus',
'PlacementPost',
'PlacementPostStatus',
'Creative',
'replace_invite_link_with_tag',
'validate_media_size',
@@ -49,7 +49,7 @@ __all__ = (
'ProjectNotFound',
'ProjectChannelConflict',
'PlacementNotFound',
'PublicationNotFound',
'PlacementPostNotFound',
'ChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
@@ -83,9 +83,9 @@ from .error import (
LoginTokenExpired,
LoginTokenNotFound,
PlacementNotFound,
PlacementPostNotFound,
ProjectChannelConflict,
ProjectNotFound,
PublicationNotFound,
TelegramChannelNotFound,
UserByUsernameNotFound,
UserNotFound,
@@ -104,10 +104,10 @@ from .placement import (
PlacementStatus,
PlacementType,
)
from .placement_post import PlacementPost, PlacementPostStatus, PostViewsAvailability
from .post import Post
from .post_views_history import PostViewsHistory
from .project import Project, ProjectStatus
from .publication import PostViewsAvailability, Publication, PublicationStatus
from .subscription import Subscription, SubscriptionStatus
from .telegram_user import TelegramUser
from .user import User

View File

@@ -53,10 +53,10 @@ def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found')
def PublicationNotFound(publication_id: uuid.UUID | None = None) -> HTTPException:
if publication_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Publication not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Publication {publication_id} not found')
def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPException:
if placement_post_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'PlacementPost not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
@@ -90,7 +90,7 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException(
status.HTTP_400_BAD_REQUEST,
f'Creative {creative_id} is used in active publications and cannot be deleted',
f'Creative {creative_id} is used in active placement_posts and cannot be deleted',
)

View File

@@ -12,10 +12,10 @@ if TYPE_CHECKING:
from .post import Post
from .project import Project
__all__ = ['Publication', 'PublicationStatus', 'PostViewsAvailability']
__all__ = ['PlacementPost', 'PlacementPostStatus', 'PostViewsAvailability']
class PublicationStatus(str, enum.Enum):
class PlacementPostStatus(str, enum.Enum):
ACTIVE = 'active' # Пост найден и отслеживается
ARCHIVED = 'archived' # Вручную архивировано
@@ -27,25 +27,25 @@ class PostViewsAvailability(str, enum.Enum):
MANUAL = 'manual' # Просмотры вводятся вручную
class Publication(TimestampedModel):
class PlacementPost(TimestampedModel):
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
status = fields.CharEnumField(PublicationStatus, default=PublicationStatus.ACTIVE)
status = fields.CharEnumField(PlacementPostStatus, default=PlacementPostStatus.ACTIVE)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='publications', on_delete=fields.CASCADE, index=True
'models.Project', related_name='placement_posts', on_delete=fields.CASCADE, index=True
)
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='publications', on_delete=fields.CASCADE, index=True
'models.Creative', related_name='placement_posts', on_delete=fields.CASCADE, index=True
)
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
'models.Placement', related_name='publications', on_delete=fields.CASCADE, index=True
'models.Placement', related_name='placement_posts', on_delete=fields.CASCADE, index=True
)
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
'models.Post', related_name='publications', on_delete=fields.SET_NULL, null=True, index=True
'models.Post', related_name='placement_posts', on_delete=fields.SET_NULL, null=True, index=True
)
if TYPE_CHECKING:
@@ -55,4 +55,4 @@ class Publication(TimestampedModel):
placement_id: UUID
class Meta:
table = 'publication'
table = 'placement_post'

View File

@@ -7,7 +7,7 @@ from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .publication import Publication
from .placement_post import PlacementPost
from .telegram_user import TelegramUser
@@ -21,17 +21,17 @@ class Subscription(TimestampedModel):
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
unsubscribed_at = fields.DatetimeField(null=True)
publication: fields.ForeignKeyRelation['Publication'] = fields.ForeignKeyField(
'models.Publication', related_name='subscriptions', on_delete=fields.CASCADE, index=True
placement_post: fields.ForeignKeyRelation['PlacementPost'] = fields.ForeignKeyField(
'models.PlacementPost', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField(
'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
publication_id: UUID
placement_post_id: UUID
telegram_user_id: UUID
class Meta:
table = 'subscription'
unique_together = (('publication_id', 'telegram_user_id'),)
unique_together = (('placement_post_id', 'telegram_user_id'),)

View File

@@ -25,10 +25,10 @@ __all__ = (
'GetPlacementsInput',
'GetPlacementsOutput',
'GetPlacementInput',
'PublicationOutput',
'GetPublicationsInput',
'GetPublicationsOutput',
'GetPublicationInput',
'PlacementPostOutput',
'GetPlacementPostsInput',
'GetPlacementPostsOutput',
'GetPlacementPostInput',
'CreativeButton',
'CreativeOutput',
'GetCreativesInput',
@@ -132,10 +132,10 @@ from .creative import (
UpdateCreativeInput,
)
from .placement import (
GetPublicationInput,
GetPublicationsInput,
GetPublicationsOutput,
PublicationOutput,
GetPlacementPostInput,
GetPlacementPostsInput,
GetPlacementPostsOutput,
PlacementPostOutput,
)
from .project import (
ArchiveProjectInput,

View File

@@ -6,7 +6,7 @@ import pydantic
from src import domain
class PublicationOutput(pydantic.BaseModel):
class PlacementPostOutput(pydantic.BaseModel):
id: uuid.UUID
project_id: uuid.UUID
project_channel_title: str | None
@@ -18,15 +18,15 @@ class PublicationOutput(pydantic.BaseModel):
cost: float | None
comment: str | None
ad_post_url: str | None
invite_link_type: domain.purchase.InviteLinkType
invite_link_type: domain.InviteLinkType
invite_link: str
status: domain.PublicationStatus
status: domain.PlacementPostStatus
subscriptions_count: int
placement_id: uuid.UUID # Ссылка на Placement
created_at: datetime.datetime
class GetPublicationsInput(pydantic.BaseModel):
class GetPlacementPostsInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID | None = None
@@ -35,11 +35,11 @@ class GetPublicationsInput(pydantic.BaseModel):
include_archived: bool = False
class GetPublicationsOutput(pydantic.BaseModel):
publications: list[PublicationOutput]
class GetPlacementPostsOutput(pydantic.BaseModel):
placement_posts: list[PlacementPostOutput]
class GetPublicationInput(pydantic.BaseModel):
publication_id: uuid.UUID
class GetPlacementPostInput(pydantic.BaseModel):
placement_post_id: uuid.UUID
user_id: uuid.UUID
workspace_id: uuid.UUID

View File

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

View File

@@ -17,7 +17,7 @@ class PostViewsHistoryOutput(pydantic.BaseModel):
class GetViewsHistoryInput(pydantic.BaseModel):
"""Получить историю просмотров для публикации."""
publication_id: uuid.UUID
placement_post_id: uuid.UUID
user_id: uuid.UUID
workspace_id: uuid.UUID
from_date: datetime.datetime | None = None # Фильтр: с какой даты

View File

@@ -63,7 +63,7 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
# Определяем тип и ID scope на основе заполненных полей
scope_type = None
scope_id = None
if scope.project_id:
scope_type = domain.PermissionScopeType.PROJECT
scope_id = scope.project_id
@@ -76,7 +76,7 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
elif scope.channel_id:
scope_type = domain.PermissionScopeType.CHANNEL
scope_id = scope.channel_id
if scope_type and scope_id:
permissions_map.setdefault(scope.permission, []).append(
WorkspacePermissionScopeOutput(type=scope_type, id=scope_id)

View File

@@ -16,7 +16,7 @@ from src.adapter.s3 import S3
from src.adapter.telegram_bot import TelegramBot
from src.config import settings
from src.controller.http_v1 import api_router
from src.controller.worker.fetch_placement_post import FetchPublicationPostWorker
from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker
from src.usecase import Usecase
@@ -24,11 +24,11 @@ from src.usecase import Usecase
async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
await postgres.connect()
await s3.connect()
await fetch_publication_post_worker.start()
await fetch_placement_post_worker.start()
yield
await fetch_publication_post_worker.stop()
await fetch_placement_post_worker.stop()
await s3.close()
await postgres.close()
@@ -51,7 +51,7 @@ usecase = Usecase(
)
deps.set_usecase(usecase)
fetch_publication_post_worker = FetchPublicationPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5))
fetch_placement_post_worker = FetchPlacementPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5))
app = FastAPI(
lifespan=lifespan,

View File

@@ -27,9 +27,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_publication_post_cycle import fetch_publication_post_cycle
from .placement.get_publication import get_publication
from .placement.get_publications import get_publications
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
from .placement.get_placement_post import get_placement_post
from .placement.get_placement_posts import get_placement_posts
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
@@ -171,10 +171,10 @@ class Usecase:
create_creative = create_creative
update_creative = update_creative
delete_creative = delete_creative
# Publication (system-managed) use cases
get_publications = get_publications
get_publication = get_publication
fetch_publication_post_cycle = fetch_publication_post_cycle
# PlacementPost (system-managed) use cases
get_placement_posts = get_placement_posts
get_placement_post = get_placement_post
fetch_placement_post_cycle = fetch_placement_post_cycle
handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription
get_views_history = get_views_history

View File

@@ -29,7 +29,7 @@ async def get_channel_analytics(
else:
allowed_project_filter = allowed_project_ids
placements = await self.database.get_workspace_publications(
placements = await self.database.get_workspace_placement_posts(
input.workspace_id,
input.project_id,
include_archived=False,
@@ -38,8 +38,8 @@ async def get_channel_analytics(
# Collect unique channels from placements
channel_map: dict[UUID, domain.Channel] = {}
for publication in placements:
placement = publication.placement
for placement_post in placements:
placement = placement_post.placement
if placement and placement.channel:
channel_map[placement.channel_id] = placement.channel
channels = list(channel_map.values())
@@ -59,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_publication_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
for publication in placements:
placement = publication.placement
for placement_post in placements:
placement = placement_post.placement
if not placement:
continue
stats = channel_stats.get(placement.channel_id)
if stats is None:
continue
stats.total_cost += publication.cost if publication.cost is not None else 0
stats.total_subscriptions += subscriptions_counts.get(publication.id, 0)
stats.total_cost += placement_post.cost if placement_post.cost is not None else 0
stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
# Get views from batch data
if publication.post and publication.post.id in views_map:
views_count = views_map[publication.post.id][0]
if placement_post.post and placement_post.post.id in views_map:
views_count = views_map[placement_post.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_publications(
placements = await self.database.get_workspace_placement_posts(
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_publication_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_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_publications(
placements = await self.database.get_workspace_placement_posts(
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.Publication] = []
previous_placements: list[domain.Publication] = []
current_placements: list[domain.PlacementPost] = []
previous_placements: list[domain.PlacementPost] = []
for publication in placements:
placement_date = publication.wanted_placement_date
for placement_post in placements:
placement_date = placement_post.wanted_placement_date
if placement_date >= input.date_from and placement_date <= input.date_to:
current_placements.append(publication)
current_placements.append(placement_post)
elif placement_date >= previous_period_start and placement_date < input.date_from:
previous_placements.append(publication)
previous_placements.append(placement_post)
placement_ids = [p.id for p in placements]
subscriptions = await self.database.get_subscriptions_for_publications(
subscriptions = await self.database.get_subscriptions_for_placement_posts(
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.publication_id] += 1
subs_per_placement_current[sub.placement_post_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.publication_id] += 1
subs_per_placement_previous[sub.placement_post_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.Publication], subs_per_placement: dict[UUID, int]
placement_list: list[domain.PlacementPost], subs_per_placement: dict[UUID, int]
) -> tuple[float, int, int]:
total_cost = 0.0
total_subscriptions = 0
total_views = 0
for publication in placement_list:
if publication.cost is not None:
total_cost += publication.cost
for placement_post in placement_list:
if placement_post.cost is not None:
total_cost += placement_post.cost
subs = subs_per_placement.get(publication.id, 0)
subs = subs_per_placement.get(placement_post.id, 0)
total_subscriptions += subs
if publication.post and publication.post.id in views_map:
total_views += views_map[publication.post.id][0]
if placement_post.post and placement_post.post.id in views_map:
total_views += views_map[placement_post.post.id][0]
return total_cost, total_subscriptions, total_views
@@ -130,16 +130,16 @@ async def get_overview_analytics(
project_spending_map: dict[UUID, float] = defaultdict(float)
project_meta: dict[UUID, domain.Project] = {}
for publication in current_placements:
placement_day = publication.wanted_placement_date.date()
project_meta[publication.project_id] = publication.project
for placement_post in current_placements:
placement_day = placement_post.wanted_placement_date.date()
project_meta[placement_post.project_id] = placement_post.project
cost_value = publication.cost or 0.0
cost_value = placement_post.cost or 0.0
cost_per_day[placement_day] += cost_value
project_spending_map[publication.project_id] += cost_value
subs = subs_per_placement_current.get(publication.id, 0)
project_spending_map[placement_post.project_id] += cost_value
subs = subs_per_placement_current.get(placement_post.id, 0)
placement = publication.placement
placement = placement_post.placement
if placement:
channel_id = placement.channel_id
if channel_id not in channel_stats:

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_publications(
placements = await self.database.get_workspace_placement_posts(
input.workspace_id,
input.project_id,
include_archived=False,
@@ -50,7 +50,7 @@ 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_publication_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
return [
dto.PlacementAnalyticsOutput(

View File

@@ -47,7 +47,7 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
if week_start.month == week_end.month:
return f'{week_start.day}-{week_end.day} {month_names[week_start.month - 1]}'
else:
return f'{week_start.day} {month_names[week_start.month - 1]}-{week_end.day} {month_names[week_end.month - 1]}'
return f'{week_start.day} {month_names[week_start.month - 1]}-{week_end.day} {month_names[week_end.month - 1]}' # noqa: E501
case dto.DateGrouping.MONTH:
# "дек 2024"
month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
@@ -60,20 +60,20 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
raise ValueError('Invalid date grouping')
def _get_grouping_date(publication: domain.Publication, date_grouping: dto.DateGroupingType) -> datetime.datetime:
def _get_grouping_date(placement_post: domain.PlacementPost, date_grouping: dto.DateGroupingType) -> datetime.datetime:
match date_grouping:
case dto.DateGroupingType.PLACEMENT_DATE:
return publication.wanted_placement_date
return placement_post.wanted_placement_date
case dto.DateGroupingType.PURCHASE_DATE:
if publication.placement:
return publication.placement.created_at
return publication.wanted_placement_date # Fallback
if placement_post.placement:
return placement_post.placement.created_at
return placement_post.wanted_placement_date # Fallback
case dto.DateGroupingType.LINK_DATE:
if publication.placement:
return publication.placement.created_at
return publication.wanted_placement_date # Fallback
if placement_post.placement:
return placement_post.placement.created_at
return placement_post.wanted_placement_date # Fallback
case _:
return publication.wanted_placement_date
return placement_post.wanted_placement_date
@dataclass
@@ -183,7 +183,7 @@ async def get_projects_analytics(
else:
allowed_project_ids = set(input.project_ids)
placements = await self.database.get_workspace_publications(
placements = await self.database.get_workspace_placement_posts(
input.workspace_id,
project_id=None,
include_archived=False,
@@ -194,28 +194,28 @@ async def get_projects_analytics(
# Фильтрация по датам на основе date_grouping
filtered_placements = []
for publication in placements:
grouping_date = _get_grouping_date(publication, input.date_grouping)
for placement_post in placements:
grouping_date = _get_grouping_date(placement_post, 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(publication)
filtered_placements.append(placement_post)
# 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
publication_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids)
placement_post_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
# Группировка по периодам
period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
total_metrics = PeriodMetrics()
for publication in filtered_placements:
grouping_date = _get_grouping_date(publication, input.date_grouping)
for placement_post in filtered_placements:
grouping_date = _get_grouping_date(placement_post, input.date_grouping)
period = _format_period(grouping_date, input.grouping)
pd = period_data[period]
@@ -223,25 +223,25 @@ async def get_projects_analytics(
pd.purchases_count += 1
total_metrics.purchases_count += 1
cost = publication.cost or 0.0
cost = placement_post.cost or 0.0
pd.total_cost += cost
total_metrics.total_cost += cost
subs_count = subscriptions_counts.get(publication.id, 0)
subs_count = subscriptions_counts.get(placement_post.id, 0)
pd.total_subscriptions += subs_count
pd.clicks_count += subs_count
total_metrics.total_subscriptions += subs_count
total_metrics.clicks_count += subs_count
if publication.post and publication.post.id in views_map:
views_count = views_map[publication.post.id][0]
if placement_post.post and placement_post.post.id in views_map:
views_count = views_map[placement_post.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
# Расчет скидок
placement = publication.placement
placement = placement_post.placement
if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
discount = placement.cost_before_bargain - cost
discount_percent = (

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_publications(
placements = await self.database.get_workspace_placement_posts(
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_publication_batch(placement_ids)
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_ids)
# Группировка по периодам
period_data: dict[str, PeriodData] = defaultdict(PeriodData)

View File

@@ -11,9 +11,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def attach_channel_to_workspace(
self: 'Usecase', input: dto.AttachChannelToWorkspaceInput
) -> dto.ProjectOutput:
async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelToWorkspaceInput) -> dto.ProjectOutput:
"""Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)"""
channel = await self.database.get_channel(channel_id=input.channel_id)

View File

@@ -19,4 +19,4 @@ async def get_channel(self: 'Usecase', input: dto.GetChannelInput) -> dto.Channe
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
)
)

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_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)
has_placement_posts = await self.database.has_placement_posts_for_creative(creative.id)
if has_placement_posts:
log.warning('Creative %s is used in placement_posts 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_publications_by_creative(creative.id)
placements_count = await self.database.count_placement_posts_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_publications_by_creative_batch(creative_ids)
placements_counts = await self.database.count_placement_posts_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_publications_by_creative(creative.id)
placements_count = await self.database.count_placement_posts_by_creative(creative.id)
return dto.CreativeOutput(
id=creative.id,

View File

@@ -9,15 +9,13 @@ if TYPE_CHECKING:
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')
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
"""Находит посты в каналах по invite_link из Placement и создает PlacementPost"""
log.debug('Starting fetch_placement_post_cycle')
# Получаем все approved Placements с invite_link
placements = (
await domain.Placement.filter(
status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]
)
await domain.Placement.filter(status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS])
.prefetch_related('channel', 'project')
.all()
)
@@ -42,23 +40,23 @@ async def fetch_publication_post_cycle(self: 'Usecase', interval_seconds: int) -
for post in posts:
# Проверяем, не создана ли уже публикация для этого поста
existing = await domain.Publication.filter(post_id=post.id).first()
existing = await domain.PlacementPost.filter(post_id=post.id).first()
if existing:
continue
publication = domain.Publication(
placement_post = domain.PlacementPost(
placement_id=placement.id,
post_id=post.id,
status=domain.PublicationStatus.ACTIVE,
status=domain.PlacementPostStatus.ACTIVE,
)
await self.database.create_publication(publication)
await self.database.create_placement_post(placement_post)
created_count += 1
log.info(
'Created publication %s for placement %s from post %s',
publication.id,
'Created placement_post %s for placement %s from post %s',
placement_post.id,
placement.id,
post.id,
)
log.debug('Fetch publication post cycle completed. Created %s publications', created_count)
log.debug('Fetch placement_post post cycle completed. Created %s placement_posts', created_count)

View File

@@ -9,47 +9,47 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def get_publication(self: 'Usecase', input: dto.GetPublicationInput) -> dto.PublicationOutput:
async def get_placement_post(self: 'Usecase', input: dto.GetPlacementPostInput) -> dto.PlacementPostOutput:
"""Получить одну публикацию по 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_post = await self.database.get_placement_post(input.workspace_id, input.placement_post_id)
if not placement_post:
log.warning('PlacementPost %s not found for user %s', input.placement_post_id, input.user_id)
raise domain.PlacementPostNotFound(input.placement_post_id)
placement = publication.placement
placement = placement_post.placement
if not placement:
log.error('Publication %s missing placement data', publication.id)
raise domain.PlacementNotFound(publication.placement_id)
log.error('PlacementPost %s missing placement data', placement_post.id)
raise domain.PlacementNotFound(placement_post.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)
log.error('PlacementPost %s placement missing related data', placement_post.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)
ad_post_url = placement_post.post.url if placement_post.post else None
subscriptions_count = await self.database.count_subscriptions_by_placement_post(placement_post.id)
return dto.PublicationOutput(
id=publication.id,
return dto.PlacementPostOutput(
id=placement_post.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,
placement_date=placement.placement_at or placement_post.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,
status=placement_post.status,
subscriptions_count=subscriptions_count,
placement_id=placement.id,
created_at=publication.created_at,
created_at=placement_post.created_at,
)

View File

@@ -9,7 +9,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
async def get_publications(self: 'Usecase', input: dto.GetPublicationsInput) -> dto.GetPublicationsOutput:
async def get_placement_posts(self: 'Usecase', input: dto.GetPlacementPostsInput) -> dto.GetPlacementPostsOutput:
"""Получить список публикаций (system-managed posts)"""
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
@@ -21,7 +21,7 @@ async def get_publications(self: 'Usecase', input: dto.GetPublicationsInput) ->
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
allowed_project_ids = None
publications = await self.database.get_workspace_publications(
placement_posts = await self.database.get_workspace_placement_posts(
input.workspace_id,
project_id=input.project_id,
placement_channel_id=input.placement_channel_id,
@@ -30,42 +30,42 @@ async def get_publications(self: 'Usecase', input: dto.GetPublicationsInput) ->
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)
placement_post_ids = [p.id for p in placement_posts]
subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
publication_outputs = []
for publication in publications:
placement = publication.placement
placement_post_outputs = []
for placement_post in placement_posts:
placement = placement_post.placement
if not placement:
log.warning('Publication %s missing placement data', publication.id)
log.warning('PlacementPost %s missing placement data', placement_post.id)
continue
if not placement.channel or not placement.project or not placement.creative:
log.warning('Publication %s placement missing related data', publication.id)
log.warning('PlacementPost %s placement missing related data', placement_post.id)
continue
ad_post_url = publication.post.url if publication.post else None
ad_post_url = placement_post.post.url if placement_post.post else None
publication_outputs.append(
dto.PublicationOutput(
id=publication.id,
placement_post_outputs.append(
dto.PlacementPostOutput(
id=placement_post.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,
placement_date=placement.placement_at or placement_post.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),
status=placement_post.status,
subscriptions_count=subscriptions_counts.get(placement_post.id, 0),
placement_id=placement.id,
created_at=publication.created_at,
created_at=placement_post.created_at,
)
)
return dto.GetPublicationsOutput(publications=publication_outputs)
return dto.GetPlacementPostsOutput(placement_posts=placement_post_outputs)

View File

@@ -28,4 +28,3 @@ async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dt
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
)

View File

@@ -14,4 +14,3 @@ async def delete_project(self: 'Usecase', workspace_id: uuid.UUID, project_id: u
async with self.database.transaction():
await self.database.delete_project(workspace_id, project_id)

View File

@@ -14,7 +14,7 @@ async def update_project_invite_link_type(
self: 'Usecase',
workspace_id: uuid.UUID,
project_id: uuid.UUID,
purchase_invite_type_default: domain.purchase.InviteLinkType,
purchase_invite_type_default: domain.InviteLinkType,
user_id: uuid.UUID,
) -> dto.ProjectOutput:
await self.ensure_workspace_permission(

View File

@@ -117,19 +117,15 @@ async def create_placements(
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
),
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
),
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),
)

View File

@@ -17,13 +17,13 @@ async def handle_subscription(
first_name: str | None = None,
last_name: str | None = None,
) -> None:
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)
placement_post = await self.database.get_placement_post_by_invite_link(invite_link)
if not placement_post:
log.warning('PlacementPost not found for invite_link: %s', invite_link)
return
if not publication.placement:
log.error('Publication %s has no placement', publication.id)
if not placement_post.placement:
log.error('PlacementPost %s has no placement', placement_post.id)
return
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id)
@@ -42,7 +42,7 @@ 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, publication.placement.project_id
subscriber.id, placement_post.placement.project_id
)
if active_subscription:
@@ -50,18 +50,20 @@ async def handle_subscription(
# Это не должно случиться (Telegram не даст подписаться дважды),
# но если случилось - логируем и игнорируем
log.warning(
'User %s (telegram_id: %s) already has active subscription to channel %s via publication %s, '
'ignoring new subscription attempt via publication %s',
'User %s (telegram_id: %s) already has active subscription to channel %s via placement_post %s, '
'ignoring new subscription attempt via placement_post %s',
subscriber.id,
user_telegram_id,
publication.placement.project_id,
active_subscription.publication_id,
publication.id,
placement_post.placement.project_id,
active_subscription.placement_post_id,
placement_post.id,
)
return
# Проверяем, была ли раньше подписка через ЭТУ публикацию (для реактивации)
existing_sub = await self.database.get_subscription_by_subscriber_and_publication(subscriber.id, publication.id)
existing_sub = await self.database.get_subscription_by_subscriber_and_placement_post(
subscriber.id, placement_post.id
)
if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
existing_sub.status = domain.SubscriptionStatus.ACTIVE
@@ -69,24 +71,24 @@ async def handle_subscription(
await self.database.update_subscription(existing_sub)
log.info(
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same publication %s',
'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement_post %s',
subscriber.id,
user_telegram_id,
publication.id,
placement_post.id,
)
return
subscription = domain.Subscription(
publication_id=publication.id,
placement_post_id=placement_post.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 publication %s (invite_link: %s)',
'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement_post %s (invite_link: %s)',
subscriber.id,
user_telegram_id,
publication.id,
placement_post.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 publication %s',
'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement_post %s',
subscriber.id,
user_telegram_id,
subscription.publication_id,
subscription.placement_post_id,
)

View File

@@ -14,22 +14,22 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
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_post = await self.database.get_placement_post(input.workspace_id, input.placement_post_id)
if not placement_post:
log.warning('PlacementPost %s not found for user %s', input.placement_post_id, input.user_id)
raise domain.PlacementPostNotFound(input.placement_post_id)
if not publication.placement:
log.error('Publication %s has no placement', publication.id)
raise domain.PlacementNotFound(publication.placement_id)
if not placement_post.placement:
log.error('PlacementPost %s has no placement', placement_post.id)
raise domain.PlacementNotFound(placement_post.placement_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, publication.placement.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement_post.placement.project_id)
if not publication.post:
if not placement_post.post:
return []
histories = await self.database.get_views_history(
publication.post.id,
placement_post.post.id,
from_date=input.from_date,
to_date=input.to_date,
)

View File

@@ -33,4 +33,4 @@ async def accept_workspace_invite(
else:
await self.database.add_user_to_workspace(invite.workspace_id, user_id)
return dto.WorkspaceInviteOutput.from_domain(invite)
return dto.WorkspaceInviteOutput.from_domain(invite)