90% сеньоров плачат на таких коммитах

This commit is contained in:
Artem Tsyrulnikov
2025-12-15 19:10:41 +03:00
parent fda91cb6d8
commit 71e5a38efc
42 changed files with 403 additions and 302 deletions

View File

@@ -1,16 +1,51 @@
from tortoise import BaseDBAsyncClient from tortoise import BaseDBAsyncClient
# ruff: noqa
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str: async def upgrade(db: BaseDBAsyncClient) -> str:
return """ 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, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ, "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, "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, "telegram_id" BIGINT NOT NULL UNIQUE,
"state" VARCHAR(26) NOT NULL, "state" VARCHAR(26) NOT NULL,
"context" JSONB 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"); 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'; 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" ( CREATE TABLE IF NOT EXISTS "user" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"telegram_id" BIGINT NOT NULL UNIQUE, "telegram_id" BIGINT NOT NULL UNIQUE,
"username" VARCHAR(255), "username" VARCHAR(255),
"first_name" 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 INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id");
CREATE TABLE IF NOT EXISTS "login_token" ( CREATE TABLE IF NOT EXISTS "login_token" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"token" VARCHAR(255) NOT NULL UNIQUE, "token" VARCHAR(255) NOT NULL UNIQUE,
"expires_at" TIMESTAMPTZ NOT NULL, "expires_at" TIMESTAMPTZ NOT NULL,
"used_at" TIMESTAMPTZ, "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 INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token");
CREATE TABLE IF NOT EXISTS "workspace" ( CREATE TABLE IF NOT EXISTS "workspace" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL "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" ( CREATE TABLE IF NOT EXISTS "project" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"status" VARCHAR(8) NOT NULL DEFAULT 'active', "status" VARCHAR(8) NOT NULL DEFAULT 'active',
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("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"); 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'; COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "creative" ( CREATE TABLE IF NOT EXISTS "creative" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"name" VARCHAR(255) NOT NULL, "name" VARCHAR(255) NOT NULL,
"text" TEXT NOT NULL, "text" TEXT NOT NULL,
"status" VARCHAR(8) NOT NULL DEFAULT 'active', "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"); CREATE INDEX IF NOT EXISTS "idx_creative_workspa_2de546" ON "creative" ("workspace_id");
COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "placement" ( CREATE TABLE IF NOT EXISTS "placement" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"placement_date" TIMESTAMPTZ NOT NULL, "placement_date" TIMESTAMPTZ NOT NULL,
"cost" DOUBLE PRECISION, "cost" DOUBLE PRECISION,
"comment" TEXT, "comment" TEXT,
"ad_post_url" VARCHAR(512),
"invite_link_type" VARCHAR(8) NOT NULL, "invite_link_type" VARCHAR(8) NOT NULL,
"invite_link" VARCHAR(512) NOT NULL, "invite_link" VARCHAR(512) NOT NULL,
"external_channel_message_id" INT,
"status" VARCHAR(8) NOT NULL DEFAULT 'active', "status" VARCHAR(8) NOT NULL DEFAULT 'active',
"subscriptions_count" INT NOT NULL DEFAULT 0, "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, "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
"placement_channel_id" UUID NOT NULL REFERENCES "channel" ("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, "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("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_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_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_project_2c6aa4" ON "placement" ("project_id");
CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_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"."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"."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" ( CREATE TABLE IF NOT EXISTS "purchase_plan" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"status" VARCHAR(8) NOT NULL DEFAULT 'active', "status" VARCHAR(8) NOT NULL DEFAULT 'active',
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("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"); 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'; COMMENT ON COLUMN "purchase_plan"."status" IS 'ACTIVE: active\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "purchase_plan_channel" ( CREATE TABLE IF NOT EXISTS "purchase_plan_channel" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"status" VARCHAR(11) NOT NULL DEFAULT 'planned', "status" VARCHAR(11) NOT NULL DEFAULT 'planned',
"planned_cost" DOUBLE PRECISION, "planned_cost" DOUBLE PRECISION,
"comment" TEXT, "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"); 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'; 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" ( CREATE TABLE IF NOT EXISTS "subscription" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"invite_link" VARCHAR(512) NOT NULL, "invite_link" VARCHAR(512) NOT NULL,
"status" VARCHAR(12) NOT NULL DEFAULT 'active', "status" VARCHAR(12) NOT NULL DEFAULT 'active',
"unsubscribed_at" TIMESTAMPTZ, "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"); CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id");
COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed';
CREATE TABLE IF NOT EXISTS "workspace_invite" ( CREATE TABLE IF NOT EXISTS "workspace_invite" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"status" VARCHAR(8) NOT NULL DEFAULT 'pending', "status" VARCHAR(8) NOT NULL DEFAULT 'pending',
"invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"user_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"); 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'; COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked';
CREATE TABLE IF NOT EXISTS "workspace_user" ( CREATE TABLE IF NOT EXISTS "workspace_user" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY,
"status" VARCHAR(7) NOT NULL DEFAULT 'active', "status" VARCHAR(7) NOT NULL DEFAULT 'active',
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("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_user_id_246318" ON "workspace_user" ("user_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_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" ( 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, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ, "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, "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") 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"); 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" ( CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
"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,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY, "permission" VARCHAR(24) NOT NULL,
"permission" VARCHAR(16) NOT NULL,
"scope_type" VARCHAR(9) NOT NULL, "scope_type" VARCHAR(9) NOT NULL,
"scope_id" UUID NOT NULL, "scope_id" UUID NOT NULL,
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, "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") 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"); 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"."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 'WORKSPACE: workspace\nPROJECT: project'; COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'PROJECT: project\nCREATIVE: creative\nPLACEMENT: placement\nCHANNEL: channel';
CREATE TABLE IF NOT EXISTS "aerich" ( CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY, "id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL, "version" VARCHAR(255) NOT NULL,
@@ -239,73 +244,74 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
MODELS_STATE = ( MODELS_STATE = (
'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"
'IXWrBwut7SW8G0cI/f4/Nf+ymg==' "T7teeG4vJc31z43AnSwLvCA9eWXKRi9QZc+jSQU/J3Ro5ADRKb6dAB4dHmYAkJRKvnqb5j"
"HuO2Ldj/vOfUe41vuO1vYR3o2bjX7/H/FMJEI="
) )

View File

@@ -177,22 +177,17 @@ class Postgres(DatabaseBase):
async def get_channel( async def get_channel(
self, self,
workspace_id: uuid.UUID,
channel_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None,
telegram_id: int | None = None, telegram_id: int | None = None,
username: str | None = None, username: str | None = None,
) -> domain.Channel | None: ) -> domain.Channel | None:
query = domain.Channel.filter(workspace_id=workspace_id)
if channel_id: if channel_id:
query = query.filter(id=channel_id) return await domain.Channel.get_or_none(id=channel_id)
if telegram_id: if telegram_id:
query = query.filter(telegram_id=telegram_id) return await domain.Channel.get_or_none(telegram_id=telegram_id)
if username: if username:
query = query.filter(username=username) return await domain.Channel.filter(username__iexact=username).first()
return await query.first() return None
async def get_workspace_channels(self, workspace_id: uuid.UUID) -> list[domain.Channel]:
return await domain.Channel.filter(workspace_id=workspace_id).all()
async def create_channel(self, channel: domain.Channel) -> None: async def create_channel(self, channel: domain.Channel) -> None:
await channel.save() 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: 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( 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: async def create_placement(self, placement: domain.Placement) -> None:
@@ -380,7 +375,9 @@ class Postgres(DatabaseBase):
query = query.filter(status=domain.PlacementStatus.ACTIVE) query = query.filter(status=domain.PlacementStatus.ACTIVE)
return ( 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') .order_by('-placement_date')
.all() .all()
) )
@@ -430,17 +427,28 @@ class Postgres(DatabaseBase):
.first() .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() await history.save()
async def get_views_history( async def get_views_history(
self, self,
placement_id: uuid.UUID, post_id: uuid.UUID,
*, *,
from_date: datetime.datetime | None = None, from_date: datetime.datetime | None = None,
to_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None,
) -> list[domain.PlacementViewsHistory]: ) -> list[domain.PostViewsHistory]:
query = domain.PlacementViewsHistory.filter(placement_id=placement_id) query = domain.PostViewsHistory.filter(post_id=post_id)
if from_date: if from_date:
query = query.filter(fetched_at__gte=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() 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: async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
return await domain.TelegramState.get_or_none(telegram_id=telegram_id) return await domain.TelegramState.get_or_none(telegram_id=telegram_id)

View File

@@ -13,7 +13,6 @@ __all__ = (
'PermissionKey', 'PermissionKey',
'PermissionScopeType', 'PermissionScopeType',
'Channel', 'Channel',
'ChannelVerificationStatus',
'Project', 'Project',
'ProjectStatus', 'ProjectStatus',
'PurchasePlan', 'PurchasePlan',
@@ -22,8 +21,9 @@ __all__ = (
'PurchasePlanChannelStatus', 'PurchasePlanChannelStatus',
'Creative', 'Creative',
'Placement', 'Placement',
'Post',
'Subscription', 'Subscription',
'PlacementViewsHistory', 'PostViewsHistory',
'TelegramState', 'TelegramState',
'TelegramStateEnum', 'TelegramStateEnum',
'ChannelNotFound', 'ChannelNotFound',
@@ -55,7 +55,7 @@ __all__ = (
'UserByUsernameNotFound', 'UserByUsernameNotFound',
) )
from .channel import Channel, ChannelVerificationStatus from .channel import Channel
from .creative import Creative, CreativeStatus from .creative import Creative, CreativeStatus
from .error import ( from .error import (
ChannelAlreadyExists, ChannelAlreadyExists,
@@ -80,7 +80,8 @@ from .error import (
) )
from .login_token import LoginToken from .login_token import LoginToken
from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability 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 .project import Project, ProjectStatus
from .purchase_plan import ( from .purchase_plan import (
PurchasePlan, PurchasePlan,

View File

@@ -3,6 +3,7 @@ from tortoise.models import Model
class TimestampedModel(Model): class TimestampedModel(Model):
id = fields.UUIDField(pk=True)
created_at = fields.DatetimeField(auto_now_add=True) created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True) updated_at = fields.DatetimeField(auto_now=True)
deleted_at = fields.DatetimeField(null=True) deleted_at = fields.DatetimeField(null=True)

View File

@@ -1,34 +1,15 @@
import enum
import uuid
from typing import TYPE_CHECKING
from tortoise import fields from tortoise import fields
from .base import TimestampedModel 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): class Channel(TimestampedModel):
id = fields.UUIDField(pk=True)
telegram_id = fields.BigIntField(null=True, unique=True, index=True) telegram_id = fields.BigIntField(null=True, unique=True, index=True)
username = fields.CharField(max_length=255, null=True, index=True) title = fields.CharField(null=True, max_length=255)
title = fields.CharField(max_length=255, null=True) username = fields.CharField(null=True, max_length=255, index=True)
verification_status = fields.CharEnumField(ChannelVerificationStatus, default=ChannelVerificationStatus.UNVERIFIED)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( access_hash = fields.BigIntField(null=True)
'models.Workspace', related_name='channels', on_delete=fields.CASCADE, index=True pts = fields.IntField(null=True)
)
if TYPE_CHECKING:
workspace_id: uuid.UUID
class Meta: class Meta:
table = 'channel' table = 'channel'

View File

@@ -17,7 +17,6 @@ class CreativeStatus(str, enum.Enum):
class Creative(TimestampedModel): class Creative(TimestampedModel):
id = fields.UUIDField(pk=True)
name = fields.CharField(max_length=255) name = fields.CharField(max_length=255)
text = fields.TextField() text = fields.TextField()
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)

View File

@@ -10,7 +10,6 @@ if TYPE_CHECKING:
class LoginToken(TimestampedModel): class LoginToken(TimestampedModel):
id = fields.UUIDField(pk=True)
token = fields.CharField(max_length=255, unique=True, index=True) token = fields.CharField(max_length=255, unique=True, index=True)
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
'models.User', related_name='login_tokens', on_delete=fields.CASCADE 'models.User', related_name='login_tokens', on_delete=fields.CASCADE

View File

@@ -9,6 +9,7 @@ from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .channel import Channel from .channel import Channel
from .creative import Creative from .creative import Creative
from .post import Post
from .project import Project from .project import Project
from .workspace import Workspace from .workspace import Workspace
@@ -33,22 +34,14 @@ class PostViewsAvailability(str, enum.Enum):
class Placement(TimestampedModel): class Placement(TimestampedModel):
id = fields.UUIDField(pk=True)
placement_date = fields.DatetimeField() placement_date = fields.DatetimeField()
cost = fields.FloatField(null=True) cost = fields.FloatField(null=True)
comment = fields.TextField(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_type = fields.CharEnumField(InviteLinkType)
invite_link = fields.CharField(max_length=512) invite_link = fields.CharField(max_length=512)
external_channel_message_id = fields.IntField(null=True)
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
subscriptions_count = fields.IntField(default=0) 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( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
@@ -62,12 +55,16 @@ class Placement(TimestampedModel):
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True '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: if TYPE_CHECKING:
project_id: UUID project_id: UUID
placement_channel_id: UUID placement_channel_id: UUID
creative_id: UUID creative_id: UUID
workspace_id: UUID workspace_id: UUID
post_id: UUID | None
class Meta: class Meta:
table = 'placement' table = 'placement'

View File

@@ -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'

26
src/domain/post.py Normal file
View File

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

View File

@@ -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'

View File

@@ -18,7 +18,6 @@ class ProjectStatus(str, enum.Enum):
class Project(TimestampedModel): class Project(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(

View File

@@ -26,7 +26,6 @@ class PurchasePlanChannelStatus(str, enum.Enum):
class PurchasePlan(TimestampedModel): class PurchasePlan(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE) status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
@@ -42,7 +41,6 @@ class PurchasePlan(TimestampedModel):
class PurchasePlanChannel(TimestampedModel): class PurchasePlanChannel(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED) status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED)
planned_cost = fields.FloatField(null=True) planned_cost = fields.FloatField(null=True)
comment = fields.TextField(null=True) comment = fields.TextField(null=True)

View File

@@ -17,7 +17,6 @@ class SubscriptionStatus(str, enum.Enum):
class Subscription(TimestampedModel): class Subscription(TimestampedModel):
id = fields.UUIDField(pk=True)
invite_link = fields.CharField(max_length=512, index=True) invite_link = fields.CharField(max_length=512, index=True)
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)

View File

@@ -14,7 +14,6 @@ class TelegramStateEnum(str, enum.Enum):
class TelegramState(TimestampedModel): class TelegramState(TimestampedModel):
id = fields.UUIDField(pk=True)
telegram_id = fields.BigIntField(unique=True, index=True) telegram_id = fields.BigIntField(unique=True, index=True)
state = fields.CharEnumField(TelegramStateEnum) state = fields.CharEnumField(TelegramStateEnum)
context = fields.JSONField(default=dict) context = fields.JSONField(default=dict)

View File

@@ -4,7 +4,6 @@ from .base import TimestampedModel
class User(TimestampedModel): class User(TimestampedModel):
id = fields.UUIDField(pk=True)
telegram_id = fields.BigIntField(unique=True, index=True) telegram_id = fields.BigIntField(unique=True, index=True)
username = fields.CharField(max_length=255, null=True) username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True) first_name = fields.CharField(max_length=255, null=True)

View File

@@ -27,7 +27,6 @@ class WorkspaceUserStatus(str, enum.Enum):
class WorkspaceUser(TimestampedModel): class WorkspaceUser(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE) status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE)
workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
@@ -53,7 +52,6 @@ class WorkspaceInviteStatus(str, enum.Enum):
class WorkspaceInvite(TimestampedModel): class WorkspaceInvite(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING) status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING)
workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
@@ -114,7 +112,6 @@ class WorkspaceUserPermission(TimestampedModel):
class WorkspaceUserPermissionScope(TimestampedModel): class WorkspaceUserPermissionScope(TimestampedModel):
id = fields.UUIDField(pk=True)
permission = fields.CharEnumField(PermissionKey) permission = fields.CharEnumField(PermissionKey)
scope_type = fields.CharEnumField(PermissionScopeType) scope_type = fields.CharEnumField(PermissionScopeType)
scope_id = fields.UUIDField() scope_id = fields.UUIDField()

View File

@@ -31,7 +31,7 @@ __all__ = (
'CreatePlacementInput', 'CreatePlacementInput',
'UpdatePlacementInput', 'UpdatePlacementInput',
'DeletePlacementInput', 'DeletePlacementInput',
'PlacementViewsHistoryOutput', 'PostViewsHistoryOutput',
'GetViewsHistoryInput', 'GetViewsHistoryInput',
'GetViewsHistoryOutput', 'GetViewsHistoryOutput',
'UpdateViewsManuallyInput', 'UpdateViewsManuallyInput',
@@ -123,7 +123,7 @@ from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOut
from .views import ( from .views import (
GetViewsHistoryInput, GetViewsHistoryInput,
GetViewsHistoryOutput, GetViewsHistoryOutput,
PlacementViewsHistoryOutput, PostViewsHistoryOutput,
UpdateViewsManuallyInput, UpdateViewsManuallyInput,
) )
from .workspace import ( from .workspace import (

View File

@@ -2,15 +2,12 @@ import uuid
import pydantic import pydantic
from src.domain.channel import ChannelVerificationStatus
class ChannelOutput(pydantic.BaseModel): class ChannelOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
telegram_id: int | None telegram_id: int | None
title: str | None title: str | None
username: str | None username: str | None
verification_status: ChannelVerificationStatus
class GetChannelsInput(pydantic.BaseModel): class GetChannelsInput(pydantic.BaseModel):

View File

@@ -4,11 +4,11 @@ import uuid
import pydantic import pydantic
class PlacementViewsHistoryOutput(pydantic.BaseModel): class PostViewsHistoryOutput(pydantic.BaseModel):
"""История просмотров placement.""" """История просмотров поста."""
id: uuid.UUID id: uuid.UUID
placement_id: uuid.UUID post_id: uuid.UUID
views_count: int views_count: int
fetched_at: datetime.datetime fetched_at: datetime.datetime
created_at: datetime.datetime created_at: datetime.datetime
@@ -27,7 +27,7 @@ class GetViewsHistoryInput(pydantic.BaseModel):
class GetViewsHistoryOutput(pydantic.BaseModel): class GetViewsHistoryOutput(pydantic.BaseModel):
"""История просмотров.""" """История просмотров."""
histories: list[PlacementViewsHistoryOutput] histories: list[PostViewsHistoryOutput]
class UpdateViewsManuallyInput(pydantic.BaseModel): class UpdateViewsManuallyInput(pydantic.BaseModel):

View File

@@ -122,14 +122,11 @@ class Database(typing.Protocol):
async def get_channel( async def get_channel(
self, self,
workspace_id: UUID,
channel_id: UUID | None = None, channel_id: UUID | None = None,
telegram_id: int | None = None, telegram_id: int | None = None,
username: str | None = None, username: str | None = None,
) -> domain.Channel | 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 create_channel(self, channel: domain.Channel) -> None: ...
async def update_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 self, subscriber_id: UUID, project_id: UUID
) -> domain.Subscription | None: ... ) -> 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( async def get_views_history(
self, self,
placement_id: UUID, post_id: UUID,
*, *,
from_date: datetime.datetime | None = None, from_date: datetime.datetime | None = None,
to_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: ... async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ...

View File

@@ -30,7 +30,9 @@ async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsI
else: else:
allowed_project_filter = allowed_project_ids allowed_project_filter = allowed_project_ids
if allowed_project_ids is None: 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: else:
channels = [] 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} 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: for p in placements:
stats = channel_stats.get(p.placement_channel_id) stats = channel_stats.get(p.placement_channel_id)
if stats is None: 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_cost += p.cost if p.cost is not None else 0
stats.total_subscriptions += p.subscriptions_count 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 stats.placements_count += 1
result = [] result = []

View File

@@ -49,6 +49,10 @@ async def get_creatives_analytics(
creative_stats: dict[UUID, CreativeStats] = {cr.id: CreativeStats() for cr in creatives} 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: for p in placements:
stats = creative_stats.get(p.creative_id) stats = creative_stats.get(p.creative_id)
if stats is None: 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_cost += p.cost if p.cost is not None else 0
stats.total_subscriptions += p.subscriptions_count 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 stats.placements_count += 1
result = [] result = []

View File

@@ -44,6 +44,10 @@ async def get_placements_analytics(
allowed_project_ids=allowed_project_ids, 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 = [ result = [
dto.PlacementAnalyticsOutput( dto.PlacementAnalyticsOutput(
id=p.id, id=p.id,
@@ -56,9 +60,9 @@ async def get_placements_analytics(
placement_date=p.placement_date, placement_date=p.placement_date,
cost=p.cost, cost=p.cost,
subscriptions_count=p.subscriptions_count, 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), 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 for p in placements
] ]

View File

@@ -70,6 +70,10 @@ async def get_spending_analytics(
subscriptions: int = 0 subscriptions: int = 0
views: 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) period_data: dict[str, PeriodData] = defaultdict(PeriodData)
@@ -84,9 +88,11 @@ async def get_spending_analytics(
total_subs += p.subscriptions_count total_subs += p.subscriptions_count
pd.subscriptions += p.subscriptions_count pd.subscriptions += p.subscriptions_count
if p.views_count is not None: # Get views from batch data
total_views += p.views_count if p.post and p.post.id in views_map:
pd.views += p.views_count 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_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 avg_cpm = (total_cost / total_views * 1000) if total_views > 0 and total_cost > 0 else None

View File

@@ -21,7 +21,6 @@ async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> dto.GetC
telegram_id=channel.telegram_id, telegram_id=channel.telegram_id,
title=channel.title, title=channel.title,
username=channel.username, username=channel.username,
verification_status=channel.verification_status,
) )
for channel in channels for channel in channels
] ]

View File

@@ -17,9 +17,7 @@ async def update_creative(
user_id: uuid.UUID, user_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
) -> dto.CreativeOutput: ) -> dto.CreativeOutput:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE
)
creative = await self.database.get_creative(workspace_id, creative_id) creative = await self.database.get_creative(workspace_id, creative_id)
if not creative: if not creative:

View File

@@ -24,7 +24,7 @@ async def create_placement(
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) 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: if not placement_channel:
raise domain.ChannelNotFound(input.placement_channel_id) raise domain.ChannelNotFound(input.placement_channel_id)
@@ -39,6 +39,8 @@ async def create_placement(
if project.channel.telegram_id is None: if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id) 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) invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
placement = domain.Placement( placement = domain.Placement(
@@ -71,13 +73,13 @@ async def create_placement(
placement_date=placement.placement_date, placement_date=placement.placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=placement.ad_post_url, ad_post_url=None,
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=placement.status, status=placement.status,
subscriptions_count=placement.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=placement.views_count, views_count=None,
views_availability=placement.views_availability, views_availability=domain.PostViewsAvailability.UNKNOWN,
last_views_fetch_at=placement.last_views_fetch_at, last_views_fetch_at=None,
created_at=placement.created_at, created_at=placement.created_at,
) )

View File

@@ -3,6 +3,8 @@ from typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase 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) 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( return dto.PlacementOutput(
id=placement.id, id=placement.id,
project_id=placement.project_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, placement_date=placement.placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, 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_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=placement.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=placement.views_count, views_count=views_count,
views_availability=placement.views_availability, views_availability=views_availability,
last_views_fetch_at=placement.last_views_fetch_at, last_views_fetch_at=last_views_fetch_at,
created_at=placement.created_at, created_at=placement.created_at,
) )

View File

@@ -2,6 +2,8 @@ from typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -26,8 +28,27 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
allowed_project_ids=allowed_project_ids, allowed_project_ids=allowed_project_ids,
) )
return dto.GetPlacementsOutput( # Batch fetch views data for all posts
placements=[ 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( dto.PlacementOutput(
id=placement.id, id=placement.id,
project_id=placement.project_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, placement_date=placement.placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, 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_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=placement.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=placement.views_count, views_count=views_count,
views_availability=placement.views_availability, views_availability=views_availability,
last_views_fetch_at=placement.last_views_fetch_at, last_views_fetch_at=last_views_fetch_at,
created_at=placement.created_at, created_at=placement.created_at,
) )
for placement in placements )
]
) return dto.GetPlacementsOutput(placements=placement_outputs)

View File

@@ -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}'

View File

@@ -4,6 +4,8 @@ from typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from .helpers import generate_post_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -17,9 +19,7 @@ async def update_placement(
user_id: uuid.UUID, user_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
) -> dto.PlacementOutput: ) -> dto.PlacementOutput:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
placement = await self.database.get_placement(workspace_id, placement_id) placement = await self.database.get_placement(workspace_id, placement_id)
if not placement: if not placement:
@@ -39,6 +39,21 @@ async def update_placement(
await self.database.update_placement(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( return dto.PlacementOutput(
id=placement.id, id=placement.id,
project_id=placement.project_id, project_id=placement.project_id,
@@ -50,13 +65,13 @@ async def update_placement(
placement_date=placement.placement_date, placement_date=placement.placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, 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_type=placement.invite_link_type,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=placement.subscriptions_count, subscriptions_count=placement.subscriptions_count,
views_count=placement.views_count, views_count=views_count,
views_availability=placement.views_availability, views_availability=views_availability,
last_views_fetch_at=placement.last_views_fetch_at, last_views_fetch_at=last_views_fetch_at,
created_at=placement.created_at, created_at=placement.created_at,
) )

View File

@@ -15,9 +15,7 @@ async def get_workspace_projects(
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ) allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ)
projects = await self.database.get_workspace_projects( projects = await self.database.get_workspace_projects(input.workspace_id, allowed_project_ids=allowed_project_ids)
input.workspace_id, allowed_project_ids=allowed_project_ids
)
return dto.GetWorkspaceProjectsOutput( return dto.GetWorkspaceProjectsOutput(
projects=[ projects=[

View File

@@ -70,7 +70,7 @@ async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> d
if len(workspaces) == 1: if len(workspaces) == 1:
workspace = workspaces[0] 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: if channel:
channel.title = input.title channel.title = input.title
channel.username = input.username 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, telegram_id=input.telegram_id,
title=input.title, title=input.title,
username=input.username, username=input.username,
verification_status=domain.ChannelVerificationStatus.VERIFIED,
workspace_id=workspace.id,
) )
await self.database.create_channel(channel) await self.database.create_channel(channel)

View File

@@ -66,7 +66,7 @@ async def tg_add_project_2(
return return
telegram_channel_id = int(channel_data['telegram_id']) 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: if channel:
channel.title = str(channel_data['title']) channel.title = str(channel_data['title'])
channel.username = channel_data.get('username') channel.username = channel_data.get('username')
@@ -76,7 +76,6 @@ async def tg_add_project_2(
telegram_id=telegram_channel_id, telegram_id=telegram_channel_id,
title=str(channel_data['title']), title=str(channel_data['title']),
username=channel_data.get('username'), username=channel_data.get('username'),
workspace_id=workspace.id,
) )
await self.database.create_channel(channel) await self.database.create_channel(channel)

View File

@@ -17,9 +17,7 @@ async def attach_channel_to_purchase_plan(
user_id: uuid.UUID, user_id: uuid.UUID,
input: dto.AttachChannelToPurchasePlanInput, input: dto.AttachChannelToPurchasePlanInput,
) -> dto.PurchasePlanChannelOutput: ) -> dto.PurchasePlanChannelOutput:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
project = await self.database.get_project(workspace_id, project_id=project_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:
@@ -30,21 +28,15 @@ async def attach_channel_to_purchase_plan(
plan = await self.ensure_active_purchase_plan(project) plan = await self.ensure_active_purchase_plan(project)
# Поиск канала по username # Поиск канала по username
channel = await self.database.get_channel(workspace_id, username=input.username) channel = await self.database.get_channel(username=input.username)
if not channel: if not channel:
# Создать неподтвержденный канал # Создать канал с nullable telegram_id/title (заполнятся парсером позже)
channel = domain.Channel( channel = domain.Channel(username=input.username)
workspace_id=workspace_id,
username=input.username,
telegram_id=None,
title=None,
verification_status=domain.ChannelVerificationStatus.UNVERIFIED,
)
await self.database.create_channel(channel) 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: 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_channel = await self.database.add_channel_to_purchase_plan(
plan.id, plan.id,
@@ -66,6 +58,5 @@ async def attach_channel_to_purchase_plan(
telegram_id=channel.telegram_id, telegram_id=channel.telegram_id,
title=channel.title, title=channel.title,
username=channel.username, username=channel.username,
verification_status=channel.verification_status,
), ),
) )

View File

@@ -39,7 +39,6 @@ async def get_purchase_plan_channels(
telegram_id=plan_channel.channel.telegram_id, telegram_id=plan_channel.channel.telegram_id,
title=plan_channel.channel.title, title=plan_channel.channel.title,
username=plan_channel.channel.username, username=plan_channel.channel.username,
verification_status=plan_channel.channel.verification_status,
), ),
) )
for plan_channel in plan_channels for plan_channel in plan_channels

View File

@@ -17,9 +17,7 @@ async def remove_purchase_plan_channel(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
) -> None: ) -> None:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
project = await self.database.get_project(workspace_id, project_id=project_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:

View File

@@ -18,9 +18,7 @@ async def update_purchase_plan_channel(
user_id: uuid.UUID, user_id: uuid.UUID,
input: dto.UpdatePurchasePlanChannelInput, input: dto.UpdatePurchasePlanChannelInput,
) -> dto.PurchasePlanChannelOutput: ) -> dto.PurchasePlanChannelOutput:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
)
project = await self.database.get_project(workspace_id, project_id=project_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:
@@ -56,7 +54,7 @@ async def update_purchase_plan_channel(
channel: domain.Channel | None = plan_channel.channel channel: domain.Channel | None = plan_channel.channel
if channel is None: 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: if channel is None:
raise domain.ChannelNotFound(plan_channel.channel_id) raise domain.ChannelNotFound(plan_channel.channel_id)
@@ -70,6 +68,5 @@ async def update_purchase_plan_channel(
telegram_id=channel.telegram_id, telegram_id=channel.telegram_id,
title=channel.title, title=channel.title,
username=channel.username, username=channel.username,
verification_status=channel.verification_status,
), ),
) )

View File

@@ -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) 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( histories = await self.database.get_views_history(
input.placement_id, placement.post.id,
from_date=input.from_date, from_date=input.from_date,
to_date=input.to_date, to_date=input.to_date,
) )
return dto.GetViewsHistoryOutput( return dto.GetViewsHistoryOutput(
histories=[ histories=[
dto.PlacementViewsHistoryOutput( dto.PostViewsHistoryOutput(
id=history.id, id=history.id,
placement_id=history.placement_id, post_id=history.post_id,
views_count=history.views_count, views_count=history.views_count,
fetched_at=history.fetched_at, fetched_at=history.fetched_at,
created_at=history.created_at, created_at=history.created_at,

View File

@@ -17,6 +17,4 @@ async def get_workspace_invites(
invites = await self.database.get_workspace_invites(workspace_id) invites = await self.database.get_workspace_invites(workspace_id)
return dto.GetWorkspaceInvitesOutput( return dto.GetWorkspaceInvitesOutput(invites=[workspace_invite_to_dto(invite) for invite in invites])
invites=[workspace_invite_to_dto(invite) for invite in invites]
)

View File

@@ -17,6 +17,4 @@ async def get_workspace_members(
members = await self.database.get_workspace_members(workspace_id) members = await self.database.get_workspace_members(workspace_id)
return dto.GetWorkspaceMembersOutput( return dto.GetWorkspaceMembersOutput(members=[workspace_member_to_dto(member) for member in members])
members=[workspace_member_to_dto(member) for member in members]
)