diff --git a/migrations/models/0_20251214134713_init.py b/migrations/models/0_20251215175910_init.py similarity index 61% rename from migrations/models/0_20251214134713_init.py rename to migrations/models/0_20251215175910_init.py index 355b56c..82446b6 100644 --- a/migrations/models/0_20251214134713_init.py +++ b/migrations/models/0_20251215175910_init.py @@ -1,16 +1,51 @@ from tortoise import BaseDBAsyncClient -# ruff: noqa RUN_IN_TRANSACTION = True async def upgrade(db: BaseDBAsyncClient) -> str: return """ - CREATE TABLE IF NOT EXISTS "telegram_state" ( + CREATE TABLE IF NOT EXISTS "channel" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, + "telegram_id" BIGINT UNIQUE, + "title" VARCHAR(255), + "username" VARCHAR(255), + "access_hash" BIGINT, + "pts" INT +); +CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id"); +CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username"); +CREATE TABLE IF NOT EXISTS "post" ( "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "message_id" INT NOT NULL, + "text" TEXT NOT NULL, + "deleted_from_channel_at" TIMESTAMPTZ, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id") +); +CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id"); +CREATE TABLE IF NOT EXISTS "post_views_history" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "views_count" INT NOT NULL, + "fetched_at" TIMESTAMPTZ NOT NULL, + "post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at"); +CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id"); +CREATE TABLE IF NOT EXISTS "telegram_state" ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, "telegram_id" BIGINT NOT NULL UNIQUE, "state" VARCHAR(26) NOT NULL, "context" JSONB NOT NULL @@ -18,10 +53,10 @@ async def upgrade(db: BaseDBAsyncClient) -> str: CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id"); COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_WORKSPACE: creative_waiting_workspace\nCREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text\nPROJECT_WAITING_WORKSPACE: project_waiting_workspace'; CREATE TABLE IF NOT EXISTS "user" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "telegram_id" BIGINT NOT NULL UNIQUE, "username" VARCHAR(255), "first_name" VARCHAR(255), @@ -29,10 +64,10 @@ CREATE TABLE IF NOT EXISTS "user" ( ); CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id"); CREATE TABLE IF NOT EXISTS "login_token" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "token" VARCHAR(255) NOT NULL UNIQUE, "expires_at" TIMESTAMPTZ NOT NULL, "used_at" TIMESTAMPTZ, @@ -40,32 +75,17 @@ CREATE TABLE IF NOT EXISTS "login_token" ( ); CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token"); CREATE TABLE IF NOT EXISTS "workspace" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "name" VARCHAR(255) NOT NULL ); -CREATE TABLE IF NOT EXISTS "channel" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "telegram_id" BIGINT UNIQUE, - "username" VARCHAR(255), - "title" VARCHAR(255), - "verification_status" VARCHAR(10) NOT NULL DEFAULT 'unverified', - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id"); -CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username"); -CREATE INDEX IF NOT EXISTS "idx_channel_workspa_4a6a23" ON "channel" ("workspace_id"); -COMMENT ON COLUMN "channel"."verification_status" IS 'UNVERIFIED: unverified\nVERIFIED: verified\nFAILED: failed'; CREATE TABLE IF NOT EXISTS "project" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "status" VARCHAR(8) NOT NULL DEFAULT 'active', "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, @@ -75,10 +95,10 @@ CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_i CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id"); COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived'; CREATE TABLE IF NOT EXISTS "creative" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "name" VARCHAR(255) NOT NULL, "text" TEXT NOT NULL, "status" VARCHAR(8) NOT NULL DEFAULT 'active', @@ -90,50 +110,35 @@ CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project CREATE INDEX IF NOT EXISTS "idx_creative_workspa_2de546" ON "creative" ("workspace_id"); COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; CREATE TABLE IF NOT EXISTS "placement" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "placement_date" TIMESTAMPTZ NOT NULL, "cost" DOUBLE PRECISION, "comment" TEXT, - "ad_post_url" VARCHAR(512), "invite_link_type" VARCHAR(8) NOT NULL, "invite_link" VARCHAR(512) NOT NULL, - "external_channel_message_id" INT, "status" VARCHAR(8) NOT NULL DEFAULT 'active', "subscriptions_count" INT NOT NULL DEFAULT 0, - "views_count" INT, - "views_availability" VARCHAR(11) NOT NULL DEFAULT 'unknown', - "last_views_fetch_at" TIMESTAMPTZ, "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, "placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL, "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id"); CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id"); CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id"); CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id"); COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; COMMENT ON COLUMN "placement"."status" IS 'PENDING: pending\nACTIVE: active\nDELETED: deleted\nARCHIVED: archived'; -COMMENT ON COLUMN "placement"."views_availability" IS 'UNKNOWN: unknown\nAVAILABLE: available\nUNAVAILABLE: unavailable\nMANUAL: manual'; -CREATE TABLE IF NOT EXISTS "placement_views_history" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "views_count" INT NOT NULL, - "fetched_at" TIMESTAMPTZ NOT NULL, - "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_placement_v_fetched_4c8f51" ON "placement_views_history" ("fetched_at"); -CREATE INDEX IF NOT EXISTS "idx_placement_v_placeme_89e0f4" ON "placement_views_history" ("placement_id"); CREATE TABLE IF NOT EXISTS "purchase_plan" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "status" VARCHAR(8) NOT NULL DEFAULT 'active', "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE @@ -143,10 +148,10 @@ CREATE INDEX IF NOT EXISTS "idx_purchase_pl_workspa_e2459d" ON "purchase_plan" ( CREATE INDEX IF NOT EXISTS "idx_purchase_pl_project_a3c246" ON "purchase_plan" ("project_id", "status"); COMMENT ON COLUMN "purchase_plan"."status" IS 'ACTIVE: active\nARCHIVED: archived'; CREATE TABLE IF NOT EXISTS "purchase_plan_channel" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "status" VARCHAR(11) NOT NULL DEFAULT 'planned', "planned_cost" DOUBLE PRECISION, "comment" TEXT, @@ -158,10 +163,10 @@ CREATE INDEX IF NOT EXISTS "idx_purchase_pl_channel_4cfc1c" ON "purchase_plan_ch CREATE INDEX IF NOT EXISTS "idx_purchase_pl_purchas_d20693" ON "purchase_plan_channel" ("purchase_plan_id"); COMMENT ON COLUMN "purchase_plan_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed'; CREATE TABLE IF NOT EXISTS "subscription" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "invite_link" VARCHAR(512) NOT NULL, "status" VARCHAR(12) NOT NULL DEFAULT 'active', "unsubscribed_at" TIMESTAMPTZ, @@ -173,10 +178,10 @@ CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" (" CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id"); COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; CREATE TABLE IF NOT EXISTS "workspace_invite" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "status" VARCHAR(8) NOT NULL DEFAULT 'pending', "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, @@ -188,10 +193,10 @@ CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id"); COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked'; CREATE TABLE IF NOT EXISTS "workspace_user" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, "status" VARCHAR(7) NOT NULL DEFAULT 'active', "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, @@ -199,32 +204,32 @@ CREATE TABLE IF NOT EXISTS "workspace_user" ( ); CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id"); CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id"); -COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nREMOVED: removed'; +COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked'; CREATE TABLE IF NOT EXISTS "workspace_user_permission" ( - "id" SERIAL NOT NULL PRIMARY KEY, + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "permission" VARCHAR(16) NOT NULL, + "permission" VARCHAR(24) NOT NULL, "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") ); CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id"); -COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'PROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nADMIN_FULL: admin_full'; +COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" ( + "id" UUID NOT NULL PRIMARY KEY, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "permission" VARCHAR(16) NOT NULL, + "permission" VARCHAR(24) NOT NULL, "scope_type" VARCHAR(9) NOT NULL, "scope_id" UUID NOT NULL, "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, CONSTRAINT "uid_workspace_u_workspa_488ed3" UNIQUE ("workspace_user_id", "permission", "scope_type", "scope_id") ); CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id"); -COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'PROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nADMIN_FULL: admin_full'; -COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project'; +COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; +COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'PROJECT: project\nCREATIVE: creative\nPLACEMENT: placement\nCHANNEL: channel'; CREATE TABLE IF NOT EXISTS "aerich" ( "id" SERIAL NOT NULL PRIMARY KEY, "version" VARCHAR(255) NOT NULL, @@ -239,73 +244,74 @@ async def downgrade(db: BaseDBAsyncClient) -> str: MODELS_STATE = ( - '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' - 'IXWrBwut7SW8G0cI/f4/Nf+ymg==' + "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" + "HuO2Ldj/vOfUe41vuO1vYR3o2bjX7/H/FMJEI=" ) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 8c53168..9099334 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -177,22 +177,17 @@ class Postgres(DatabaseBase): async def get_channel( self, - workspace_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None, username: str | None = None, ) -> domain.Channel | None: - query = domain.Channel.filter(workspace_id=workspace_id) if channel_id: - query = query.filter(id=channel_id) + return await domain.Channel.get_or_none(id=channel_id) if telegram_id: - query = query.filter(telegram_id=telegram_id) + return await domain.Channel.get_or_none(telegram_id=telegram_id) if username: - query = query.filter(username=username) - return await query.first() - - async def get_workspace_channels(self, workspace_id: uuid.UUID) -> list[domain.Channel]: - return await domain.Channel.filter(workspace_id=workspace_id).all() + return await domain.Channel.filter(username__iexact=username).first() + return None async def create_channel(self, channel: domain.Channel) -> None: await channel.save() @@ -345,7 +340,7 @@ class Postgres(DatabaseBase): async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: return await domain.Placement.get_or_none(id=placement_id, workspace_id=workspace_id).prefetch_related( - 'project', 'project__channel', 'placement_channel', 'creative' + 'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel' ) async def create_placement(self, placement: domain.Placement) -> None: @@ -380,7 +375,9 @@ class Postgres(DatabaseBase): query = query.filter(status=domain.PlacementStatus.ACTIVE) return ( - await query.prefetch_related('project', 'project__channel', 'placement_channel', 'creative') + await query.prefetch_related( + 'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel' + ) .order_by('-placement_date') .all() ) @@ -430,17 +427,28 @@ class Postgres(DatabaseBase): .first() ) - async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: + # Post methods + async def create_post(self, post: domain.Post) -> None: + await post.save() + + async def get_post(self, post_id: uuid.UUID) -> domain.Post | None: + return await domain.Post.get_or_none(id=post_id).prefetch_related('channel') + + async def get_post_by_channel_and_message(self, channel_id: uuid.UUID, message_id: int) -> domain.Post | None: + return await domain.Post.get_or_none(channel_id=channel_id, message_id=message_id).prefetch_related('channel') + + # Post views history + async def create_post_views_history(self, history: domain.PostViewsHistory) -> None: await history.save() async def get_views_history( self, - placement_id: uuid.UUID, + post_id: uuid.UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, - ) -> list[domain.PlacementViewsHistory]: - query = domain.PlacementViewsHistory.filter(placement_id=placement_id) + ) -> list[domain.PostViewsHistory]: + query = domain.PostViewsHistory.filter(post_id=post_id) if from_date: query = query.filter(fetched_at__gte=from_date) @@ -449,6 +457,26 @@ class Postgres(DatabaseBase): return await query.order_by('fetched_at').all() + async def get_latest_views_data(self, post_id: uuid.UUID) -> tuple[int, datetime.datetime] | None: + latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first() + if latest: + return (latest.views_count, latest.fetched_at) + return None + + async def get_latest_views_data_batch( + self, post_ids: list[uuid.UUID] + ) -> dict[uuid.UUID, tuple[int, datetime.datetime]]: + if not post_ids: + return {} + + results: dict[uuid.UUID, tuple[int, datetime.datetime]] = {} + for post_id in post_ids: + latest = await domain.PostViewsHistory.filter(post_id=post_id).order_by('-fetched_at').first() + if latest: + results[post_id] = (latest.views_count, latest.fetched_at) + + return results + async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: return await domain.TelegramState.get_or_none(telegram_id=telegram_id) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index c0b7b1c..0bf54f7 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -13,7 +13,6 @@ __all__ = ( 'PermissionKey', 'PermissionScopeType', 'Channel', - 'ChannelVerificationStatus', 'Project', 'ProjectStatus', 'PurchasePlan', @@ -22,8 +21,9 @@ __all__ = ( 'PurchasePlanChannelStatus', 'Creative', 'Placement', + 'Post', 'Subscription', - 'PlacementViewsHistory', + 'PostViewsHistory', 'TelegramState', 'TelegramStateEnum', 'ChannelNotFound', @@ -55,7 +55,7 @@ __all__ = ( 'UserByUsernameNotFound', ) -from .channel import Channel, ChannelVerificationStatus +from .channel import Channel from .creative import Creative, CreativeStatus from .error import ( ChannelAlreadyExists, @@ -80,7 +80,8 @@ from .error import ( ) from .login_token import LoginToken from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability -from .placement_views_history import PlacementViewsHistory +from .post import Post +from .post_views_history import PostViewsHistory from .project import Project, ProjectStatus from .purchase_plan import ( PurchasePlan, diff --git a/src/domain/base.py b/src/domain/base.py index e629692..5e7cbc2 100644 --- a/src/domain/base.py +++ b/src/domain/base.py @@ -3,6 +3,7 @@ from tortoise.models import Model class TimestampedModel(Model): + id = fields.UUIDField(pk=True) created_at = fields.DatetimeField(auto_now_add=True) updated_at = fields.DatetimeField(auto_now=True) deleted_at = fields.DatetimeField(null=True) diff --git a/src/domain/channel.py b/src/domain/channel.py index a4f4c38..5c08d92 100644 --- a/src/domain/channel.py +++ b/src/domain/channel.py @@ -1,34 +1,15 @@ -import enum -import uuid -from typing import TYPE_CHECKING - from tortoise import fields from .base import TimestampedModel -if TYPE_CHECKING: - from .workspace import Workspace - - -class ChannelVerificationStatus(str, enum.Enum): - UNVERIFIED = 'unverified' - VERIFIED = 'verified' - FAILED = 'failed' - class Channel(TimestampedModel): - id = fields.UUIDField(pk=True) telegram_id = fields.BigIntField(null=True, unique=True, index=True) - username = fields.CharField(max_length=255, null=True, index=True) - title = fields.CharField(max_length=255, null=True) - verification_status = fields.CharEnumField(ChannelVerificationStatus, default=ChannelVerificationStatus.UNVERIFIED) + title = fields.CharField(null=True, max_length=255) + username = fields.CharField(null=True, max_length=255, index=True) - workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( - 'models.Workspace', related_name='channels', on_delete=fields.CASCADE, index=True - ) - - if TYPE_CHECKING: - workspace_id: uuid.UUID + access_hash = fields.BigIntField(null=True) + pts = fields.IntField(null=True) class Meta: table = 'channel' diff --git a/src/domain/creative.py b/src/domain/creative.py index 93eecbb..6721a81 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -17,7 +17,6 @@ class CreativeStatus(str, enum.Enum): class Creative(TimestampedModel): - id = fields.UUIDField(pk=True) name = fields.CharField(max_length=255) text = fields.TextField() status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) diff --git a/src/domain/login_token.py b/src/domain/login_token.py index 24e4170..83695b0 100644 --- a/src/domain/login_token.py +++ b/src/domain/login_token.py @@ -10,7 +10,6 @@ if TYPE_CHECKING: class LoginToken(TimestampedModel): - id = fields.UUIDField(pk=True) token = fields.CharField(max_length=255, unique=True, index=True) user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( 'models.User', related_name='login_tokens', on_delete=fields.CASCADE diff --git a/src/domain/placement.py b/src/domain/placement.py index d11b018..fb2cd18 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -9,6 +9,7 @@ from .base import TimestampedModel if TYPE_CHECKING: from .channel import Channel from .creative import Creative + from .post import Post from .project import Project from .workspace import Workspace @@ -33,22 +34,14 @@ class PostViewsAvailability(str, enum.Enum): class Placement(TimestampedModel): - id = fields.UUIDField(pk=True) placement_date = fields.DatetimeField() cost = fields.FloatField(null=True) comment = fields.TextField(null=True) - ad_post_url = fields.CharField(max_length=512, null=True) invite_link_type = fields.CharEnumField(InviteLinkType) invite_link = fields.CharField(max_length=512) - external_channel_message_id = fields.IntField(null=True) status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) - subscriptions_count = fields.IntField(default=0) - views_count = fields.IntField(null=True) - - views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN) - last_views_fetch_at = fields.DatetimeField(null=True) project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True @@ -62,12 +55,16 @@ class Placement(TimestampedModel): workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( 'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True ) + post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( + 'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True + ) if TYPE_CHECKING: project_id: UUID placement_channel_id: UUID creative_id: UUID workspace_id: UUID + post_id: UUID | None class Meta: table = 'placement' diff --git a/src/domain/placement_views_history.py b/src/domain/placement_views_history.py deleted file mode 100644 index e1c945f..0000000 --- a/src/domain/placement_views_history.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import TYPE_CHECKING -from uuid import UUID - -from tortoise import fields - -from .base import TimestampedModel - -if TYPE_CHECKING: - from .placement import Placement - - -class PlacementViewsHistory(TimestampedModel): - id = fields.UUIDField(pk=True) - - views_count = fields.IntField() # Количество просмотров на момент снимка - fetched_at = fields.DatetimeField(index=True) - - placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( - 'models.Placement', related_name='views_histories', on_delete=fields.CASCADE, index=True - ) - - if TYPE_CHECKING: - placement_id: UUID - - class Meta: - table = 'placement_views_history' diff --git a/src/domain/post.py b/src/domain/post.py new file mode 100644 index 0000000..95b9098 --- /dev/null +++ b/src/domain/post.py @@ -0,0 +1,26 @@ +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .channel import Channel + + +class Post(TimestampedModel): + message_id = fields.IntField() + text = fields.TextField() + deleted_from_channel_at = fields.DatetimeField(null=True) + + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='posts', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + channel_id: uuid.UUID + + class Meta: + table = 'post' + unique_together = (('channel_id', 'message_id'),) diff --git a/src/domain/post_views_history.py b/src/domain/post_views_history.py new file mode 100644 index 0000000..d5238fc --- /dev/null +++ b/src/domain/post_views_history.py @@ -0,0 +1,24 @@ +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .post import Post + + +class PostViewsHistory(TimestampedModel): + views_count = fields.IntField() # Количество просмотров на момент снимка + fetched_at = fields.DatetimeField(index=True) + + post: fields.ForeignKeyRelation['Post'] = fields.ForeignKeyField( + 'models.Post', related_name='views_histories', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + post_id: UUID + + class Meta: + table = 'post_views_history' diff --git a/src/domain/project.py b/src/domain/project.py index cc0e420..273f0b3 100644 --- a/src/domain/project.py +++ b/src/domain/project.py @@ -18,7 +18,6 @@ class ProjectStatus(str, enum.Enum): class Project(TimestampedModel): - id = fields.UUIDField(pk=True) status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( diff --git a/src/domain/purchase_plan.py b/src/domain/purchase_plan.py index f2e7d5a..5ed4c64 100644 --- a/src/domain/purchase_plan.py +++ b/src/domain/purchase_plan.py @@ -26,7 +26,6 @@ class PurchasePlanChannelStatus(str, enum.Enum): class PurchasePlan(TimestampedModel): - id = fields.UUIDField(pk=True) status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE) workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( @@ -42,7 +41,6 @@ class PurchasePlan(TimestampedModel): class PurchasePlanChannel(TimestampedModel): - id = fields.UUIDField(pk=True) status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED) planned_cost = fields.FloatField(null=True) comment = fields.TextField(null=True) diff --git a/src/domain/subscription.py b/src/domain/subscription.py index a536ec7..ad6582e 100644 --- a/src/domain/subscription.py +++ b/src/domain/subscription.py @@ -17,7 +17,6 @@ class SubscriptionStatus(str, enum.Enum): class Subscription(TimestampedModel): - id = fields.UUIDField(pk=True) invite_link = fields.CharField(max_length=512, index=True) status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE) unsubscribed_at = fields.DatetimeField(null=True) diff --git a/src/domain/telegram_state.py b/src/domain/telegram_state.py index 87264ce..92d0497 100644 --- a/src/domain/telegram_state.py +++ b/src/domain/telegram_state.py @@ -14,7 +14,6 @@ class TelegramStateEnum(str, enum.Enum): class TelegramState(TimestampedModel): - id = fields.UUIDField(pk=True) telegram_id = fields.BigIntField(unique=True, index=True) state = fields.CharEnumField(TelegramStateEnum) context = fields.JSONField(default=dict) diff --git a/src/domain/user.py b/src/domain/user.py index 10da897..a6c2b49 100644 --- a/src/domain/user.py +++ b/src/domain/user.py @@ -4,7 +4,6 @@ from .base import TimestampedModel class User(TimestampedModel): - id = fields.UUIDField(pk=True) telegram_id = fields.BigIntField(unique=True, index=True) username = fields.CharField(max_length=255, null=True) first_name = fields.CharField(max_length=255, null=True) diff --git a/src/domain/workspace.py b/src/domain/workspace.py index e3711a7..82219bc 100644 --- a/src/domain/workspace.py +++ b/src/domain/workspace.py @@ -27,7 +27,6 @@ class WorkspaceUserStatus(str, enum.Enum): class WorkspaceUser(TimestampedModel): - id = fields.UUIDField(pk=True) status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE) workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( @@ -53,7 +52,6 @@ class WorkspaceInviteStatus(str, enum.Enum): class WorkspaceInvite(TimestampedModel): - id = fields.UUIDField(pk=True) status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING) workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( @@ -114,7 +112,6 @@ class WorkspaceUserPermission(TimestampedModel): class WorkspaceUserPermissionScope(TimestampedModel): - id = fields.UUIDField(pk=True) permission = fields.CharEnumField(PermissionKey) scope_type = fields.CharEnumField(PermissionScopeType) scope_id = fields.UUIDField() diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 98b193e..e3897af 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -31,7 +31,7 @@ __all__ = ( 'CreatePlacementInput', 'UpdatePlacementInput', 'DeletePlacementInput', - 'PlacementViewsHistoryOutput', + 'PostViewsHistoryOutput', 'GetViewsHistoryInput', 'GetViewsHistoryOutput', 'UpdateViewsManuallyInput', @@ -123,7 +123,7 @@ from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOut from .views import ( GetViewsHistoryInput, GetViewsHistoryOutput, - PlacementViewsHistoryOutput, + PostViewsHistoryOutput, UpdateViewsManuallyInput, ) from .workspace import ( diff --git a/src/dto/channel.py b/src/dto/channel.py index bf1a866..b897bff 100644 --- a/src/dto/channel.py +++ b/src/dto/channel.py @@ -2,15 +2,12 @@ import uuid import pydantic -from src.domain.channel import ChannelVerificationStatus - class ChannelOutput(pydantic.BaseModel): id: uuid.UUID telegram_id: int | None title: str | None username: str | None - verification_status: ChannelVerificationStatus class GetChannelsInput(pydantic.BaseModel): diff --git a/src/dto/views.py b/src/dto/views.py index 45ede91..193d032 100644 --- a/src/dto/views.py +++ b/src/dto/views.py @@ -4,11 +4,11 @@ import uuid import pydantic -class PlacementViewsHistoryOutput(pydantic.BaseModel): - """История просмотров placement.""" +class PostViewsHistoryOutput(pydantic.BaseModel): + """История просмотров поста.""" id: uuid.UUID - placement_id: uuid.UUID + post_id: uuid.UUID views_count: int fetched_at: datetime.datetime created_at: datetime.datetime @@ -27,7 +27,7 @@ class GetViewsHistoryInput(pydantic.BaseModel): class GetViewsHistoryOutput(pydantic.BaseModel): """История просмотров.""" - histories: list[PlacementViewsHistoryOutput] + histories: list[PostViewsHistoryOutput] class UpdateViewsManuallyInput(pydantic.BaseModel): diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 7665b6e..0847509 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -122,14 +122,11 @@ class Database(typing.Protocol): async def get_channel( self, - workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None, username: str | None = None, ) -> domain.Channel | None: ... - async def get_workspace_channels(self, workspace_id: UUID) -> list[domain.Channel]: ... - async def create_channel(self, channel: domain.Channel) -> None: ... async def update_channel(self, channel: domain.Channel) -> None: ... @@ -232,15 +229,27 @@ class Database(typing.Protocol): self, subscriber_id: UUID, project_id: UUID ) -> domain.Subscription | None: ... - async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ... + # Post methods + async def create_post(self, post: domain.Post) -> None: ... + + async def get_post(self, post_id: UUID) -> domain.Post | None: ... + + async def get_post_by_channel_and_message(self, channel_id: UUID, message_id: int) -> domain.Post | None: ... + + # Post views history + async def create_post_views_history(self, history: domain.PostViewsHistory) -> None: ... async def get_views_history( self, - placement_id: UUID, + post_id: UUID, *, from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, - ) -> list[domain.PlacementViewsHistory]: ... + ) -> list[domain.PostViewsHistory]: ... + + async def get_latest_views_data(self, post_id: UUID) -> tuple[int, datetime.datetime] | None: ... + + async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ... async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ... diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py index 9ab8c2b..2e40e1e 100644 --- a/src/usecase/analytics/get_channel_analytics.py +++ b/src/usecase/analytics/get_channel_analytics.py @@ -30,7 +30,9 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI else: allowed_project_filter = allowed_project_ids if allowed_project_ids is None: - channels = await self.database.get_workspace_channels(input.workspace_id) + # Получить каналы через Projects + projects = await self.database.get_workspace_projects(input.workspace_id) + channels = [p.channel for p in projects] else: channels = [] @@ -57,6 +59,10 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in channels} + # Batch fetch views data for all posts + 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 {} + for p in placements: stats = channel_stats.get(p.placement_channel_id) if stats is None: @@ -64,7 +70,12 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI stats.total_cost += p.cost if p.cost is not None else 0 stats.total_subscriptions += p.subscriptions_count - stats.total_views += p.views_count if p.views_count is not None else 0 + + # Get views from batch data + if p.post and p.post.id in views_map: + views_count = views_map[p.post.id][0] + stats.total_views += views_count + stats.placements_count += 1 result = [] diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 68681fd..00d6bf9 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -49,6 +49,10 @@ async def get_creatives_analytics( creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives} + # Batch fetch views data for all posts + 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 {} + for p in placements: stats = creative_stats.get(p.creative_id) if stats is None: @@ -56,7 +60,12 @@ async def get_creatives_analytics( stats.total_cost += p.cost if p.cost is not None else 0 stats.total_subscriptions += p.subscriptions_count - stats.total_views += p.views_count if p.views_count is not None else 0 + + # Get views from batch data + if p.post and p.post.id in views_map: + views_count = views_map[p.post.id][0] + stats.total_views += views_count + stats.placements_count += 1 result = [] diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index e1706f4..28e04a6 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -44,6 +44,10 @@ async def get_placements_analytics( allowed_project_ids=allowed_project_ids, ) + # Batch fetch views data for all posts + 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 {} + result = [ dto.PlacementAnalyticsOutput( id=p.id, @@ -56,9 +60,9 @@ async def get_placements_analytics( placement_date=p.placement_date, cost=p.cost, subscriptions_count=p.subscriptions_count, - views_count=p.views_count, + views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None, cpf=_calculate_cpf(p.cost, p.subscriptions_count), - cpm=_calculate_cpm(p.cost, p.views_count), + cpm=_calculate_cpm(p.cost, views_map[p.post.id][0] if p.post and p.post.id in views_map else None), ) for p in placements ] diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index bd6ae8e..053a7a4 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -70,6 +70,10 @@ async def get_spending_analytics( subscriptions: int = 0 views: int = 0 + # Batch fetch views data for all posts + post_ids = [p.post.id for p in filtered if p.post] + views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} + # Группировка по периодам period_data: dict[str, PeriodData] = defaultdict(PeriodData) @@ -84,9 +88,11 @@ async def get_spending_analytics( total_subs += p.subscriptions_count pd.subscriptions += p.subscriptions_count - if p.views_count is not None: - total_views += p.views_count - pd.views += p.views_count + # Get views from batch data + if p.post and p.post.id in views_map: + views_count = views_map[p.post.id][0] + total_views += views_count + pd.views += views_count avg_cpf = total_cost / total_subs if total_subs > 0 and total_cost > 0 else None avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py index 5696c48..649082a 100644 --- a/src/usecase/channel/get_channels.py +++ b/src/usecase/channel/get_channels.py @@ -21,7 +21,6 @@ async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> dto.GetC telegram_id=channel.telegram_id, title=channel.title, username=channel.username, - verification_status=channel.verification_status, ) for channel in channels ] diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index 6af83eb..b56e814 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -17,9 +17,7 @@ async def update_creative( user_id: uuid.UUID, workspace_id: uuid.UUID, ) -> dto.CreativeOutput: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE) creative = await self.database.get_creative(workspace_id, creative_id) if not creative: diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index 26fad5f..27978ba 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -24,7 +24,7 @@ async def create_placement( if not project: raise domain.ProjectNotFound(input.project_id) - placement_channel = await self.database.get_channel(workspace_id, channel_id=input.placement_channel_id) + placement_channel = await self.database.get_channel(channel_id=input.placement_channel_id) if not placement_channel: raise domain.ChannelNotFound(input.placement_channel_id) @@ -39,6 +39,8 @@ async def create_placement( if project.channel.telegram_id is None: raise domain.ChannelNotFound(project.channel.id) + print(f'Телега id: {project.channel.telegram_id}') + invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) placement = domain.Placement( @@ -71,13 +73,13 @@ async def create_placement( placement_date=placement.placement_date, cost=placement.cost, comment=placement.comment, - ad_post_url=placement.ad_post_url, + ad_post_url=None, invite_link_type=placement.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=placement.subscriptions_count, - views_count=placement.views_count, - views_availability=placement.views_availability, - last_views_fetch_at=placement.last_views_fetch_at, + views_count=None, + views_availability=domain.PostViewsAvailability.UNKNOWN, + last_views_fetch_at=None, created_at=placement.created_at, ) diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py index ffe33b0..b3826a3 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -3,6 +3,8 @@ from typing import TYPE_CHECKING from src import domain, dto +from .helpers import generate_post_url + if TYPE_CHECKING: from .. import Usecase @@ -21,6 +23,21 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + # Compute post-related fields + ad_post_url = None + views_count = None + views_availability = domain.PostViewsAvailability.UNKNOWN + last_views_fetch_at = None + + if placement.post: + ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id) + + # Get latest views data + views_data = await self.database.get_latest_views_data(placement.post.id) + if views_data: + views_count, last_views_fetch_at = views_data + views_availability = domain.PostViewsAvailability.AVAILABLE + return dto.PlacementOutput( id=placement.id, project_id=placement.project_id, @@ -32,13 +49,13 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl placement_date=placement.placement_date, cost=placement.cost, comment=placement.comment, - ad_post_url=placement.ad_post_url, + ad_post_url=ad_post_url, invite_link_type=placement.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=placement.subscriptions_count, - views_count=placement.views_count, - views_availability=placement.views_availability, - last_views_fetch_at=placement.last_views_fetch_at, + views_count=views_count, + views_availability=views_availability, + last_views_fetch_at=last_views_fetch_at, created_at=placement.created_at, ) diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py index d2e8c84..a2cc738 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING from src import domain, dto +from .helpers import generate_post_url + if TYPE_CHECKING: from .. import Usecase @@ -26,8 +28,27 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. allowed_project_ids=allowed_project_ids, ) - return dto.GetPlacementsOutput( - placements=[ + # Batch fetch views data for all posts + 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 {} + + # Build output DTOs + placement_outputs = [] + for placement in placements: + # Compute post-related fields + ad_post_url = None + views_count = None + views_availability = domain.PostViewsAvailability.UNKNOWN + last_views_fetch_at = None + + if placement.post: + ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id) + views_data = views_map.get(placement.post.id) + if views_data: + views_count, last_views_fetch_at = views_data + views_availability = domain.PostViewsAvailability.AVAILABLE + + placement_outputs.append( dto.PlacementOutput( id=placement.id, project_id=placement.project_id, @@ -39,16 +60,16 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. placement_date=placement.placement_date, cost=placement.cost, comment=placement.comment, - ad_post_url=placement.ad_post_url, + ad_post_url=ad_post_url, invite_link_type=placement.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=placement.subscriptions_count, - views_count=placement.views_count, - views_availability=placement.views_availability, - last_views_fetch_at=placement.last_views_fetch_at, + views_count=views_count, + views_availability=views_availability, + last_views_fetch_at=last_views_fetch_at, created_at=placement.created_at, ) - for placement in placements - ] - ) + ) + + return dto.GetPlacementsOutput(placements=placement_outputs) diff --git a/src/usecase/placement/helpers.py b/src/usecase/placement/helpers.py new file mode 100644 index 0000000..25b2be0 --- /dev/null +++ b/src/usecase/placement/helpers.py @@ -0,0 +1,7 @@ +def generate_post_url(channel_username: str | None, message_id: int) -> str | None: + """Generate Telegram post URL from channel username and message ID.""" + if not channel_username: + return None + # Remove @ prefix if present + username = channel_username.lstrip('@') + return f'https://t.me/{username}/{message_id}' diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index e1386b7..2214722 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING from src import domain, dto +from .helpers import generate_post_url + if TYPE_CHECKING: from .. import Usecase @@ -17,9 +19,7 @@ async def update_placement( user_id: uuid.UUID, workspace_id: uuid.UUID, ) -> dto.PlacementOutput: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) placement = await self.database.get_placement(workspace_id, placement_id) if not placement: @@ -39,6 +39,21 @@ async def update_placement( await self.database.update_placement(placement) + # Compute post-related fields + ad_post_url = None + views_count = None + views_availability = domain.PostViewsAvailability.UNKNOWN + last_views_fetch_at = None + + if placement.post: + ad_post_url = generate_post_url(placement.post.channel.username, placement.post.message_id) + + # Get latest views data + views_data = await self.database.get_latest_views_data(placement.post.id) + if views_data: + views_count, last_views_fetch_at = views_data + views_availability = domain.PostViewsAvailability.AVAILABLE + return dto.PlacementOutput( id=placement.id, project_id=placement.project_id, @@ -50,13 +65,13 @@ async def update_placement( placement_date=placement.placement_date, cost=placement.cost, comment=placement.comment, - ad_post_url=placement.ad_post_url, + ad_post_url=ad_post_url, invite_link_type=placement.invite_link_type, invite_link=placement.invite_link, status=placement.status, subscriptions_count=placement.subscriptions_count, - views_count=placement.views_count, - views_availability=placement.views_availability, - last_views_fetch_at=placement.last_views_fetch_at, + views_count=views_count, + views_availability=views_availability, + last_views_fetch_at=last_views_fetch_at, created_at=placement.created_at, ) diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py index 1872db8..2800ab2 100644 --- a/src/usecase/project/get_workspace_projects.py +++ b/src/usecase/project/get_workspace_projects.py @@ -15,9 +15,7 @@ async def get_workspace_projects( allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ) - projects = await self.database.get_workspace_projects( - input.workspace_id, allowed_project_ids=allowed_project_ids - ) + projects = await self.database.get_workspace_projects(input.workspace_id, allowed_project_ids=allowed_project_ids) return dto.GetWorkspaceProjectsOutput( projects=[ diff --git a/src/usecase/project/tg_add_project_1.py b/src/usecase/project/tg_add_project_1.py index 2c76aba..9df2d37 100644 --- a/src/usecase/project/tg_add_project_1.py +++ b/src/usecase/project/tg_add_project_1.py @@ -70,7 +70,7 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d if len(workspaces) == 1: workspace = workspaces[0] - channel = await self.database.get_channel(workspace.id, telegram_id=input.telegram_id) + channel = await self.database.get_channel(telegram_id=input.telegram_id) if channel: channel.title = input.title channel.username = input.username @@ -80,8 +80,6 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d telegram_id=input.telegram_id, title=input.title, username=input.username, - verification_status=domain.ChannelVerificationStatus.VERIFIED, - workspace_id=workspace.id, ) await self.database.create_channel(channel) diff --git a/src/usecase/project/tg_add_project_2.py b/src/usecase/project/tg_add_project_2.py index 24cc2a4..9c0dd3d 100644 --- a/src/usecase/project/tg_add_project_2.py +++ b/src/usecase/project/tg_add_project_2.py @@ -66,7 +66,7 @@ async def tg_add_project_2( return telegram_channel_id = int(channel_data['telegram_id']) - channel = await self.database.get_channel(workspace.id, telegram_id=telegram_channel_id) + channel = await self.database.get_channel(telegram_id=telegram_channel_id) if channel: channel.title = str(channel_data['title']) channel.username = channel_data.get('username') @@ -76,7 +76,6 @@ async def tg_add_project_2( telegram_id=telegram_channel_id, title=str(channel_data['title']), username=channel_data.get('username'), - workspace_id=workspace.id, ) await self.database.create_channel(channel) diff --git a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py index c91179a..ea17133 100644 --- a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py +++ b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py @@ -17,9 +17,7 @@ async def attach_channel_to_purchase_plan( user_id: uuid.UUID, input: dto.AttachChannelToPurchasePlanInput, ) -> dto.PurchasePlanChannelOutput: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: @@ -30,21 +28,15 @@ async def attach_channel_to_purchase_plan( plan = await self.ensure_active_purchase_plan(project) # Поиск канала по username - channel = await self.database.get_channel(workspace_id, username=input.username) + channel = await self.database.get_channel(username=input.username) if not channel: - # Создать неподтвержденный канал - channel = domain.Channel( - workspace_id=workspace_id, - username=input.username, - telegram_id=None, - title=None, - verification_status=domain.ChannelVerificationStatus.UNVERIFIED, - ) + # Создать канал с nullable telegram_id/title (заполнятся парсером позже) + channel = domain.Channel(username=input.username) await self.database.create_channel(channel) - log.info('Created unverified channel with username %s', input.username) + log.info('Created channel with username @%s (pending parser update)', input.username) else: - log.info('Found existing channel %s for username %s', channel.id, input.username) + log.info('Found existing channel %s for username @%s', channel.id, input.username) plan_channel = await self.database.add_channel_to_purchase_plan( plan.id, @@ -66,6 +58,5 @@ async def attach_channel_to_purchase_plan( telegram_id=channel.telegram_id, title=channel.title, username=channel.username, - verification_status=channel.verification_status, ), ) diff --git a/src/usecase/purchase_plan/get_purchase_plan_channels.py b/src/usecase/purchase_plan/get_purchase_plan_channels.py index 16ae8d8..d44368c 100644 --- a/src/usecase/purchase_plan/get_purchase_plan_channels.py +++ b/src/usecase/purchase_plan/get_purchase_plan_channels.py @@ -39,7 +39,6 @@ async def get_purchase_plan_channels( telegram_id=plan_channel.channel.telegram_id, title=plan_channel.channel.title, username=plan_channel.channel.username, - verification_status=plan_channel.channel.verification_status, ), ) for plan_channel in plan_channels diff --git a/src/usecase/purchase_plan/remove_purchase_plan_channel.py b/src/usecase/purchase_plan/remove_purchase_plan_channel.py index 95bcde7..f861ea0 100644 --- a/src/usecase/purchase_plan/remove_purchase_plan_channel.py +++ b/src/usecase/purchase_plan/remove_purchase_plan_channel.py @@ -17,9 +17,7 @@ async def remove_purchase_plan_channel( workspace_id: uuid.UUID, user_id: uuid.UUID, ) -> None: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: diff --git a/src/usecase/purchase_plan/update_purchase_plan_channel.py b/src/usecase/purchase_plan/update_purchase_plan_channel.py index 4142c92..fab9f3f 100644 --- a/src/usecase/purchase_plan/update_purchase_plan_channel.py +++ b/src/usecase/purchase_plan/update_purchase_plan_channel.py @@ -18,9 +18,7 @@ async def update_purchase_plan_channel( user_id: uuid.UUID, input: dto.UpdatePurchasePlanChannelInput, ) -> dto.PurchasePlanChannelOutput: - context = await self.ensure_workspace_permission( - workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE - ) + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: @@ -56,7 +54,7 @@ async def update_purchase_plan_channel( channel: domain.Channel | None = plan_channel.channel if channel is None: - channel = await self.database.get_channel(workspace_id, channel_id=plan_channel.channel_id) + channel = await self.database.get_channel(channel_id=plan_channel.channel_id) if channel is None: raise domain.ChannelNotFound(plan_channel.channel_id) @@ -70,6 +68,5 @@ async def update_purchase_plan_channel( telegram_id=channel.telegram_id, title=channel.title, username=channel.username, - verification_status=channel.verification_status, ), ) diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py index 7f94dda..a22c026 100644 --- a/src/usecase/views/get_views_history.py +++ b/src/usecase/views/get_views_history.py @@ -21,17 +21,20 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + if not placement.post: + return dto.GetViewsHistoryOutput(histories=[]) + histories = await self.database.get_views_history( - input.placement_id, + placement.post.id, from_date=input.from_date, to_date=input.to_date, ) return dto.GetViewsHistoryOutput( histories=[ - dto.PlacementViewsHistoryOutput( + dto.PostViewsHistoryOutput( id=history.id, - placement_id=history.placement_id, + post_id=history.post_id, views_count=history.views_count, fetched_at=history.fetched_at, created_at=history.created_at, diff --git a/src/usecase/workspace/get_workspace_invites.py b/src/usecase/workspace/get_workspace_invites.py index ecdacd6..6182ec9 100644 --- a/src/usecase/workspace/get_workspace_invites.py +++ b/src/usecase/workspace/get_workspace_invites.py @@ -17,6 +17,4 @@ async def get_workspace_invites( invites = await self.database.get_workspace_invites(workspace_id) - return dto.GetWorkspaceInvitesOutput( - invites=[workspace_invite_to_dto(invite) for invite in invites] - ) + return dto.GetWorkspaceInvitesOutput(invites=[workspace_invite_to_dto(invite) for invite in invites]) diff --git a/src/usecase/workspace/get_workspace_members.py b/src/usecase/workspace/get_workspace_members.py index a91eb22..1c54639 100644 --- a/src/usecase/workspace/get_workspace_members.py +++ b/src/usecase/workspace/get_workspace_members.py @@ -17,6 +17,4 @@ async def get_workspace_members( members = await self.database.get_workspace_members(workspace_id) - return dto.GetWorkspaceMembersOutput( - members=[workspace_member_to_dto(member) for member in members] - ) + return dto.GetWorkspaceMembersOutput(members=[workspace_member_to_dto(member) for member in members])