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

@@ -1,3 +1,4 @@
# ruff: noqa
from tortoise import BaseDBAsyncClient from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True

View File

@@ -1,3 +1,4 @@
# ruff: noqa
from tortoise import BaseDBAsyncClient from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True

View File

@@ -1,3 +1,4 @@
# ruff: noqa
from tortoise import BaseDBAsyncClient from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True
@@ -55,8 +56,8 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
FROM "purchase_channel" pc FROM "purchase_channel" pc
JOIN "purchase" p ON pc.purchase_id = p.id; JOIN "purchase" p ON pc.purchase_id = p.id;
-- Шаг 4: Создать таблицу publication (бывший placement) -- Шаг 4: Создать таблицу placement_post (бывший placement)
CREATE TABLE "publication" ( CREATE TABLE "placement_post" (
"id" UUID NOT NULL PRIMARY KEY, "id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -71,8 +72,8 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE
); );
-- Шаг 5: Мигрировать данные из old_placement в publication -- Шаг 5: Мигрировать данные из old_placement в placement_post
INSERT INTO "publication" ( INSERT INTO "placement_post" (
"id", "created_at", "updated_at", "deleted_at", "id", "created_at", "updated_at", "deleted_at",
"wanted_placement_date", "cost", "comment", "status", "wanted_placement_date", "cost", "comment", "status",
"creative_id", "placement_id", "post_id", "project_id" "creative_id", "placement_id", "post_id", "project_id"
@@ -85,13 +86,13 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
-- Шаг 6: Обновить foreign keys для таблиц, ссылающихся на old_placement -- Шаг 6: Обновить foreign keys для таблиц, ссылающихся на old_placement
-- 6.1: Обновить subscription (placement_id -> publication_id) -- 6.1: Обновить subscription (placement_id -> placement_post_id)
-- Удалить constraint ДО переименования колонки -- Удалить constraint ДО переименования колонки
ALTER TABLE "subscription" ALTER TABLE "subscription"
DROP CONSTRAINT IF EXISTS "subscription_placement_id_fkey"; DROP CONSTRAINT IF EXISTS "subscription_placement_id_fkey";
-- Переименовать колонку -- Переименовать колонку
ALTER TABLE "subscription" RENAME COLUMN "placement_id" TO "publication_id"; ALTER TABLE "subscription" RENAME COLUMN "placement_id" TO "placement_post_id";
-- 6.2: Обновить workspace_user_permission_scope.placement_id -- 6.2: Обновить workspace_user_permission_scope.placement_id
-- Удалить старый foreign key constraint ДО обновления данных -- Удалить старый foreign key constraint ДО обновления данных
@@ -109,22 +110,22 @@ async def upgrade(db: BaseDBAsyncClient) -> str:
ON "placement" ("project_id", "status"); ON "placement" ("project_id", "status");
CREATE INDEX "idx_placement_channel_fb3968" CREATE INDEX "idx_placement_channel_fb3968"
ON "placement" ("channel_id"); ON "placement" ("channel_id");
CREATE INDEX "idx_publication_creativ_1c6807" CREATE INDEX "idx_placement_post_creativ_1c6807"
ON "publication" ("creative_id"); ON "placement_post" ("creative_id");
CREATE INDEX "idx_publication_placeme_bd5bc9" CREATE INDEX "idx_placement_post_placeme_bd5bc9"
ON "publication" ("placement_id"); ON "placement_post" ("placement_id");
CREATE INDEX "idx_publication_post_id_998755" CREATE INDEX "idx_placement_post_post_id_998755"
ON "publication" ("post_id"); ON "placement_post" ("post_id");
CREATE INDEX "idx_publication_project_94129d" CREATE INDEX "idx_placement_post_project_94129d"
ON "publication" ("project_id"); ON "placement_post" ("project_id");
ALTER TABLE "subscription" ALTER TABLE "subscription"
ADD CONSTRAINT "fk_subscrip_publicat_1959be9e" ADD CONSTRAINT "fk_subscrip_placem_1959be9e"
FOREIGN KEY ("publication_id") FOREIGN KEY ("placement_post_id")
REFERENCES "publication" ("id") ON DELETE CASCADE; REFERENCES "placement_post" ("id") ON DELETE CASCADE;
CREATE UNIQUE INDEX "uid_subscriptio_publica_578bfb" CREATE UNIQUE INDEX "uid_subscriptio_placem_578bfb"
ON "subscription" ("publication_id", "telegram_user_id"); ON "subscription" ("placement_post_id", "telegram_user_id");
ALTER TABLE "workspace_user_permission_scope" ALTER TABLE "workspace_user_permission_scope"
ADD CONSTRAINT "fk_workspac_placemen_8c5d3f2a" ADD CONSTRAINT "fk_workspac_placemen_8c5d3f2a"
@@ -150,9 +151,9 @@ CPM: cpm';
IS 'SELF_PROMO: self_promo IS 'SELF_PROMO: self_promo
STANDARD: standard'; STANDARD: standard';
COMMENT ON TABLE "publication" COMMENT ON TABLE "placement_post"
IS 'Публикация, мониторимая системой (бывший Placement)'; IS 'Публикация, мониторимая системой (бывший Placement)';
COMMENT ON COLUMN "publication"."status" COMMENT ON COLUMN "placement_post"."status"
IS 'ACTIVE: active IS 'ACTIVE: active
ARCHIVED: archived'; ARCHIVED: archived';
@@ -273,7 +274,7 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE
); );
-- Шаг 7: Мигрировать данные из publication в placement -- Шаг 7: Мигрировать данные из placement_post в placement
INSERT INTO "placement" ( INSERT INTO "placement" (
"id", "created_at", "updated_at", "deleted_at", "id", "created_at", "updated_at", "deleted_at",
"wanted_placement_date", "cost", "comment", "status", "wanted_placement_date", "cost", "comment", "status",
@@ -283,17 +284,17 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
pub.id, pub.created_at, pub.updated_at, pub.deleted_at, pub.id, pub.created_at, pub.updated_at, pub.deleted_at,
pub.wanted_placement_date, pub.cost, pub.comment, pub.status, pub.wanted_placement_date, pub.cost, pub.comment, pub.status,
pub.creative_id, pub.placement_id, pub.post_id, pub.project_id pub.creative_id, pub.placement_id, pub.post_id, pub.project_id
FROM "publication" pub; FROM "placement_post" pub;
-- Шаг 8: Обновить foreign keys обратно -- Шаг 8: Обновить foreign keys обратно
-- 8.1: Обновить subscription (publication_id -> placement_id) -- 8.1: Обновить subscription (placement_post_id -> placement_id)
-- Удалить новый foreign key constraint -- Удалить новый foreign key constraint
ALTER TABLE "subscription" ALTER TABLE "subscription"
DROP CONSTRAINT IF EXISTS "fk_subscrip_publicat_1959be9e"; DROP CONSTRAINT IF EXISTS "fk_subscrip_placem_1959be9e";
-- Переименовать колонку обратно -- Переименовать колонку обратно
ALTER TABLE "subscription" RENAME COLUMN "publication_id" TO "placement_id"; ALTER TABLE "subscription" RENAME COLUMN "placement_post_id" TO "placement_id";
-- 8.2: Обновить workspace_user_permission_scope.placement_id обратно -- 8.2: Обновить workspace_user_permission_scope.placement_id обратно
-- Удалить constraint на новый placement -- Удалить constraint на новый placement
@@ -303,7 +304,7 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
-- Обновить данные обратно: новые placement_id -> старые placement_id -- Обновить данные обратно: новые placement_id -> старые placement_id
UPDATE "workspace_user_permission_scope" wups UPDATE "workspace_user_permission_scope" wups
SET placement_id = pub.id SET placement_id = pub.id
FROM "publication" pub FROM "placement_post" pub
WHERE wups.placement_id = pub.placement_id; WHERE wups.placement_id = pub.placement_id;
-- Шаг 9: Создать индексы и constraints -- Шаг 9: Создать индексы и constraints
@@ -326,86 +327,86 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
REFERENCES "placement" ("id") ON DELETE CASCADE; REFERENCES "placement" ("id") ON DELETE CASCADE;
-- Шаг 10: Удалить новые таблицы -- Шаг 10: Удалить новые таблицы
DROP TABLE IF EXISTS "publication"; DROP TABLE IF EXISTS "placement_post";
DROP TABLE IF EXISTS "new_placement"; DROP TABLE IF EXISTS "new_placement";
""" """
MODELS_STATE = ( MODELS_STATE = (
"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'
"OfXn7/A5u7TFM=" 'OfXn7/A5u7TFM='
) )

View File

@@ -9,4 +9,4 @@ class JWTConfig(BaseModel):
class JWTBase: class JWTBase:
def __init__(self, config: JWTConfig) -> None: def __init__(self, config: JWTConfig) -> None:
self.config = config self.config = config

View File

@@ -12,4 +12,3 @@ class TelegramBase:
def __init__(self, config: TelegramConfig) -> None: def __init__(self, config: TelegramConfig) -> None:
self.bot: Bot = Bot(token=config.TOKEN) self.bot: Bot = Bot(token=config.TOKEN)
logging.info('Telegram bot initialized') logging.info('Telegram bot initialized')

View File

@@ -392,9 +392,9 @@ class Postgres(DatabaseBase):
) )
@staticmethod @staticmethod
async def get_publication(workspace_id: uuid.UUID, publication_id: uuid.UUID) -> domain.Publication | None: async def get_placement_post(workspace_id: uuid.UUID, placement_post_id: uuid.UUID) -> domain.PlacementPost | None:
return await domain.Publication.get_or_none( return await domain.PlacementPost.get_or_none(
id=publication_id, placement__project__workspace_id=workspace_id id=placement_post_id, placement__project__workspace_id=workspace_id
).prefetch_related( ).prefetch_related(
'placement', 'placement',
'placement__project', 'placement__project',
@@ -406,7 +406,7 @@ class Postgres(DatabaseBase):
) )
@staticmethod @staticmethod
async def get_workspace_publications( async def get_workspace_placement_posts(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
placement_channel_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, allowed_project_ids: set[uuid.UUID] | None = None,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
) -> list[domain.Publication]: ) -> list[domain.PlacementPost]:
query = domain.Publication.filter(placement__project__workspace_id=workspace_id) query = domain.PlacementPost.filter(placement__project__workspace_id=workspace_id)
if project_id: if project_id:
query = query.filter(placement__project_id=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) query = query.filter(created_at__lte=date_to)
if not include_archived: if not include_archived:
query = query.filter(status=domain.PublicationStatus.ACTIVE) query = query.filter(status=domain.PlacementPostStatus.ACTIVE)
return ( return (
await query.prefetch_related( await query.prefetch_related(
@@ -452,12 +452,12 @@ class Postgres(DatabaseBase):
) )
@staticmethod @staticmethod
async def create_publication(publication: domain.Publication) -> None: async def create_placement_post(placement_post: domain.PlacementPost) -> None:
await publication.save() await placement_post.save()
@staticmethod @staticmethod
async def get_publication_by_invite_link(invite_link: str) -> domain.Publication | None: async def get_placement_post_by_invite_link(invite_link: str) -> domain.PlacementPost | None:
return await domain.Publication.get_or_none(placement__invite_link=invite_link).prefetch_related( return await domain.PlacementPost.get_or_none(placement__invite_link=invite_link).prefetch_related(
'placement', 'placement',
'placement__project', 'placement__project',
'placement__project__channel', 'placement__project__channel',
@@ -471,26 +471,28 @@ class Postgres(DatabaseBase):
await subscription.save() await subscription.save()
@staticmethod @staticmethod
async def get_subscription_by_subscriber_and_publication( async def get_subscription_by_subscriber_and_placement_post(
telegram_user_id: uuid.UUID, publication_id: uuid.UUID telegram_user_id: uuid.UUID, placement_post_id: uuid.UUID
) -> domain.Subscription | None: ) -> 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 @staticmethod
async def update_subscription(subscription: domain.Subscription) -> None: async def update_subscription(subscription: domain.Subscription) -> None:
await subscription.save() await subscription.save()
@staticmethod @staticmethod
async def get_subscriptions_for_publications( async def get_subscriptions_for_placement_posts(
publication_ids: list[uuid.UUID], placement_post_ids: list[uuid.UUID],
*, *,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
) -> list[domain.Subscription]: ) -> list[domain.Subscription]:
if not publication_ids: if not placement_post_ids:
return [] return []
query = domain.Subscription.filter(publication_id__in=publication_ids) query = domain.Subscription.filter(placement_post_id__in=placement_post_ids)
if date_from: if date_from:
query = query.filter(created_at__gte=date_from) query = query.filter(created_at__gte=date_from)
@@ -506,10 +508,10 @@ class Postgres(DatabaseBase):
return ( return (
await domain.Subscription.filter( await domain.Subscription.filter(
telegram_user_id=telegram_user_id, telegram_user_id=telegram_user_id,
publication__placement__project_id=project_id, placement_post__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE, status=domain.SubscriptionStatus.ACTIVE,
) )
.prefetch_related('publication', 'telegram_user') .prefetch_related('placement_post', 'telegram_user')
.all() .all()
) )
@@ -520,10 +522,10 @@ class Postgres(DatabaseBase):
return ( return (
await domain.Subscription.filter( await domain.Subscription.filter(
telegram_user_id=telegram_user_id, telegram_user_id=telegram_user_id,
publication__placement__project_id=project_id, placement_post__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE, status=domain.SubscriptionStatus.ACTIVE,
) )
.prefetch_related('publication', 'telegram_user') .prefetch_related('placement_post', 'telegram_user')
.first() .first()
) )
@@ -555,22 +557,22 @@ class Postgres(DatabaseBase):
# Count methods # Count methods
@staticmethod @staticmethod
async def count_publications_by_creative(creative_id: uuid.UUID) -> int: async def count_placement_posts_by_creative(creative_id: uuid.UUID) -> int:
return await domain.Publication.filter(placement__creative_id=creative_id).count() return await domain.PlacementPost.filter(placement__creative_id=creative_id).count()
@staticmethod @staticmethod
async def count_subscriptions_by_publication(publication_id: uuid.UUID) -> int: async def count_subscriptions_by_placement_post(placement_post_id: uuid.UUID) -> int:
return await domain.Subscription.filter(publication_id=publication_id).count() return await domain.Subscription.filter(placement_post_id=placement_post_id).count()
@staticmethod @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: if not creative_ids:
return {} return {}
from tortoise.functions import Count from tortoise.functions import Count
results = ( 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') .group_by('placement__creative_id')
.annotate(count=Count('id')) .annotate(count=Count('id'))
.values('placement__creative_id', 'count') .values('placement__creative_id', 'count')
@@ -580,22 +582,22 @@ class Postgres(DatabaseBase):
return {cid: counts.get(cid, 0) for cid in creative_ids} return {cid: counts.get(cid, 0) for cid in creative_ids}
@staticmethod @staticmethod
async def count_subscriptions_by_publication_batch(publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: async def count_subscriptions_by_placement_post_batch(placement_post_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not publication_ids: if not placement_post_ids:
return {} return {}
from tortoise.functions import Count from tortoise.functions import Count
results = ( results = (
await domain.Subscription.filter(publication_id__in=publication_ids) await domain.Subscription.filter(placement_post_id__in=placement_post_ids)
.group_by('publication_id') .group_by('placement_post_id')
.annotate(count=Count('id')) .annotate(count=Count('id'))
.values('publication_id', 'count') .values('placement_post_id', 'count')
) )
counts = {row['publication_id']: row['count'] for row in results} counts = {row['placement_post_id']: row['count'] for row in results}
return {pid: counts.get(pid, 0) for pid in publication_ids} return {pid: counts.get(pid, 0) for pid in placement_post_ids}
@staticmethod @staticmethod
async def has_publications_for_creative(creative_id: uuid.UUID) -> bool: async def has_placement_posts_for_creative(creative_id: uuid.UUID) -> bool:
return await domain.Publication.filter(placement__creative_id=creative_id).exists() 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.channels import channels_router
from src.controller.http_v1.creatives import creatives_router from src.controller.http_v1.creatives import creatives_router
from src.controller.http_v1.internal import internal_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.projects import projects_router
from src.controller.http_v1.purchases import placements_user_router from src.controller.http_v1.purchases import placements_user_router
from src.controller.http_v1.views import views_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(projects_router)
api_v1_router.include_router(placements_user_router) # User-managed placements api_v1_router.include_router(placements_user_router) # User-managed placements
api_v1_router.include_router(creatives_router) 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(views_router)
api_v1_router.include_router(analytics_router) api_v1_router.include_router(analytics_router)
api_v1_router.include_router(workspaces_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 import deps, dto
from src.adapter.jwt import JWTPayload from src.adapter.jwt import JWTPayload
# Publications router - System-managed actual published posts # PlacementPosts router - System-managed actual published posts
publications_router = APIRouter(prefix='/workspaces/{workspace_id}/publications', tags=['publications']) placement_posts_router = APIRouter(prefix='/workspaces/{workspace_id}/placement_posts', tags=['placement_posts'])
@publications_router.get('') @placement_posts_router.get('')
async def list_publications( async def list_placement_posts(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
placement_channel_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> dto.GetPublicationsOutput: ) -> dto.GetPlacementPostsOutput:
"""List all publications (system-managed actual posts) in workspace""" """List all placement_posts (system-managed actual posts) in workspace"""
input = dto.GetPublicationsInput( input = dto.GetPlacementPostsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_id, project_id=project_id,
@@ -30,24 +30,24 @@ async def list_publications(
include_archived=include_archived, 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 return result
@publications_router.get('/{publication_id}') @placement_posts_router.get('/{placement_post_id}')
async def get_publication( async def get_placement_post(
publication_id: uuid.UUID, placement_post_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PublicationOutput: ) -> dto.PlacementPostOutput:
"""Get single publication by ID""" """Get single placement_post by ID"""
input = dto.GetPublicationInput( input = dto.GetPlacementPostInput(
publication_id=publication_id, placement_post_id=placement_post_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_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, to_date: datetime.datetime | None = None,
) -> Page[dto.PostViewsHistoryOutput]: ) -> Page[dto.PostViewsHistoryOutput]:
input_data = dto.GetViewsHistoryInput( input_data = dto.GetViewsHistoryInput(
publication_id=placement_id, placement_post_id=placement_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
from_date=from_date, from_date=from_date,

View File

@@ -10,8 +10,8 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class FetchPublicationPostWorker(WorkerBase): class FetchPlacementPostWorker(WorkerBase):
"""Worker that fetches posts from channels and creates Publications from Placements""" """Worker that fetches posts from channels and creates PlacementPosts from Placements"""
async def _cycle_func(self) -> None: 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', 'Placement',
'PlacementStatus', 'PlacementStatus',
'PlacementType', 'PlacementType',
'Publication', 'PlacementPost',
'PublicationStatus', 'PlacementPostStatus',
'Creative', 'Creative',
'replace_invite_link_with_tag', 'replace_invite_link_with_tag',
'validate_media_size', 'validate_media_size',
@@ -49,7 +49,7 @@ __all__ = (
'ProjectNotFound', 'ProjectNotFound',
'ProjectChannelConflict', 'ProjectChannelConflict',
'PlacementNotFound', 'PlacementNotFound',
'PublicationNotFound', 'PlacementPostNotFound',
'ChannelNotFound', 'ChannelNotFound',
'ChannelAlreadyExists', 'ChannelAlreadyExists',
'ChannelNoAdminRights', 'ChannelNoAdminRights',
@@ -83,9 +83,9 @@ from .error import (
LoginTokenExpired, LoginTokenExpired,
LoginTokenNotFound, LoginTokenNotFound,
PlacementNotFound, PlacementNotFound,
PlacementPostNotFound,
ProjectChannelConflict, ProjectChannelConflict,
ProjectNotFound, ProjectNotFound,
PublicationNotFound,
TelegramChannelNotFound, TelegramChannelNotFound,
UserByUsernameNotFound, UserByUsernameNotFound,
UserNotFound, UserNotFound,
@@ -104,10 +104,10 @@ from .placement import (
PlacementStatus, PlacementStatus,
PlacementType, PlacementType,
) )
from .placement_post import PlacementPost, PlacementPostStatus, PostViewsAvailability
from .post import Post from .post import Post
from .post_views_history import PostViewsHistory from .post_views_history import PostViewsHistory
from .project import Project, ProjectStatus from .project import Project, ProjectStatus
from .publication import PostViewsAvailability, Publication, PublicationStatus
from .subscription import Subscription, SubscriptionStatus from .subscription import Subscription, SubscriptionStatus
from .telegram_user import TelegramUser from .telegram_user import TelegramUser
from .user import User 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') return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found')
def PublicationNotFound(publication_id: uuid.UUID | None = None) -> HTTPException: def PlacementPostNotFound(placement_post_id: uuid.UUID | None = None) -> HTTPException:
if publication_id is None: if placement_post_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Publication not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'PlacementPost not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Publication {publication_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'PlacementPost {placement_post_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException: def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
@@ -90,7 +90,7 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException( return HTTPException(
status.HTTP_400_BAD_REQUEST, 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 .post import Post
from .project import Project from .project import Project
__all__ = ['Publication', 'PublicationStatus', 'PostViewsAvailability'] __all__ = ['PlacementPost', 'PlacementPostStatus', 'PostViewsAvailability']
class PublicationStatus(str, enum.Enum): class PlacementPostStatus(str, enum.Enum):
ACTIVE = 'active' # Пост найден и отслеживается ACTIVE = 'active' # Пост найден и отслеживается
ARCHIVED = 'archived' # Вручную архивировано ARCHIVED = 'archived' # Вручную архивировано
@@ -27,25 +27,25 @@ class PostViewsAvailability(str, enum.Enum):
MANUAL = 'manual' # Просмотры вводятся вручную MANUAL = 'manual' # Просмотры вводятся вручную
class Publication(TimestampedModel): class PlacementPost(TimestampedModel):
wanted_placement_date = fields.DatetimeField(db_column='placement_date') wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True) cost = fields.FloatField(null=True)
comment = fields.TextField(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( 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( 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( 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( 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: if TYPE_CHECKING:
@@ -55,4 +55,4 @@ class Publication(TimestampedModel):
placement_id: UUID placement_id: UUID
class Meta: class Meta:
table = 'publication' table = 'placement_post'

View File

@@ -7,7 +7,7 @@ from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .publication import Publication from .placement_post import PlacementPost
from .telegram_user import TelegramUser from .telegram_user import TelegramUser
@@ -21,17 +21,17 @@ class Subscription(TimestampedModel):
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE) status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
unsubscribed_at = fields.DatetimeField(null=True) unsubscribed_at = fields.DatetimeField(null=True)
publication: fields.ForeignKeyRelation['Publication'] = fields.ForeignKeyField( placement_post: fields.ForeignKeyRelation['PlacementPost'] = fields.ForeignKeyField(
'models.Publication', related_name='subscriptions', on_delete=fields.CASCADE, index=True 'models.PlacementPost', related_name='subscriptions', on_delete=fields.CASCADE, index=True
) )
telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField( telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField(
'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True 'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
publication_id: UUID placement_post_id: UUID
telegram_user_id: UUID telegram_user_id: UUID
class Meta: class Meta:
table = 'subscription' 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', 'GetPlacementsInput',
'GetPlacementsOutput', 'GetPlacementsOutput',
'GetPlacementInput', 'GetPlacementInput',
'PublicationOutput', 'PlacementPostOutput',
'GetPublicationsInput', 'GetPlacementPostsInput',
'GetPublicationsOutput', 'GetPlacementPostsOutput',
'GetPublicationInput', 'GetPlacementPostInput',
'CreativeButton', 'CreativeButton',
'CreativeOutput', 'CreativeOutput',
'GetCreativesInput', 'GetCreativesInput',
@@ -132,10 +132,10 @@ from .creative import (
UpdateCreativeInput, UpdateCreativeInput,
) )
from .placement import ( from .placement import (
GetPublicationInput, GetPlacementPostInput,
GetPublicationsInput, GetPlacementPostsInput,
GetPublicationsOutput, GetPlacementPostsOutput,
PublicationOutput, PlacementPostOutput,
) )
from .project import ( from .project import (
ArchiveProjectInput, ArchiveProjectInput,

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
# Определяем тип и ID scope на основе заполненных полей # Определяем тип и ID scope на основе заполненных полей
scope_type = None scope_type = None
scope_id = None scope_id = None
if scope.project_id: if scope.project_id:
scope_type = domain.PermissionScopeType.PROJECT scope_type = domain.PermissionScopeType.PROJECT
scope_id = scope.project_id scope_id = scope.project_id
@@ -76,7 +76,7 @@ class WorkspaceMemberOutput(pydantic.BaseModel):
elif scope.channel_id: elif scope.channel_id:
scope_type = domain.PermissionScopeType.CHANNEL scope_type = domain.PermissionScopeType.CHANNEL
scope_id = scope.channel_id scope_id = scope.channel_id
if scope_type and scope_id: if scope_type and scope_id:
permissions_map.setdefault(scope.permission, []).append( permissions_map.setdefault(scope.permission, []).append(
WorkspacePermissionScopeOutput(type=scope_type, id=scope_id) 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.adapter.telegram_bot import TelegramBot
from src.config import settings from src.config import settings
from src.controller.http_v1 import api_router 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 from src.usecase import Usecase
@@ -24,11 +24,11 @@ from src.usecase import Usecase
async def lifespan(_: FastAPI) -> AsyncGenerator[None]: async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
await postgres.connect() await postgres.connect()
await s3.connect() await s3.connect()
await fetch_publication_post_worker.start() await fetch_placement_post_worker.start()
yield yield
await fetch_publication_post_worker.stop() await fetch_placement_post_worker.stop()
await s3.close() await s3.close()
await postgres.close() await postgres.close()
@@ -51,7 +51,7 @@ usecase = Usecase(
) )
deps.set_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( app = FastAPI(
lifespan=lifespan, 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_creative import get_creative
from .creative.get_creatives import get_creatives from .creative.get_creatives import get_creatives
from .creative.update_creative import update_creative from .creative.update_creative import update_creative
from .placement.fetch_publication_post_cycle import fetch_publication_post_cycle from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
from .placement.get_publication import get_publication from .placement.get_placement_post import get_placement_post
from .placement.get_publications import get_publications from .placement.get_placement_posts import get_placement_posts
from .project.archive_project import archive_project from .project.archive_project import archive_project
from .project.delete_project import delete_project from .project.delete_project import delete_project
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
@@ -171,10 +171,10 @@ class Usecase:
create_creative = create_creative create_creative = create_creative
update_creative = update_creative update_creative = update_creative
delete_creative = delete_creative delete_creative = delete_creative
# Publication (system-managed) use cases # PlacementPost (system-managed) use cases
get_publications = get_publications get_placement_posts = get_placement_posts
get_publication = get_publication get_placement_post = get_placement_post
fetch_publication_post_cycle = fetch_publication_post_cycle fetch_placement_post_cycle = fetch_placement_post_cycle
handle_subscription = handle_subscription handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription handle_unsubscription = handle_unsubscription
get_views_history = get_views_history get_views_history = get_views_history

View File

@@ -29,7 +29,7 @@ async def get_channel_analytics(
else: else:
allowed_project_filter = allowed_project_ids 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.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -38,8 +38,8 @@ async def get_channel_analytics(
# Collect unique channels from placements # Collect unique channels from placements
channel_map: dict[UUID, domain.Channel] = {} channel_map: dict[UUID, domain.Channel] = {}
for publication in placements: for placement_post in placements:
placement = publication.placement placement = placement_post.placement
if placement and placement.channel: if placement and placement.channel:
channel_map[placement.channel_id] = placement.channel channel_map[placement.channel_id] = placement.channel
channels = list(channel_map.values()) channels = list(channel_map.values())
@@ -59,22 +59,22 @@ async def get_channel_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in placements] 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: for placement_post in placements:
placement = publication.placement placement = placement_post.placement
if not placement: if not placement:
continue continue
stats = channel_stats.get(placement.channel_id) stats = channel_stats.get(placement.channel_id)
if stats is None: if stats is None:
continue continue
stats.total_cost += publication.cost if publication.cost is not None else 0 stats.total_cost += placement_post.cost if placement_post.cost is not None else 0
stats.total_subscriptions += subscriptions_counts.get(publication.id, 0) stats.total_subscriptions += subscriptions_counts.get(placement_post.id, 0)
# Get views from batch data # Get views from batch data
if publication.post and publication.post.id in views_map: if placement_post.post and placement_post.post.id in views_map:
views_count = views_map[publication.post.id][0] views_count = views_map[placement_post.post.id][0]
stats.total_views += views_count stats.total_views += views_count
stats.placements_count += 1 stats.placements_count += 1

View File

@@ -33,7 +33,7 @@ async def get_creatives_analytics(
include_archived=False, include_archived=False,
allowed_project_ids=allowed_project_ids, 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.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -55,7 +55,7 @@ async def get_creatives_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in placements] 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: for p in placements:
stats = creative_stats.get(p.creative_id) 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) period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1)
previous_period_start = input.date_from - period_length 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, input.workspace_id,
project_id=input.project_id, project_id=input.project_id,
include_archived=False, include_archived=False,
@@ -64,18 +64,18 @@ async def get_overview_analytics(
date_to=input.date_to, date_to=input.date_to,
) )
current_placements: list[domain.Publication] = [] current_placements: list[domain.PlacementPost] = []
previous_placements: list[domain.Publication] = [] previous_placements: list[domain.PlacementPost] = []
for publication in placements: for placement_post in placements:
placement_date = publication.wanted_placement_date placement_date = placement_post.wanted_placement_date
if placement_date >= input.date_from and placement_date <= input.date_to: 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: 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] 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 placement_ids, date_from=previous_period_start, date_to=input.date_to
) )
@@ -89,30 +89,30 @@ async def get_overview_analytics(
continue continue
if created_at >= input.date_from and created_at <= input.date_to: 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 subs_per_day_current[created_at.date()] += 1
elif created_at >= previous_period_start and created_at < input.date_from: 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] 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 {} views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
def _aggregate_totals( 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]: ) -> tuple[float, int, int]:
total_cost = 0.0 total_cost = 0.0
total_subscriptions = 0 total_subscriptions = 0
total_views = 0 total_views = 0
for publication in placement_list: for placement_post in placement_list:
if publication.cost is not None: if placement_post.cost is not None:
total_cost += publication.cost 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 total_subscriptions += subs
if publication.post and publication.post.id in views_map: if placement_post.post and placement_post.post.id in views_map:
total_views += views_map[publication.post.id][0] total_views += views_map[placement_post.post.id][0]
return total_cost, total_subscriptions, total_views 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_spending_map: dict[UUID, float] = defaultdict(float)
project_meta: dict[UUID, domain.Project] = {} project_meta: dict[UUID, domain.Project] = {}
for publication in current_placements: for placement_post in current_placements:
placement_day = publication.wanted_placement_date.date() placement_day = placement_post.wanted_placement_date.date()
project_meta[publication.project_id] = publication.project 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 cost_per_day[placement_day] += cost_value
project_spending_map[publication.project_id] += cost_value project_spending_map[placement_post.project_id] += cost_value
subs = subs_per_placement_current.get(publication.id, 0) subs = subs_per_placement_current.get(placement_post.id, 0)
placement = publication.placement placement = placement_post.placement
if placement: if placement:
channel_id = placement.channel_id channel_id = placement.channel_id
if channel_id not in channel_stats: 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) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
allowed_project_ids = None allowed_project_ids = None
placements = await self.database.get_workspace_publications( placements = await self.database.get_workspace_placement_posts(
input.workspace_id, input.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -50,7 +50,7 @@ async def get_placements_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in placements] 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 [ return [
dto.PlacementAnalyticsOutput( 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: if week_start.month == week_end.month:
return f'{week_start.day}-{week_end.day} {month_names[week_start.month - 1]}' return f'{week_start.day}-{week_end.day} {month_names[week_start.month - 1]}'
else: 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: case dto.DateGrouping.MONTH:
# "дек 2024" # "дек 2024"
month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'] month_names = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
@@ -60,20 +60,20 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
raise ValueError('Invalid date grouping') 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: match date_grouping:
case dto.DateGroupingType.PLACEMENT_DATE: case dto.DateGroupingType.PLACEMENT_DATE:
return publication.wanted_placement_date return placement_post.wanted_placement_date
case dto.DateGroupingType.PURCHASE_DATE: case dto.DateGroupingType.PURCHASE_DATE:
if publication.placement: if placement_post.placement:
return publication.placement.created_at return placement_post.placement.created_at
return publication.wanted_placement_date # Fallback return placement_post.wanted_placement_date # Fallback
case dto.DateGroupingType.LINK_DATE: case dto.DateGroupingType.LINK_DATE:
if publication.placement: if placement_post.placement:
return publication.placement.created_at return placement_post.placement.created_at
return publication.wanted_placement_date # Fallback return placement_post.wanted_placement_date # Fallback
case _: case _:
return publication.wanted_placement_date return placement_post.wanted_placement_date
@dataclass @dataclass
@@ -183,7 +183,7 @@ async def get_projects_analytics(
else: else:
allowed_project_ids = set(input.project_ids) 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, input.workspace_id,
project_id=None, project_id=None,
include_archived=False, include_archived=False,
@@ -194,28 +194,28 @@ async def get_projects_analytics(
# Фильтрация по датам на основе date_grouping # Фильтрация по датам на основе date_grouping
filtered_placements = [] filtered_placements = []
for publication in placements: for placement_post in placements:
grouping_date = _get_grouping_date(publication, input.date_grouping) grouping_date = _get_grouping_date(placement_post, input.date_grouping)
if input.date_from and grouping_date < input.date_from: if input.date_from and grouping_date < input.date_from:
continue continue
if input.date_to and grouping_date > input.date_to: if input.date_to and grouping_date > input.date_to:
continue continue
filtered_placements.append(publication) filtered_placements.append(placement_post)
# Batch fetch views data # Batch fetch views data
post_ids = [p.post.id for p in filtered_placements if p.post] 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 {} views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
publication_ids = [p.id for p in filtered_placements] placement_post_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids) subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
# Группировка по периодам # Группировка по периодам
period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics) period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
total_metrics = PeriodMetrics() total_metrics = PeriodMetrics()
for publication in filtered_placements: for placement_post in filtered_placements:
grouping_date = _get_grouping_date(publication, input.date_grouping) grouping_date = _get_grouping_date(placement_post, input.date_grouping)
period = _format_period(grouping_date, input.grouping) period = _format_period(grouping_date, input.grouping)
pd = period_data[period] pd = period_data[period]
@@ -223,25 +223,25 @@ async def get_projects_analytics(
pd.purchases_count += 1 pd.purchases_count += 1
total_metrics.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 pd.total_cost += cost
total_metrics.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.total_subscriptions += subs_count
pd.clicks_count += subs_count pd.clicks_count += subs_count
total_metrics.total_subscriptions += subs_count total_metrics.total_subscriptions += subs_count
total_metrics.clicks_count += subs_count total_metrics.clicks_count += subs_count
if publication.post and publication.post.id in views_map: if placement_post.post and placement_post.post.id in views_map:
views_count = views_map[publication.post.id][0] views_count = views_map[placement_post.post.id][0]
pd.total_views += views_count pd.total_views += views_count
pd.reach_volume += views_count pd.reach_volume += views_count
total_metrics.total_views += views_count total_metrics.total_views += views_count
total_metrics.reach_volume += 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: if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
discount = placement.cost_before_bargain - cost discount = placement.cost_before_bargain - cost
discount_percent = ( discount_percent = (

View File

@@ -46,7 +46,7 @@ async def get_spending_analytics(
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
allowed_project_ids = None allowed_project_ids = None
placements = await self.database.get_workspace_publications( placements = await self.database.get_workspace_placement_posts(
input.workspace_id, input.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -76,7 +76,7 @@ async def get_spending_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in filtered] 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) period_data: dict[str, PeriodData] = defaultdict(PeriodData)

View File

@@ -11,9 +11,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def attach_channel_to_workspace( async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelToWorkspaceInput) -> dto.ProjectOutput:
self: 'Usecase', input: dto.AttachChannelToWorkspaceInput
) -> dto.ProjectOutput:
"""Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)""" """Привязать канал к workspace (вызывается из Golang бота после выбора workspace пользователем)"""
channel = await self.database.get_channel(channel_id=input.channel_id) 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, telegram_id=channel.telegram_id,
title=channel.title, title=channel.title,
username=channel.username, 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) context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
has_publications = await self.database.has_publications_for_creative(creative.id) has_placement_posts = await self.database.has_placement_posts_for_creative(creative.id)
if has_publications: if has_placement_posts:
log.warning('Creative %s is used in publications and cannot be deleted', input.creative_id) log.warning('Creative %s is used in placement_posts and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(input.creative_id) raise domain.CreativeInUse(input.creative_id)
media_key = creative.media_s3_key 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) 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( return dto.CreativeOutput(
id=creative.id, 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] 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 [ return [
dto.CreativeOutput( dto.CreativeOutput(

View File

@@ -69,7 +69,7 @@ async def update_creative(
await self.database.update_creative(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( return dto.CreativeOutput(
id=creative.id, id=creative.id,

View File

@@ -9,15 +9,13 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def fetch_publication_post_cycle(self: 'Usecase', interval_seconds: int) -> None: async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
"""Находит посты в каналах по invite_link из Placement и создает Publication""" """Находит посты в каналах по invite_link из Placement и создает PlacementPost"""
log.debug('Starting fetch_publication_post_cycle') log.debug('Starting fetch_placement_post_cycle')
# Получаем все approved Placements с invite_link # Получаем все approved Placements с invite_link
placements = ( placements = (
await domain.Placement.filter( await domain.Placement.filter(status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS])
status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]
)
.prefetch_related('channel', 'project') .prefetch_related('channel', 'project')
.all() .all()
) )
@@ -42,23 +40,23 @@ async def fetch_publication_post_cycle(self: 'Usecase', interval_seconds: int) -
for post in posts: 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: if existing:
continue continue
publication = domain.Publication( placement_post = domain.PlacementPost(
placement_id=placement.id, placement_id=placement.id,
post_id=post.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 created_count += 1
log.info( log.info(
'Created publication %s for placement %s from post %s', 'Created placement_post %s for placement %s from post %s',
publication.id, placement_post.id,
placement.id, placement.id,
post.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__) 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)""" """Получить одну публикацию по ID (system-managed post)"""
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
) )
publication = await self.database.get_publication(input.workspace_id, input.publication_id) placement_post = await self.database.get_placement_post(input.workspace_id, input.placement_post_id)
if not publication: if not placement_post:
log.warning('Publication %s not found for user %s', input.publication_id, input.user_id) log.warning('PlacementPost %s not found for user %s', input.placement_post_id, input.user_id)
raise domain.PublicationNotFound(input.publication_id) raise domain.PlacementPostNotFound(input.placement_post_id)
placement = publication.placement placement = placement_post.placement
if not placement: if not placement:
log.error('Publication %s missing placement data', publication.id) log.error('PlacementPost %s missing placement data', placement_post.id)
raise domain.PlacementNotFound(publication.placement_id) raise domain.PlacementNotFound(placement_post.placement_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
if not placement.channel or not placement.project or not placement.creative: 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) raise domain.PlacementNotFound(placement.id)
ad_post_url = publication.post.url if publication.post else None ad_post_url = placement_post.post.url if placement_post.post else None
subscriptions_count = await self.database.count_subscriptions_by_publication(publication.id) subscriptions_count = await self.database.count_subscriptions_by_placement_post(placement_post.id)
return dto.PublicationOutput( return dto.PlacementPostOutput(
id=publication.id, id=placement_post.id,
project_id=placement.project_id, project_id=placement.project_id,
project_channel_title=placement.project.channel.title if placement.project.channel else None, project_channel_title=placement.project.channel.title if placement.project.channel else None,
placement_channel_id=placement.channel_id, placement_channel_id=placement.channel_id,
placement_channel_title=placement.channel.title, placement_channel_title=placement.channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, 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, cost=placement.cost_value,
comment=placement.comment, comment=placement.comment,
ad_post_url=ad_post_url, ad_post_url=ad_post_url,
invite_link_type=placement.invite_link_type, invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=publication.status, status=placement_post.status,
subscriptions_count=subscriptions_count, subscriptions_count=subscriptions_count,
placement_id=placement.id, 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__) 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)""" """Получить список публикаций (system-managed posts)"""
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ 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) context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
allowed_project_ids = None allowed_project_ids = None
publications = await self.database.get_workspace_publications( placement_posts = await self.database.get_workspace_placement_posts(
input.workspace_id, input.workspace_id,
project_id=input.project_id, project_id=input.project_id,
placement_channel_id=input.placement_channel_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, allowed_project_ids=allowed_project_ids,
) )
publication_ids = [p.id for p in publications] placement_post_ids = [p.id for p in placement_posts]
subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids) subscriptions_counts = await self.database.count_subscriptions_by_placement_post_batch(placement_post_ids)
publication_outputs = [] placement_post_outputs = []
for publication in publications: for placement_post in placement_posts:
placement = publication.placement placement = placement_post.placement
if not placement: if not placement:
log.warning('Publication %s missing placement data', publication.id) log.warning('PlacementPost %s missing placement data', placement_post.id)
continue continue
if not placement.channel or not placement.project or not placement.creative: 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 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( placement_post_outputs.append(
dto.PublicationOutput( dto.PlacementPostOutput(
id=publication.id, id=placement_post.id,
project_id=placement.project_id, project_id=placement.project_id,
project_channel_title=placement.project.channel.title if placement.project.channel else None, project_channel_title=placement.project.channel.title if placement.project.channel else None,
placement_channel_id=placement.channel_id, placement_channel_id=placement.channel_id,
placement_channel_title=placement.channel.title, placement_channel_title=placement.channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, 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, cost=placement.cost_value,
comment=placement.comment, comment=placement.comment,
ad_post_url=ad_post_url, ad_post_url=ad_post_url,
invite_link_type=placement.invite_link_type, invite_link_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=publication.status, status=placement_post.status,
subscriptions_count=subscriptions_counts.get(publication.id, 0), subscriptions_count=subscriptions_counts.get(placement_post.id, 0),
placement_id=placement.id, 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, status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default, 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(): async with self.database.transaction():
await self.database.delete_project(workspace_id, project_id) await self.database.delete_project(workspace_id, project_id)

View File

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

View File

@@ -117,19 +117,15 @@ async def create_placements(
status=channel_input.status or domain.PlacementStatus.PLANNED, status=channel_input.status or domain.PlacementStatus.PLANNED,
comment=channel_input.comment, comment=channel_input.comment,
# Приоритет: channel details > общие details # Приоритет: channel details > общие details
placement_at=(channel_details.placement_at if channel_details else None) or ( placement_at=(channel_details.placement_at if channel_details else None)
details.placement_at if details else None or (details.placement_at if details else None),
),
payment_at=details.payment_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 ( cost_type=(channel_details.cost.type if channel_details and channel_details.cost else None)
details.cost.type if details and 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)
cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None) or ( or (details.cost.value if details and details.cost else None),
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_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, 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), 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, first_name: str | None = None,
last_name: str | None = None, last_name: str | None = None,
) -> None: ) -> None:
publication = await self.database.get_publication_by_invite_link(invite_link) placement_post = await self.database.get_placement_post_by_invite_link(invite_link)
if not publication: if not placement_post:
log.warning('Publication not found for invite_link: %s', invite_link) log.warning('PlacementPost not found for invite_link: %s', invite_link)
return return
if not publication.placement: if not placement_post.placement:
log.error('Publication %s has no placement', publication.id) log.error('PlacementPost %s has no placement', placement_post.id)
return return
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) 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) await self.database.update_telegram_user(subscriber)
active_subscription = await self.database.get_active_subscription_by_subscriber_and_project( 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: if active_subscription:
@@ -50,18 +50,20 @@ async def handle_subscription(
# Это не должно случиться (Telegram не даст подписаться дважды), # Это не должно случиться (Telegram не даст подписаться дважды),
# но если случилось - логируем и игнорируем # но если случилось - логируем и игнорируем
log.warning( log.warning(
'User %s (telegram_id: %s) already has active subscription to channel %s via publication %s, ' 'User %s (telegram_id: %s) already has active subscription to channel %s via placement_post %s, '
'ignoring new subscription attempt via publication %s', 'ignoring new subscription attempt via placement_post %s',
subscriber.id, subscriber.id,
user_telegram_id, user_telegram_id,
publication.placement.project_id, placement_post.placement.project_id,
active_subscription.publication_id, active_subscription.placement_post_id,
publication.id, placement_post.id,
) )
return 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: if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
existing_sub.status = domain.SubscriptionStatus.ACTIVE existing_sub.status = domain.SubscriptionStatus.ACTIVE
@@ -69,24 +71,24 @@ async def handle_subscription(
await self.database.update_subscription(existing_sub) await self.database.update_subscription(existing_sub)
log.info( 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, subscriber.id,
user_telegram_id, user_telegram_id,
publication.id, placement_post.id,
) )
return return
subscription = domain.Subscription( subscription = domain.Subscription(
publication_id=publication.id, placement_post_id=placement_post.id,
telegram_user_id=subscriber.id, telegram_user_id=subscriber.id,
invite_link=invite_link, invite_link=invite_link,
) )
await self.database.create_subscription(subscription) await self.database.create_subscription(subscription)
log.info( 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, subscriber.id,
user_telegram_id, user_telegram_id,
publication.id, placement_post.id,
invite_link, 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) await self.database.update_subscription(subscription)
log.info( 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, subscriber.id,
user_telegram_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 input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
) )
publication = await self.database.get_publication(input.workspace_id, input.publication_id) placement_post = await self.database.get_placement_post(input.workspace_id, input.placement_post_id)
if not publication: if not placement_post:
log.warning('Publication %s not found for user %s', input.publication_id, input.user_id) log.warning('PlacementPost %s not found for user %s', input.placement_post_id, input.user_id)
raise domain.PublicationNotFound(input.publication_id) raise domain.PlacementPostNotFound(input.placement_post_id)
if not publication.placement: if not placement_post.placement:
log.error('Publication %s has no placement', publication.id) log.error('PlacementPost %s has no placement', placement_post.id)
raise domain.PlacementNotFound(publication.placement_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 [] return []
histories = await self.database.get_views_history( histories = await self.database.get_views_history(
publication.post.id, placement_post.post.id,
from_date=input.from_date, from_date=input.from_date,
to_date=input.to_date, to_date=input.to_date,
) )

View File

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