feat: переименование доменной области

This commit is contained in:
Artem Tsyrulnikov
2025-12-14 12:21:35 +03:00
parent 3cb3da4dbe
commit f3e09a08eb
75 changed files with 1624 additions and 1417 deletions

View File

@@ -1,221 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "subscriber" (
"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),
"last_name" VARCHAR(255)
);
CREATE INDEX IF NOT EXISTS "idx_subscriber_telegra_5eef32" ON "subscriber" ("telegram_id");
CREATE TABLE IF NOT EXISTS "telegram_state" (
"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,
"state" VARCHAR(24) NOT NULL,
"context" JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id");
COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text';
CREATE TABLE IF NOT EXISTS "user" (
"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)
);
CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id");
CREATE TABLE IF NOT EXISTS "external_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 NOT NULL UNIQUE,
"title" VARCHAR(255) NOT NULL,
"username" VARCHAR(255),
"description" TEXT,
"subscribers_count" INT,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");
CREATE INDEX IF NOT EXISTS "idx_external_ch_usernam_9b9fb4" ON "external_channel" ("username");
CREATE INDEX IF NOT EXISTS "idx_external_ch_user_id_dd2b1c" ON "external_channel" ("user_id");
CREATE TABLE IF NOT EXISTS "login_token" (
"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,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token");
CREATE TABLE IF NOT EXISTS "target_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 NOT NULL UNIQUE,
"title" VARCHAR(255) NOT NULL,
"username" VARCHAR(255),
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_target_chan_telegra_ab5a54" ON "target_channel" ("telegram_id");
CREATE INDEX IF NOT EXISTS "idx_target_chan_usernam_a6cf8b" ON "target_channel" ("username");
CREATE INDEX IF NOT EXISTS "idx_target_chan_user_id_c3bdc7" ON "target_channel" ("user_id");
COMMENT ON COLUMN "target_channel"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "creative" (
"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',
"placements_count" INT NOT NULL DEFAULT 0,
"target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_creative_target__8e3b64" ON "creative" ("target_channel_id");
CREATE INDEX IF NOT EXISTS "idx_creative_user_id_a27cfc" ON "creative" ("user_id");
COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "placement" (
"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,
"external_channel_id" UUID NOT NULL REFERENCES "external_channel" ("id") ON DELETE CASCADE,
"target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id");
CREATE INDEX IF NOT EXISTS "idx_placement_externa_5b96c7" ON "placement" ("external_channel_id");
CREATE INDEX IF NOT EXISTS "idx_placement_target__ed9070" ON "placement" ("target_channel_id");
CREATE INDEX IF NOT EXISTS "idx_placement_user_id_7abaf9" ON "placement" ("user_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 "subscription" (
"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,
"placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE,
"subscriber_id" UUID NOT NULL REFERENCES "subscriber" ("id") ON DELETE CASCADE,
"target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link");
CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id");
CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id");
CREATE INDEX IF NOT EXISTS "idx_subscriptio_target__79117e" ON "subscription" ("target_channel_id");
COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed';
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS "target_external_channel" (
"target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE,
"external_channel_id" UUID NOT NULL REFERENCES "external_channel" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "uidx_target_exte_target__ba70ec" ON "target_external_channel" ("target_channel_id", "external_channel_id");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
MODELS_STATE = (
"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"
)

View File

@@ -0,0 +1,309 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "telegram_state" (
"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,
"state" VARCHAR(26) NOT NULL,
"context" JSONB NOT NULL
);
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" (
"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),
"last_name" VARCHAR(255)
);
CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id");
CREATE TABLE IF NOT EXISTS "login_token" (
"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,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token");
CREATE TABLE IF NOT EXISTS "workspace" (
"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" (
"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,
CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id")
);
CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_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';
CREATE TABLE IF NOT EXISTS "creative" (
"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',
"placements_count" INT NOT NULL DEFAULT 0,
"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_creative_project_0766a6" ON "creative" ("project_id");
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" (
"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,
"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_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" (
"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
);
CREATE INDEX IF NOT EXISTS "idx_purchase_pl_project_ceb049" ON "purchase_plan" ("project_id");
CREATE INDEX IF NOT EXISTS "idx_purchase_pl_workspa_e2459d" ON "purchase_plan" ("workspace_id");
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" (
"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,
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"purchase_plan_id" UUID NOT NULL REFERENCES "purchase_plan" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_purchase_pl_purchas_e3a717" UNIQUE ("purchase_plan_id", "channel_id")
);
CREATE INDEX IF NOT EXISTS "idx_purchase_pl_channel_4cfc1c" ON "purchase_plan_channel" ("channel_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';
CREATE TABLE IF NOT EXISTS "subscription" (
"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,
"placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE,
"subscriber_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link");
CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id");
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" (
"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,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id")
);
CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_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';
CREATE TABLE IF NOT EXISTS "workspace_user" (
"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,
CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "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");
COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nREMOVED: removed';
CREATE TABLE IF NOT EXISTS "workspace_user_permission" (
"id" SERIAL 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(17) 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 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics';
CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
"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(17) 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 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics';
COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project';
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
MODELS_STATE = (
'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'
)

View File

@@ -1,72 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "uid_external_ch_telegra_13cca5";
CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_user_id_289e6a" ON "external_channel" ("user_id", "telegram_id");
CREATE INDEX IF NOT EXISTS "idx_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
DROP INDEX IF EXISTS "idx_external_ch_telegra_13cca5";
DROP INDEX IF EXISTS "uid_external_ch_user_id_289e6a";
CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");"""
MODELS_STATE = (
'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'
'8q//AeAg1wo='
)

View File

@@ -1,124 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "target_channel" DROP CONSTRAINT IF EXISTS "fk_target_c_user_f3145593";
ALTER TABLE "placement" DROP CONSTRAINT IF EXISTS "fk_placemen_user_004a0a62";
DROP INDEX IF EXISTS "uid_external_ch_user_id_289e6a";
ALTER TABLE "external_channel" DROP CONSTRAINT IF EXISTS "fk_external_user_23b1e9dc";
ALTER TABLE "creative" DROP CONSTRAINT IF EXISTS "fk_creative_user_7868b532";
CREATE TABLE IF NOT EXISTS "workspace" (
"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 "workspace_user" (
"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,
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "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");
ALTER TABLE "creative" RENAME COLUMN "user_id" TO "workspace_id";
ALTER TABLE "external_channel" RENAME COLUMN "user_id" TO "workspace_id";
ALTER TABLE "placement" RENAME COLUMN "user_id" TO "workspace_id";
ALTER TABLE "target_channel" RENAME COLUMN "user_id" TO "workspace_id";
ALTER TABLE "telegram_state" ALTER COLUMN "state" TYPE VARCHAR(26) USING "state"::VARCHAR(26);
COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_WORKSPACE: creative_waiting_workspace
CREATIVE_WAITING_CHANNEL: creative_waiting_channel
CREATIVE_WAITING_NAME: creative_waiting_name
CREATIVE_WAITING_TEXT: creative_waiting_text';
ALTER TABLE "creative" ADD CONSTRAINT "fk_creative_workspac_ac3ef81f" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE;
ALTER TABLE "external_channel" ADD CONSTRAINT "fk_external_workspac_5266308f" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE;
ALTER TABLE "placement" ADD CONSTRAINT "fk_placemen_workspac_c3bc4808" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE;
ALTER TABLE "target_channel" ADD CONSTRAINT "fk_target_c_workspac_8a2fd2e3" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "external_channel" DROP CONSTRAINT IF EXISTS "fk_external_workspac_5266308f";
ALTER TABLE "target_channel" DROP CONSTRAINT IF EXISTS "fk_target_c_workspac_8a2fd2e3";
ALTER TABLE "placement" DROP CONSTRAINT IF EXISTS "fk_placemen_workspac_c3bc4808";
ALTER TABLE "creative" DROP CONSTRAINT IF EXISTS "fk_creative_workspac_ac3ef81f";
ALTER TABLE "creative" RENAME COLUMN "workspace_id" TO "user_id";
ALTER TABLE "placement" RENAME COLUMN "workspace_id" TO "user_id";
ALTER TABLE "target_channel" RENAME COLUMN "workspace_id" TO "user_id";
COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_CHANNEL: creative_waiting_channel
CREATIVE_WAITING_NAME: creative_waiting_name
CREATIVE_WAITING_TEXT: creative_waiting_text';
ALTER TABLE "telegram_state" ALTER COLUMN "state" TYPE VARCHAR(24) USING "state"::VARCHAR(24);
ALTER TABLE "external_channel" RENAME COLUMN "workspace_id" TO "user_id";
DROP TABLE IF EXISTS "workspace_user";
DROP TABLE IF EXISTS "workspace";
ALTER TABLE "creative" ADD CONSTRAINT "fk_creative_user_7868b532" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE;
ALTER TABLE "placement" ADD CONSTRAINT "fk_placemen_user_004a0a62" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE;
ALTER TABLE "target_channel" ADD CONSTRAINT "fk_target_c_user_f3145593" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE;
ALTER TABLE "external_channel" ADD CONSTRAINT "fk_external_user_23b1e9dc" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_user_id_289e6a" ON "external_channel" ("user_id", "telegram_id");"""
MODELS_STATE = (
"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"
"W5ItNdeILv1jLdc4tVqpHT/ut/5sP+/g=="
)

View File

@@ -19,6 +19,9 @@ class Postgres(DatabaseBase):
async def create_user(self, user: domain.User) -> None: async def create_user(self, user: domain.User) -> None:
await user.save() await user.save()
async def update_user(self, user: domain.User) -> None:
await user.save()
async def create_workspace(self, workspace: domain.Workspace) -> None: async def create_workspace(self, workspace: domain.Workspace) -> None:
await workspace.save() await workspace.save()
@@ -28,10 +31,12 @@ class Postgres(DatabaseBase):
async def delete_workspace(self, workspace_id: uuid.UUID) -> None: async def delete_workspace(self, workspace_id: uuid.UUID) -> None:
await domain.Workspace.filter(id=workspace_id).delete() await domain.Workspace.filter(id=workspace_id).delete()
async def add_user_to_workspace( async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> None:
self, workspace_id: uuid.UUID, user_id: uuid.UUID, *, is_owner: bool = False await domain.WorkspaceUser.create(
) -> None: workspace_id=workspace_id,
await domain.WorkspaceUser.create(workspace_id=workspace_id, user_id=user_id, is_owner=is_owner) user_id=user_id,
status=domain.WorkspaceUserStatus.ACTIVE,
)
async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]: async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]:
return ( return (
@@ -71,9 +76,6 @@ class Postgres(DatabaseBase):
async def create_login_token(self, login_token: domain.LoginToken) -> None: async def create_login_token(self, login_token: domain.LoginToken) -> None:
await login_token.save() await login_token.save()
async def create_external_channel(self, channel: domain.ExternalChannel) -> None:
await channel.save()
async def create_creative(self, creative: domain.Creative) -> None: async def create_creative(self, creative: domain.Creative) -> None:
await creative.save() await creative.save()
@@ -94,120 +96,165 @@ class Postgres(DatabaseBase):
if updated == 0: if updated == 0:
raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован
async def get_target_channel( async def get_channel(
self, workspace_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None self,
) -> domain.TargetChannel | None: workspace_id: uuid.UUID,
if not channel_id and not telegram_id: channel_id: uuid.UUID | None = None,
raise ValueError('Either channel_id or telegram_id must be provided') telegram_id: int | None = None,
username: str | None = None,
q = domain.TargetChannel.filter(workspace_id=workspace_id) ) -> domain.Channel | None:
if channel_id: query = domain.Channel.filter(workspace_id=workspace_id)
q = q.filter(id=channel_id)
if telegram_id:
q = q.filter(telegram_id=telegram_id)
return await q.first()
async def get_target_channel_for_user_by_telegram(
self, user_id: uuid.UUID, telegram_id: int
) -> domain.TargetChannel | None:
return (
await domain.TargetChannel.filter(telegram_id=telegram_id, workspace__workspace_users__user_id=user_id)
.prefetch_related('workspace')
.first()
)
async def upsert_target_channel(self, channel: domain.TargetChannel) -> None:
existing = await self.get_target_channel(workspace_id=channel.workspace_id, telegram_id=channel.telegram_id)
if not existing:
await channel.save()
return
existing.title = channel.title
existing.username = channel.username
existing.workspace_id = channel.workspace_id
existing.status = channel.status
existing.deleted_at = None
existing.updated_at = timezone.now()
await existing.save()
async def get_workspace_target_channels(self, workspace_id: uuid.UUID) -> list[domain.TargetChannel]:
return await domain.TargetChannel.filter(
workspace_id=workspace_id, status=domain.TargetChannelStatus.ACTIVE
).all()
async def update_target_channel(self, channel: domain.TargetChannel) -> None:
await channel.save()
async def get_external_channel(
self, workspace_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | None:
query = domain.ExternalChannel.filter(workspace_id=workspace_id)
if channel_id: if channel_id:
query = query.filter(id=channel_id) query = query.filter(id=channel_id)
if telegram_id: if telegram_id:
query = query.filter(telegram_id=telegram_id) query = query.filter(telegram_id=telegram_id)
if username:
query = query.filter(username=username)
return await query.first() return await query.first()
async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: async def get_workspace_channels(self, workspace_id: uuid.UUID) -> list[domain.Channel]:
return await domain.ExternalChannel.filter(target_channels__id=target_channel_id).all() return await domain.Channel.filter(workspace_id=workspace_id).all()
async def get_workspace_external_channels(self, workspace_id: uuid.UUID) -> list[domain.ExternalChannel]: async def create_channel(self, channel: domain.Channel) -> None:
return await domain.ExternalChannel.filter(workspace_id=workspace_id).all()
async def add_external_channel_to_targets(
self, external_channel: domain.ExternalChannel, target_channel_ids: list[uuid.UUID]
) -> None:
target_channels = await domain.TargetChannel.filter(id__in=target_channel_ids).all()
await external_channel.target_channels.add(*target_channels)
async def remove_external_channel_from_target(
self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID
) -> None:
external_channel = await domain.ExternalChannel.get_or_none(id=external_channel_id)
if not external_channel:
raise ValueError(f'ExternalChannel {external_channel_id} not found')
target_channel = await domain.TargetChannel.get_or_none(id=target_channel_id)
if target_channel:
await external_channel.target_channels.remove(target_channel)
async def delete_external_channel(self, channel_id: uuid.UUID) -> None:
await domain.ExternalChannel.filter(id=channel_id).delete()
async def update_external_channel(self, channel: domain.ExternalChannel) -> None:
await channel.save() await channel.save()
async def update_channel(self, channel: domain.Channel) -> None:
await channel.save()
async def delete_channel(self, channel_id: uuid.UUID) -> None:
await domain.Channel.filter(id=channel_id).delete()
async def search_channels(self, username_query: str | None = None) -> list[domain.Channel]:
query = domain.Channel.all()
if username_query:
# Частичный поиск (case-insensitive)
query = query.filter(username__icontains=username_query)
return await query.all()
async def get_project(
self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None
) -> domain.Project | None:
query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel')
if project_id:
query = query.filter(id=project_id)
if channel_id:
query = query.filter(channel_id=channel_id)
return await query.first()
async def get_project_for_user_by_telegram(
self, user_id: uuid.UUID, channel_telegram_id: int
) -> domain.Project | None:
return (
await domain.Project.filter(
channel__telegram_id=channel_telegram_id, workspace__workspace_users__user_id=user_id
)
.prefetch_related('channel')
.first()
)
async def get_project_by_channel_telegram(self, channel_telegram_id: int) -> domain.Project | None:
return await domain.Project.filter(channel__telegram_id=channel_telegram_id).prefetch_related('channel').first()
async def create_project(self, project: domain.Project) -> None:
await project.save()
async def update_project(self, project: domain.Project) -> None:
await project.save()
async def get_workspace_projects(self, workspace_id: uuid.UUID) -> list[domain.Project]:
return (
await domain.Project.filter(workspace_id=workspace_id)
.prefetch_related('channel')
.order_by('created_at')
.all()
)
async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None:
return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE)
async def get_purchase_plan(self, workspace_id: uuid.UUID, plan_id: uuid.UUID) -> domain.PurchasePlan | None:
return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id)
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None:
await plan.save()
async def get_purchase_plan_channels(self, plan_id: uuid.UUID) -> list[domain.PurchasePlanChannel]:
return await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id).prefetch_related('channel').all()
async def get_purchase_plan_channel(
self, plan_channel_id: uuid.UUID, plan_id: uuid.UUID
) -> domain.PurchasePlanChannel | None:
return (
await domain.PurchasePlanChannel.filter(id=plan_channel_id, purchase_plan_id=plan_id)
.prefetch_related('channel')
.first()
)
async def add_channel_to_purchase_plan(
self,
plan_id: uuid.UUID,
channel_id: uuid.UUID,
*,
status: domain.PurchasePlanChannelStatus | None = None,
planned_cost: float | None = None,
comment: str | None = None,
) -> domain.PurchasePlanChannel:
defaults: dict[str, object | None] = {}
if status is not None:
defaults['status'] = status
if planned_cost is not None:
defaults['planned_cost'] = planned_cost
if comment is not None:
defaults['comment'] = comment
plan_channel, created = await domain.PurchasePlanChannel.get_or_create(
purchase_plan_id=plan_id,
channel_id=channel_id,
defaults=defaults,
)
if not created and defaults:
for field, value in defaults.items():
setattr(plan_channel, field, value)
await plan_channel.save()
return plan_channel
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None:
await plan_channel.save()
async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None:
await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id, channel_id=channel_id).delete()
async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related( return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related(
'target_channel' 'project', 'project__channel'
) )
async def update_creative(self, creative: domain.Creative) -> None: async def update_creative(self, creative: domain.Creative) -> None:
await creative.save() await creative.save()
async def get_workspace_creatives( async def get_workspace_creatives(
self, workspace_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]: ) -> list[domain.Creative]:
query = domain.Creative.filter(workspace_id=workspace_id) query = domain.Creative.filter(workspace_id=workspace_id)
if target_channel_id: if project_id:
query = query.filter(target_channel_id=target_channel_id) query = query.filter(project_id=project_id)
if not include_archived: if not include_archived:
query = query.filter(status=domain.CreativeStatus.ACTIVE) query = query.filter(status=domain.CreativeStatus.ACTIVE)
return await query.prefetch_related('target_channel').order_by('-created_at').all() return await query.prefetch_related('project', 'project__channel').order_by('-created_at').all()
async def delete_creative(self, creative_id: uuid.UUID) -> None: async def delete_creative(self, creative_id: uuid.UUID) -> None:
await domain.Creative.filter(id=creative_id).delete() await domain.Creative.filter(id=creative_id).delete()
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(
'target_channel', 'external_channel', 'creative' 'project', 'project__channel', 'placement_channel', 'creative'
) )
async def create_placement(self, placement: domain.Placement) -> None: async def create_placement(self, placement: domain.Placement) -> None:
@@ -219,17 +266,17 @@ class Postgres(DatabaseBase):
async def get_workspace_placements( async def get_workspace_placements(
self, self,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
external_channel_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> list[domain.Placement]: ) -> list[domain.Placement]:
query = domain.Placement.filter(workspace_id=workspace_id) query = domain.Placement.filter(workspace_id=workspace_id)
if target_channel_id: if project_id:
query = query.filter(target_channel_id=target_channel_id) query = query.filter(project_id=project_id)
if external_channel_id: if placement_channel_id:
query = query.filter(external_channel_id=external_channel_id) query = query.filter(placement_channel_id=placement_channel_id)
if creative_id: if creative_id:
query = query.filter(creative_id=creative_id) query = query.filter(creative_id=creative_id)
@@ -237,7 +284,7 @@ class Postgres(DatabaseBase):
query = query.filter(status=domain.PlacementStatus.ACTIVE) query = query.filter(status=domain.PlacementStatus.ACTIVE)
return ( return (
await query.prefetch_related('target_channel', 'external_channel', 'creative') await query.prefetch_related('project', 'project__channel', 'placement_channel', 'creative')
.order_by('-placement_date') .order_by('-placement_date')
.all() .all()
) )
@@ -247,23 +294,9 @@ class Postgres(DatabaseBase):
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None:
return await domain.Placement.get_or_none(invite_link=invite_link).prefetch_related( return await domain.Placement.get_or_none(invite_link=invite_link).prefetch_related(
'target_channel', 'external_channel', 'creative' 'project', 'project__channel', 'placement_channel', 'creative'
) )
async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None:
return await domain.Subscriber.get_or_none(telegram_id=telegram_id)
async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None:
existing = await self.get_subscriber(subscriber.telegram_id)
if not existing:
await subscriber.save()
return
existing.username = subscriber.username
existing.first_name = subscriber.first_name
existing.last_name = subscriber.last_name
await existing.save()
async def create_subscription(self, subscription: domain.Subscription) -> None: async def create_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save() await subscription.save()
@@ -275,26 +308,26 @@ class Postgres(DatabaseBase):
async def update_subscription(self, subscription: domain.Subscription) -> None: async def update_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save() await subscription.save()
async def get_active_subscriptions_by_subscriber_and_channel( async def get_active_subscriptions_by_subscriber_and_project(
self, subscriber_id: uuid.UUID, channel_telegram_id: int self, subscriber_id: uuid.UUID, project_id: uuid.UUID
) -> list[domain.Subscription]: ) -> list[domain.Subscription]:
return ( return (
await domain.Subscription.filter( await domain.Subscription.filter(
subscriber_id=subscriber_id, subscriber_id=subscriber_id,
target_channel__telegram_id=channel_telegram_id, placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE, status=domain.SubscriptionStatus.ACTIVE,
) )
.prefetch_related('placement', 'subscriber') .prefetch_related('placement', 'subscriber')
.all() .all()
) )
async def get_active_subscription_by_subscriber_and_channel( async def get_active_subscription_by_subscriber_and_project(
self, subscriber_id: uuid.UUID, channel_telegram_id: int self, subscriber_id: uuid.UUID, project_id: uuid.UUID
) -> domain.Subscription | None: ) -> domain.Subscription | None:
return ( return (
await domain.Subscription.filter( await domain.Subscription.filter(
subscriber_id=subscriber_id, subscriber_id=subscriber_id,
target_channel__telegram_id=channel_telegram_id, placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE, status=domain.SubscriptionStatus.ACTIVE,
) )
.prefetch_related('placement', 'subscriber') .prefetch_related('placement', 'subscriber')

View File

@@ -2,10 +2,11 @@ from fastapi import APIRouter
from src.controller.http.analytics import analytics_router from src.controller.http.analytics import analytics_router
from src.controller.http.auth import auth_router from src.controller.http.auth import auth_router
from src.controller.http.channels import channels_router
from src.controller.http.creatives import creatives_router from src.controller.http.creatives import creatives_router
from src.controller.http.external_channels import external_channels_router
from src.controller.http.placements import placements_router from src.controller.http.placements import placements_router
from src.controller.http.target_channels import target_channels_router from src.controller.http.projects import projects_router
from src.controller.http.purchase_plans import purchase_plan_channels_router
from src.controller.http.views import views_router from src.controller.http.views import views_router
from src.controller.http.workspaces import workspaces_router from src.controller.http.workspaces import workspaces_router
@@ -14,8 +15,9 @@ api_router = APIRouter()
# API v1 endpoints # API v1 endpoints
api_v1_router = APIRouter(prefix='/api/v1') api_v1_router = APIRouter(prefix='/api/v1')
api_v1_router.include_router(auth_router) api_v1_router.include_router(auth_router)
api_v1_router.include_router(target_channels_router) api_v1_router.include_router(channels_router)
api_v1_router.include_router(external_channels_router) api_v1_router.include_router(projects_router)
api_v1_router.include_router(purchase_plan_channels_router)
api_v1_router.include_router(creatives_router) api_v1_router.include_router(creatives_router)
api_v1_router.include_router(placements_router) api_v1_router.include_router(placements_router)
api_v1_router.include_router(views_router) api_v1_router.include_router(views_router)

View File

@@ -15,12 +15,12 @@ analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags
async def get_placements_analytics( async def get_placements_analytics(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
) -> dto.GetPlacementsAnalyticsOutput: ) -> dto.GetPlacementsAnalyticsOutput:
input = dto.GetPlacementsAnalyticsInput( input = dto.GetPlacementsAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
) )
return await deps.get_usecase().get_placements_analytics(input) return await deps.get_usecase().get_placements_analytics(input)
@@ -29,35 +29,35 @@ async def get_placements_analytics(
async def get_creatives_analytics( async def get_creatives_analytics(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
) -> dto.GetCreativesAnalyticsOutput: ) -> dto.GetCreativesAnalyticsOutput:
input = dto.GetCreativesAnalyticsInput( input = dto.GetCreativesAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
) )
return await deps.get_usecase().get_creatives_analytics(input) return await deps.get_usecase().get_creatives_analytics(input)
@analytics_router.get('/external-channels') @analytics_router.get('/channels')
async def get_external_channels_analytics( async def get_channel_analytics(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
) -> dto.GetExternalChannelsAnalyticsOutput: ) -> dto.GetChannelAnalyticsOutput:
input = dto.GetExternalChannelsAnalyticsInput( input = dto.GetChannelAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
) )
return await deps.get_usecase().get_external_channels_analytics(input) return await deps.get_usecase().get_channel_analytics(input)
@analytics_router.get('/spending') @analytics_router.get('/spending')
async def get_spending_analytics( async def get_spending_analytics(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
grouping: dto.DateGrouping = dto.DateGrouping.DAY, grouping: dto.DateGrouping = dto.DateGrouping.DAY,
@@ -65,7 +65,7 @@ async def get_spending_analytics(
input = dto.GetSpendingAnalyticsInput( input = dto.GetSpendingAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,
grouping=grouping, grouping=grouping,

View File

@@ -0,0 +1,18 @@
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import deps, dto
from src.adapter.jwt import JWTPayload
channels_router = APIRouter(prefix='/channels', tags=['channels'])
@channels_router.get('')
async def get_channels(
_: Annotated[JWTPayload, Depends(deps.get_current_user)],
username: str | None = None,
) -> dto.GetChannelsOutput:
input = dto.GetChannelsInput(username=username)
return await deps.get_usecase().get_channels(input=input)

View File

@@ -14,13 +14,13 @@ creatives_router = APIRouter(prefix='/workspaces/{workspace_id}/creatives', tags
async def list_creatives( async def list_creatives(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> dto.GetCreativesOutput: ) -> dto.GetCreativesOutput:
input = dto.GetCreativesInput( input = dto.GetCreativesInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
include_archived=include_archived, include_archived=include_archived,
) )

View File

@@ -1,92 +0,0 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import deps, dto
from src.adapter.jwt import JWTPayload
external_channels_router = APIRouter(prefix='/workspaces/{workspace_id}/external_channels', tags=['external_channels'])
@external_channels_router.get('/target/{target_channel_id}')
async def get_external_channels(
target_channel_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetExternalChannelsOutput:
"""Get all external channels for a specific target channel."""
input = dto.GetExternalChannelsInput(
target_channel_id=target_channel_id,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
return await deps.get_usecase().get_external_channels(input=input)
@external_channels_router.post('')
async def create_external_channel(
request: dto.CreateExternalChannelInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ExternalChannelOutput:
"""Create a new external channel and link it to target channels."""
return await deps.get_usecase().create_external_channel(
input=request,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
@external_channels_router.patch('/{channel_id}')
async def update_external_channel(
channel_id: uuid.UUID,
request: dto.UpdateExternalChannelInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ExternalChannelOutput:
"""Update external channel fields (title, username, description, subscribers_count)."""
return await deps.get_usecase().update_external_channel(
channel_id=channel_id,
input=request,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
@external_channels_router.patch('/{channel_id}/links')
async def update_external_channel_links(
channel_id: uuid.UUID,
request: dto.UpdateExternalChannelLinksInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ExternalChannelOutput:
"""
Update target channel links for an existing external channel.
You can add new target channels, remove existing ones, or both in a single request.
"""
return await deps.get_usecase().update_external_channel_links(
channel_id=channel_id,
input=request,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
@external_channels_router.delete('/{channel_id}')
async def delete_external_channel(
channel_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None:
"""Delete an external channel."""
input = dto.DeleteExternalChannelInput(
channel_id=channel_id,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
await deps.get_usecase().delete_external_channel(input=input)

View File

@@ -14,16 +14,16 @@ placements_router = APIRouter(prefix='/workspaces/{workspace_id}/placements', ta
async def list_placements( async def list_placements(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
external_channel_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> dto.GetPlacementsOutput: ) -> dto.GetPlacementsOutput:
input = dto.GetPlacementsInput( input = dto.GetPlacementsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
external_channel_id=external_channel_id, placement_channel_id=placement_channel_id,
creative_id=creative_id, creative_id=creative_id,
include_archived=include_archived, include_archived=include_archived,
) )

View File

@@ -7,17 +7,17 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload from src.adapter.jwt import JWTPayload
target_channels_router = APIRouter(prefix='/workspaces/{workspace_id}/target_channels', tags=['target_channels']) projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects'])
@target_channels_router.get('') @projects_router.get('')
async def get_user_target_chans( async def get_workspace_projects(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetUserTargetChansOutput: ) -> dto.GetWorkspaceProjectsOutput:
input = dto.GetUserTargetChansInput( input = dto.GetWorkspaceProjectsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
) )
return await deps.get_usecase().get_user_target_chans(input=input) return await deps.get_usecase().get_workspace_projects(input=input)

View File

@@ -0,0 +1,74 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from src import deps, dto
from src.adapter.jwt import JWTPayload
purchase_plan_channels_router = APIRouter(
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase-plan/channels',
tags=['purchase plan'],
)
@purchase_plan_channels_router.get('')
async def get_purchase_plan_channels(
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetPurchasePlanChannelsOutput:
input = dto.GetPurchasePlanChannelsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
return await deps.get_usecase().get_purchase_plan_channels(input=input)
@purchase_plan_channels_router.post('')
async def attach_channel_to_purchase_plan(
request: dto.AttachChannelToPurchasePlanInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchasePlanChannelOutput:
return await deps.get_usecase().attach_channel_to_purchase_plan(
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
input=request,
)
@purchase_plan_channels_router.patch('/{channel_id}')
async def update_purchase_plan_channel(
channel_id: uuid.UUID,
request: dto.UpdatePurchasePlanChannelInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchasePlanChannelOutput:
return await deps.get_usecase().update_purchase_plan_channel(
channel_id=channel_id,
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
input=request,
)
@purchase_plan_channels_router.delete('/{channel_id}')
async def remove_purchase_plan_channel(
channel_id: uuid.UUID,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None:
await deps.get_usecase().remove_purchase_plan_channel(
channel_id=channel_id,
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
)

View File

@@ -8,6 +8,6 @@ from . import ( # noqa: E402, F401
chat_member_updated, chat_member_updated,
creative_commands, creative_commands,
my_chat_member, my_chat_member,
project_commands,
start_with_login, start_with_login,
target_channel_commands,
) )

View File

@@ -29,12 +29,12 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
bot_removed = was_member and is_now_not_member bot_removed = was_member and is_now_not_member
if bot_removed: if bot_removed:
disconnect_input = dto.DisconnectTargetChanByTgIdInput( disconnect_input = dto.DisconnectProjectByTgIdInput(
telegram_id=event.chat.id, telegram_id=event.chat.id,
user_telegram_id=event.from_user.id, user_telegram_id=event.from_user.id,
) )
try: try:
await usecase.disconnect_target_chan_by_tg_id(input=disconnect_input) await usecase.disconnect_project_by_tg_id(input=disconnect_input)
except HTTPException as e: except HTTPException as e:
log.error(e) log.error(e)
@@ -53,14 +53,14 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
) )
if permissions_changed: if permissions_changed:
permissions_input = dto.UpdateTargetChanPermissionsInput( permissions_input = dto.UpdateProjectPermissionsInput(
telegram_id=event.chat.id, telegram_id=event.chat.id,
permissions=bot_permissions, permissions=bot_permissions,
chat_title=event.chat.title or f'Channel {event.chat.id}', chat_title=event.chat.title or f'Channel {event.chat.id}',
user_telegram_id=event.from_user.id, user_telegram_id=event.from_user.id,
) )
try: try:
await usecase.update_target_chan_permissions(input=permissions_input) await usecase.update_project_permissions(input=permissions_input)
except HTTPException as e: except HTTPException as e:
log.error(e) log.error(e)
return return
@@ -71,7 +71,7 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
bot_added = was_not_member and is_now_member bot_added = was_not_member and is_now_member
if bot_added: if bot_added:
connect_input = dto.ConnectTargetChanInput( connect_input = dto.ConnectProjectInput(
telegram_id=event.chat.id, telegram_id=event.chat.id,
title=event.chat.title or f'Channel {event.chat.id}', title=event.chat.title or f'Channel {event.chat.id}',
username=event.chat.username, username=event.chat.username,
@@ -79,6 +79,6 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None:
bot_permissions=bot_permissions, bot_permissions=bot_permissions,
) )
try: try:
await usecase.tg_add_target_chan_1(input=connect_input) await usecase.tg_add_project_1(input=connect_input)
except HTTPException as e: except HTTPException as e:
log.error(e) log.error(e)

View File

@@ -9,14 +9,14 @@ from src.controller.telegram_callback import telegram_callback_router
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@telegram_callback_router.callback_query(F.data.startswith('tg_add_target_chan_2:')) @telegram_callback_router.callback_query(F.data.startswith('tg_add_project_2:'))
async def callback_target_channel_workspace(callback: CallbackQuery) -> None: async def callback_project_workspace(callback: CallbackQuery) -> None:
if not callback.from_user or not callback.message or not callback.data: if not callback.from_user or not callback.message or not callback.data:
log.error('Failed to get required data from callback query') log.error('Failed to get required data from callback query')
return return
usecase = deps.get_usecase() usecase = deps.get_usecase()
await usecase.tg_add_target_chan_2( await usecase.tg_add_project_2(
telegram_id=callback.from_user.id, telegram_id=callback.from_user.id,
chat_id=callback.message.chat.id, chat_id=callback.message.chat.id,
callback_data=callback.data, callback_data=callback.data,

View File

@@ -2,16 +2,29 @@ __all__ = (
'User', 'User',
'Workspace', 'Workspace',
'WorkspaceUser', 'WorkspaceUser',
'TargetChannel', 'WorkspaceInvite',
'ExternalChannel', 'WorkspaceInviteStatus',
'WorkspaceUserStatus',
'WorkspaceUserPermission',
'WorkspaceUserPermissionScope',
'PermissionKey',
'PermissionScopeType',
'Channel',
'ChannelVerificationStatus',
'Project',
'ProjectStatus',
'PurchasePlan',
'PurchasePlanChannel',
'PurchasePlanStatus',
'PurchasePlanChannelStatus',
'Creative', 'Creative',
'Placement', 'Placement',
'Subscription', 'Subscription',
'Subscriber',
'PlacementViewsHistory', 'PlacementViewsHistory',
'TelegramState', 'TelegramState',
'TelegramStateEnum', 'TelegramStateEnum',
'TargetChannelStatus', 'ChannelNotFound',
'ProjectNotFound',
'CreativeStatus', 'CreativeStatus',
'PlacementStatus', 'PlacementStatus',
'SubscriptionStatus', 'SubscriptionStatus',
@@ -24,40 +37,57 @@ __all__ = (
'LoginTokenNotFound', 'LoginTokenNotFound',
'LoginTokenExpired', 'LoginTokenExpired',
'LoginTokenAlreadyUsed', 'LoginTokenAlreadyUsed',
'TargetChannelNotFound', 'ProjectNotFound',
'PurchasePlanNotFound',
'PurchasePlanChannelNotFound',
'ChannelNotFound',
'ChannelAlreadyExists', 'ChannelAlreadyExists',
'ChannelNoAdminRights', 'ChannelNoAdminRights',
'ExternalChannelNotFound',
'ExternalChannelAlreadyExists',
'CreativeNotFound', 'CreativeNotFound',
'CreativeInUse', 'CreativeInUse',
'PlacementNotFound', 'PlacementNotFound',
) )
from .channel import Channel, ChannelVerificationStatus
from .creative import Creative, CreativeStatus from .creative import Creative, CreativeStatus
from .error import ( from .error import (
ChannelAlreadyExists, ChannelAlreadyExists,
ChannelNoAdminRights, ChannelNoAdminRights,
ChannelNotFound,
CreativeInUse, CreativeInUse,
CreativeNotFound, CreativeNotFound,
ExternalChannelAlreadyExists,
ExternalChannelNotFound,
LoginTokenAlreadyUsed, LoginTokenAlreadyUsed,
LoginTokenExpired, LoginTokenExpired,
LoginTokenNotFound, LoginTokenNotFound,
PlacementNotFound, PlacementNotFound,
TargetChannelNotFound, ProjectNotFound,
PurchasePlanChannelNotFound,
PurchasePlanNotFound,
UserNotFound, UserNotFound,
WorkspaceAccessDenied, WorkspaceAccessDenied,
WorkspaceNotFound, WorkspaceNotFound,
) )
from .external_channel import ExternalChannel
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 .placement_views_history import PlacementViewsHistory
from .subscriber import Subscriber from .project import Project, ProjectStatus
from .purchase_plan import (
PurchasePlan,
PurchasePlanChannel,
PurchasePlanChannelStatus,
PurchasePlanStatus,
)
from .subscription import Subscription, SubscriptionStatus from .subscription import Subscription, SubscriptionStatus
from .target_channel import TargetChannel, TargetChannelStatus
from .telegram_state import TelegramState, TelegramStateEnum from .telegram_state import TelegramState, TelegramStateEnum
from .user import User from .user import User
from .workspace import Workspace, WorkspaceUser from .workspace import (
PermissionKey,
PermissionScopeType,
Workspace,
WorkspaceInvite,
WorkspaceInviteStatus,
WorkspaceUser,
WorkspaceUserPermission,
WorkspaceUserPermissionScope,
WorkspaceUserStatus,
)

34
src/domain/channel.py Normal file
View File

@@ -0,0 +1,34 @@
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)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='channels', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
workspace_id: uuid.UUID
class Meta:
table = 'channel'

View File

@@ -7,7 +7,7 @@ from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .target_channel import TargetChannel from .project import Project
from .workspace import Workspace from .workspace import Workspace
@@ -26,13 +26,13 @@ class Creative(TimestampedModel):
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True 'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True
) )
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.TargetChannel', related_name='creatives', on_delete=fields.CASCADE, index=True 'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
workspace_id: UUID workspace_id: UUID
target_channel_id: UUID project_id: UUID
class Meta: class Meta:
table = 'creative' table = 'creative'

View File

@@ -21,10 +21,28 @@ def LoginTokenAlreadyUsed() -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used') return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used')
def TargetChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException: def ChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None: if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Target channel not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Target channel {channel_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Channel {channel_id} not found')
def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
if project_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Project not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException:
if plan_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found')
def PurchasePlanChannelNotFound(plan_channel_id: uuid.UUID | None = None) -> HTTPException:
if plan_channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException: def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
@@ -37,16 +55,6 @@ def ChannelNoAdminRights() -> HTTPException:
) )
def ExternalChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException:
if channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'External channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'External channel {channel_id} not found')
def ExternalChannelAlreadyExists(telegram_id: int) -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, f'External channel {telegram_id} already exists')
def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException: def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException:
if creative_id is None: if creative_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found')

View File

@@ -1,30 +0,0 @@
from typing import TYPE_CHECKING
from uuid import UUID
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .target_channel import TargetChannel
from .workspace import Workspace
class ExternalChannel(TimestampedModel):
id = fields.UUIDField(pk=True)
telegram_id = fields.BigIntField(index=True)
title = fields.CharField(max_length=255)
username = fields.CharField(max_length=255, null=True, index=True)
description = fields.TextField(null=True)
subscribers_count = fields.IntField(null=True)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='external_channels', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
target_channels: fields.ManyToManyRelation['TargetChannel']
workspace_id: UUID
class Meta:
table = 'external_channel'

View File

@@ -7,9 +7,9 @@ from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .channel import Channel
from .creative import Creative from .creative import Creative
from .external_channel import ExternalChannel from .project import Project
from .target_channel import TargetChannel
from .workspace import Workspace from .workspace import Workspace
@@ -50,11 +50,11 @@ class Placement(TimestampedModel):
views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN) views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN)
last_views_fetch_at = fields.DatetimeField(null=True) last_views_fetch_at = fields.DatetimeField(null=True)
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.TargetChannel', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
) )
external_channel: fields.ForeignKeyRelation['ExternalChannel'] = fields.ForeignKeyField( placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.ExternalChannel', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
) )
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
@@ -64,8 +64,8 @@ class Placement(TimestampedModel):
) )
if TYPE_CHECKING: if TYPE_CHECKING:
target_channel_id: UUID project_id: UUID
external_channel_id: UUID placement_channel_id: UUID
creative_id: UUID creative_id: UUID
workspace_id: UUID workspace_id: UUID

37
src/domain/project.py Normal file
View File

@@ -0,0 +1,37 @@
import enum
from typing import TYPE_CHECKING
from uuid import UUID
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .channel import Channel
from .workspace import Workspace
class ProjectStatus(str, enum.Enum):
ACTIVE = 'active'
INACTIVE = 'inactive'
ARCHIVED = 'archived'
class Project(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
workspace_id: UUID
channel_id: UUID
class Meta:
table = 'project'
unique_together = (('workspace_id', 'channel_id'),)

View File

@@ -0,0 +1,63 @@
import enum
import uuid
from typing import TYPE_CHECKING
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .channel import Channel
from .project import Project
from .workspace import Workspace
class PurchasePlanStatus(str, enum.Enum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class PurchasePlanChannelStatus(str, enum.Enum):
PLANNED = 'planned'
APPROVED = 'approved'
REJECTED = 'rejected'
IN_PROGRESS = 'in_progress'
COMPLETED = 'completed'
class PurchasePlan(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='purchase_plans', on_delete=fields.CASCADE, index=True
)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='purchase_plans', on_delete=fields.CASCADE, index=True
)
class Meta:
table = 'purchase_plan'
indexes = (('project', 'status'),)
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)
purchase_plan: fields.ForeignKeyRelation['PurchasePlan'] = fields.ForeignKeyField(
'models.PurchasePlan', related_name='channels', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='purchase_plan_channels', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
purchase_plan_id: uuid.UUID
channel_id: uuid.UUID
class Meta:
table = 'purchase_plan_channel'
unique_together = (('purchase_plan_id', 'channel_id'),)

View File

@@ -1,14 +0,0 @@
from tortoise import fields
from .base import TimestampedModel
class Subscriber(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)
last_name = fields.CharField(max_length=255, null=True)
class Meta:
table = 'subscriber'

View File

@@ -8,8 +8,7 @@ from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .placement import Placement from .placement import Placement
from .subscriber import Subscriber from .user import User
from .target_channel import TargetChannel
class SubscriptionStatus(str, enum.Enum): class SubscriptionStatus(str, enum.Enum):
@@ -26,17 +25,13 @@ class Subscription(TimestampedModel):
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True
) )
subscriber: fields.ForeignKeyRelation['Subscriber'] = fields.ForeignKeyField( subscriber: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField(
'models.Subscriber', related_name='subscriptions', on_delete=fields.CASCADE, index=True 'models.User', related_name='subscriptions', on_delete=fields.CASCADE, index=True
)
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField(
'models.TargetChannel', related_name='subscriptions', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
placement_id: UUID placement_id: UUID
subscriber_id: UUID subscriber_id: UUID
target_channel_id: UUID
class Meta: class Meta:
table = 'subscription' table = 'subscription'

View File

@@ -1,43 +0,0 @@
import enum
from typing import TYPE_CHECKING
from uuid import UUID
from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
from .external_channel import ExternalChannel
from .workspace import Workspace
class TargetChannelStatus(str, enum.Enum):
ACTIVE = 'active'
INACTIVE = 'inactive'
ARCHIVED = 'archived'
class TargetChannel(TimestampedModel):
id = fields.UUIDField(pk=True)
telegram_id = fields.BigIntField(unique=True, index=True)
title = fields.CharField(max_length=255)
username = fields.CharField(max_length=255, null=True, index=True)
status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='target_channels', on_delete=fields.CASCADE, index=True
)
external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField(
'models.ExternalChannel',
related_name='target_channels',
through='target_external_channel',
forward_key='external_channel_id',
backward_key='target_channel_id',
)
if TYPE_CHECKING:
workspace_id: UUID
class Meta:
table = 'target_channel'

View File

@@ -10,7 +10,7 @@ class TelegramStateEnum(str, enum.Enum):
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel' CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
CREATIVE_WAITING_NAME = 'creative_waiting_name' CREATIVE_WAITING_NAME = 'creative_waiting_name'
CREATIVE_WAITING_TEXT = 'creative_waiting_text' CREATIVE_WAITING_TEXT = 'creative_waiting_text'
TARGET_CHANNEL_WAITING_WORKSPACE = 'target_channel_waiting_workspace' PROJECT_WAITING_WORKSPACE = 'project_waiting_workspace'
class TelegramState(TimestampedModel): class TelegramState(TimestampedModel):

View File

@@ -7,6 +7,8 @@ class User(TimestampedModel):
id = fields.UUIDField(pk=True) 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)
last_name = fields.CharField(max_length=255, null=True)
class Meta: class Meta:
table = 'user' table = 'user'

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import enum
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -19,8 +20,15 @@ class Workspace(TimestampedModel):
table = 'workspace' table = 'workspace'
class WorkspaceUserStatus(str, enum.Enum):
ACTIVE = 'active'
INVITED = 'invited'
REMOVED = 'removed'
class WorkspaceUser(TimestampedModel): class WorkspaceUser(TimestampedModel):
id = fields.UUIDField(pk=True) id = fields.UUIDField(pk=True)
status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE)
workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
'models.Workspace', related_name='workspace_users', on_delete=fields.CASCADE, index=True 'models.Workspace', related_name='workspace_users', on_delete=fields.CASCADE, index=True
@@ -36,3 +44,65 @@ class WorkspaceUser(TimestampedModel):
class Meta: class Meta:
table = 'workspace_user' table = 'workspace_user'
unique_together = (('workspace_id', 'user_id'),) unique_together = (('workspace_id', 'user_id'),)
class WorkspaceInviteStatus(str, enum.Enum):
PENDING = 'pending'
ACCEPTED = 'accepted'
REVOKED = 'revoked'
class WorkspaceInvite(TimestampedModel):
id = fields.UUIDField(pk=True)
status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING)
workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField(
'models.Workspace', related_name='invites', on_delete=fields.CASCADE, index=True
)
invited_by: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
'models.User', related_name='sent_invites', on_delete=fields.CASCADE, index=True
)
user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField(
'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True
)
class Meta:
table = 'workspace_invite'
unique_together = (('workspace_id', 'user_id'),)
class PermissionKey(str, enum.Enum):
MANAGE_PROJECTS = 'manage_projects'
MANAGE_PLACEMENTS = 'manage_placements'
VIEW_ANALYTICS = 'view_analytics'
class PermissionScopeType(str, enum.Enum):
WORKSPACE = 'workspace'
PROJECT = 'project'
class WorkspaceUserPermission(TimestampedModel):
workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField(
'models.WorkspaceUser', related_name='permissions', on_delete=fields.CASCADE, index=True
)
permission = fields.CharEnumField(PermissionKey)
class Meta:
table = 'workspace_user_permission'
unique_together = (('workspace_user_id', 'permission'),)
class WorkspaceUserPermissionScope(TimestampedModel):
id = fields.UUIDField(pk=True)
permission = fields.CharEnumField(PermissionKey)
scope_type = fields.CharEnumField(PermissionScopeType)
scope_id = fields.UUIDField()
workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField(
'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True
)
class Meta:
table = 'workspace_user_permission_scope'
unique_together = (('workspace_user_id', 'permission', 'scope_type', 'scope_id'),)

View File

@@ -1,21 +1,22 @@
__all__ = ( __all__ = (
'UpdateTargetChanPermissionsInput', 'UpdateProjectPermissionsInput',
'GetUserTargetChansInput', 'GetWorkspaceProjectsInput',
'GetUserTargetChansOutput', 'GetWorkspaceProjectsOutput',
'DisconnectTargetChanByTgIdInput', 'DisconnectProjectByTgIdInput',
'ConnectTargetChanInput', 'ConnectProjectInput',
'ConnectTargetChanOutput', 'ProjectOutput',
'ValidateLoginTokenInput', 'ValidateLoginTokenInput',
'ValidateLoginTokenOutput', 'ValidateLoginTokenOutput',
'TelegramLoginInput', 'TelegramLoginInput',
'ChannelBotPermissions', 'ChannelBotPermissions',
'ExternalChannelOutput', 'ChannelOutput',
'GetExternalChannelsInput', 'GetChannelsInput',
'GetExternalChannelsOutput', 'GetChannelsOutput',
'CreateExternalChannelInput', 'PurchasePlanChannelOutput',
'DeleteExternalChannelInput', 'GetPurchasePlanChannelsInput',
'UpdateExternalChannelInput', 'GetPurchasePlanChannelsOutput',
'UpdateExternalChannelLinksInput', 'AttachChannelToPurchasePlanInput',
'UpdatePurchasePlanChannelInput',
'CreativeOutput', 'CreativeOutput',
'GetCreativesInput', 'GetCreativesInput',
'GetCreativesOutput', 'GetCreativesOutput',
@@ -37,14 +38,14 @@ __all__ = (
'UserOutput', 'UserOutput',
'DateGrouping', 'DateGrouping',
'PlacementAnalyticsOutput', 'PlacementAnalyticsOutput',
'ChannelAnalyticsOutput',
'CreativeAnalyticsOutput', 'CreativeAnalyticsOutput',
'ExternalChannelAnalyticsOutput',
'GetPlacementsAnalyticsInput', 'GetPlacementsAnalyticsInput',
'GetPlacementsAnalyticsOutput', 'GetPlacementsAnalyticsOutput',
'GetCreativesAnalyticsInput', 'GetCreativesAnalyticsInput',
'GetCreativesAnalyticsOutput', 'GetCreativesAnalyticsOutput',
'GetExternalChannelsAnalyticsInput', 'GetChannelAnalyticsInput',
'GetExternalChannelsAnalyticsOutput', 'GetChannelAnalyticsOutput',
'SpendingDataPoint', 'SpendingDataPoint',
'GetSpendingAnalyticsInput', 'GetSpendingAnalyticsInput',
'GetSpendingAnalyticsOutput', 'GetSpendingAnalyticsOutput',
@@ -56,13 +57,13 @@ __all__ = (
) )
from .analytics import ( from .analytics import (
ChannelAnalyticsOutput,
CreativeAnalyticsOutput, CreativeAnalyticsOutput,
DateGrouping, DateGrouping,
ExternalChannelAnalyticsOutput, GetChannelAnalyticsInput,
GetChannelAnalyticsOutput,
GetCreativesAnalyticsInput, GetCreativesAnalyticsInput,
GetCreativesAnalyticsOutput, GetCreativesAnalyticsOutput,
GetExternalChannelsAnalyticsInput,
GetExternalChannelsAnalyticsOutput,
GetPlacementsAnalyticsInput, GetPlacementsAnalyticsInput,
GetPlacementsAnalyticsOutput, GetPlacementsAnalyticsOutput,
GetSpendingAnalyticsInput, GetSpendingAnalyticsInput,
@@ -70,6 +71,7 @@ from .analytics import (
PlacementAnalyticsOutput, PlacementAnalyticsOutput,
SpendingDataPoint, SpendingDataPoint,
) )
from .channel import ChannelOutput, GetChannelsInput, GetChannelsOutput
from .creative import ( from .creative import (
CreateCreativeInput, CreateCreativeInput,
CreativeOutput, CreativeOutput,
@@ -79,15 +81,6 @@ from .creative import (
GetCreativesOutput, GetCreativesOutput,
UpdateCreativeInput, UpdateCreativeInput,
) )
from .external_channel import (
CreateExternalChannelInput,
DeleteExternalChannelInput,
ExternalChannelOutput,
GetExternalChannelsInput,
GetExternalChannelsOutput,
UpdateExternalChannelInput,
UpdateExternalChannelLinksInput,
)
from .placement import ( from .placement import (
CreatePlacementInput, CreatePlacementInput,
DeletePlacementInput, DeletePlacementInput,
@@ -97,14 +90,21 @@ from .placement import (
PlacementOutput, PlacementOutput,
UpdatePlacementInput, UpdatePlacementInput,
) )
from .target_channel import ( from .project import (
ChannelBotPermissions, ChannelBotPermissions,
ConnectTargetChanInput, ConnectProjectInput,
ConnectTargetChanOutput, DisconnectProjectByTgIdInput,
DisconnectTargetChanByTgIdInput, GetWorkspaceProjectsInput,
GetUserTargetChansInput, GetWorkspaceProjectsOutput,
GetUserTargetChansOutput, ProjectOutput,
UpdateTargetChanPermissionsInput, UpdateProjectPermissionsInput,
)
from .purchase_plan import (
AttachChannelToPurchasePlanInput,
GetPurchasePlanChannelsInput,
GetPurchasePlanChannelsOutput,
PurchasePlanChannelOutput,
UpdatePurchasePlanChannelInput,
) )
from .telegram_login import TelegramLoginInput from .telegram_login import TelegramLoginInput
from .user import UserOutput from .user import UserOutput

View File

@@ -15,10 +15,10 @@ class DateGrouping(StrEnum):
class PlacementAnalyticsOutput(pydantic.BaseModel): class PlacementAnalyticsOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
target_channel_id: uuid.UUID project_id: uuid.UUID
target_channel_title: str project_channel_title: str
external_channel_id: uuid.UUID placement_channel_id: uuid.UUID
external_channel_title: str placement_channel_title: str
creative_id: uuid.UUID creative_id: uuid.UUID
creative_name: str creative_name: str
placement_date: datetime.datetime placement_date: datetime.datetime
@@ -40,7 +40,7 @@ class CreativeAnalyticsOutput(pydantic.BaseModel):
avg_cpm: float | None avg_cpm: float | None
class ExternalChannelAnalyticsOutput(pydantic.BaseModel): class ChannelAnalyticsOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
title: str title: str
username: str | None username: str | None
@@ -55,7 +55,7 @@ class ExternalChannelAnalyticsOutput(pydantic.BaseModel):
class GetPlacementsAnalyticsInput(pydantic.BaseModel): class GetPlacementsAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
class GetPlacementsAnalyticsOutput(pydantic.BaseModel): class GetPlacementsAnalyticsOutput(pydantic.BaseModel):
@@ -65,21 +65,21 @@ class GetPlacementsAnalyticsOutput(pydantic.BaseModel):
class GetCreativesAnalyticsInput(pydantic.BaseModel): class GetCreativesAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
class GetCreativesAnalyticsOutput(pydantic.BaseModel): class GetCreativesAnalyticsOutput(pydantic.BaseModel):
creatives: list[CreativeAnalyticsOutput] creatives: list[CreativeAnalyticsOutput]
class GetExternalChannelsAnalyticsInput(pydantic.BaseModel): class GetChannelAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
class GetExternalChannelsAnalyticsOutput(pydantic.BaseModel): class GetChannelAnalyticsOutput(pydantic.BaseModel):
external_channels: list[ExternalChannelAnalyticsOutput] channels: list[ChannelAnalyticsOutput]
class SpendingDataPoint(pydantic.BaseModel): class SpendingDataPoint(pydantic.BaseModel):
@@ -94,7 +94,7 @@ class SpendingDataPoint(pydantic.BaseModel):
class GetSpendingAnalyticsInput(pydantic.BaseModel): class GetSpendingAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
date_from: datetime.datetime | None = None date_from: datetime.datetime | None = None
date_to: datetime.datetime | None = None date_to: datetime.datetime | None = None
grouping: DateGrouping = DateGrouping.DAY grouping: DateGrouping = DateGrouping.DAY

21
src/dto/channel.py Normal file
View File

@@ -0,0 +1,21 @@
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):
username: str | None = None
class GetChannelsOutput(pydantic.BaseModel):
channels: list[ChannelOutput]

View File

@@ -10,8 +10,8 @@ class CreativeOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
text: str text: str
target_channel_id: uuid.UUID project_id: uuid.UUID
target_channel_title: str project_channel_title: str
created_at: datetime.datetime created_at: datetime.datetime
status: domain.CreativeStatus status: domain.CreativeStatus
placements_count: int placements_count: int
@@ -20,7 +20,7 @@ class CreativeOutput(pydantic.BaseModel):
class GetCreativesInput(pydantic.BaseModel): class GetCreativesInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
include_archived: bool = False include_archived: bool = False
@@ -37,7 +37,7 @@ class GetCreativeInput(pydantic.BaseModel):
class CreateCreativeInput(pydantic.BaseModel): class CreateCreativeInput(pydantic.BaseModel):
name: str name: str
text: str text: str
target_channel_id: uuid.UUID project_id: uuid.UUID
class UpdateCreativeInput(pydantic.BaseModel): class UpdateCreativeInput(pydantic.BaseModel):

View File

@@ -1,49 +0,0 @@
import uuid
import pydantic
class ExternalChannelOutput(pydantic.BaseModel):
id: uuid.UUID
telegram_id: int
title: str
username: str | None
description: str | None
subscribers_count: int | None
class GetExternalChannelsInput(pydantic.BaseModel):
target_channel_id: uuid.UUID
user_id: uuid.UUID
workspace_id: uuid.UUID
class GetExternalChannelsOutput(pydantic.BaseModel):
external_channels: list[ExternalChannelOutput]
class CreateExternalChannelInput(pydantic.BaseModel):
telegram_id: int
title: str
username: str | None = None
description: str | None = None
subscribers_count: int | None = None
target_channel_ids: list[uuid.UUID]
class DeleteExternalChannelInput(pydantic.BaseModel):
channel_id: uuid.UUID
user_id: uuid.UUID
workspace_id: uuid.UUID
class UpdateExternalChannelInput(pydantic.BaseModel):
title: str | None = None
username: str | None = None
description: str | None = None
subscribers_count: int | None = None
class UpdateExternalChannelLinksInput(pydantic.BaseModel):
add_target_channel_ids: list[uuid.UUID] = []
remove_target_channel_ids: list[uuid.UUID] = []

View File

@@ -8,10 +8,10 @@ from src import domain
class PlacementOutput(pydantic.BaseModel): class PlacementOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
target_channel_id: uuid.UUID project_id: uuid.UUID
target_channel_title: str project_channel_title: str
external_channel_id: uuid.UUID placement_channel_id: uuid.UUID
external_channel_title: str placement_channel_title: str
creative_id: uuid.UUID creative_id: uuid.UUID
creative_name: str creative_name: str
placement_date: datetime.datetime placement_date: datetime.datetime
@@ -31,8 +31,8 @@ class PlacementOutput(pydantic.BaseModel):
class GetPlacementsInput(pydantic.BaseModel): class GetPlacementsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
external_channel_id: uuid.UUID | None = None placement_channel_id: uuid.UUID | None = None
creative_id: uuid.UUID | None = None creative_id: uuid.UUID | None = None
include_archived: bool = False include_archived: bool = False
@@ -48,8 +48,8 @@ class GetPlacementInput(pydantic.BaseModel):
class CreatePlacementInput(pydantic.BaseModel): class CreatePlacementInput(pydantic.BaseModel):
target_channel_id: uuid.UUID project_id: uuid.UUID
external_channel_id: uuid.UUID placement_channel_id: uuid.UUID
creative_id: uuid.UUID creative_id: uuid.UUID
placement_date: datetime.datetime placement_date: datetime.datetime
cost: float | None = None cost: float | None = None

View File

@@ -2,7 +2,7 @@ import uuid
import pydantic import pydantic
from src.domain.target_channel import TargetChannelStatus from src.domain.project import ProjectStatus
class ChannelBotPermissions(pydantic.BaseModel): class ChannelBotPermissions(pydantic.BaseModel):
@@ -17,7 +17,7 @@ class ChannelBotPermissions(pydantic.BaseModel):
can_pin_messages: bool | None = None can_pin_messages: bool | None = None
class ConnectTargetChanInput(pydantic.BaseModel): class ConnectProjectInput(pydantic.BaseModel):
telegram_id: int telegram_id: int
title: str title: str
username: str | None username: str | None
@@ -25,31 +25,29 @@ class ConnectTargetChanInput(pydantic.BaseModel):
bot_permissions: ChannelBotPermissions bot_permissions: ChannelBotPermissions
class ConnectTargetChanOutput(pydantic.BaseModel): class ProjectOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
telegram_id: int telegram_id: int
title: str title: str
username: str | None username: str | None
status: TargetChannelStatus status: ProjectStatus
# Deprecated: используйте status. Оставлено для обратной совместимости с фронтендом
is_active: bool
class GetUserTargetChansInput(pydantic.BaseModel): class GetWorkspaceProjectsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
class GetUserTargetChansOutput(pydantic.BaseModel): class GetWorkspaceProjectsOutput(pydantic.BaseModel):
target_channels: list[ConnectTargetChanOutput] projects: list[ProjectOutput]
class DisconnectTargetChanByTgIdInput(pydantic.BaseModel): class DisconnectProjectByTgIdInput(pydantic.BaseModel):
telegram_id: int telegram_id: int
user_telegram_id: int user_telegram_id: int
class UpdateTargetChanPermissionsInput(pydantic.BaseModel): class UpdateProjectPermissionsInput(pydantic.BaseModel):
telegram_id: int telegram_id: int
permissions: ChannelBotPermissions permissions: ChannelBotPermissions
chat_title: str chat_title: str

38
src/dto/purchase_plan.py Normal file
View File

@@ -0,0 +1,38 @@
import uuid
import pydantic
from src.domain.purchase_plan import PurchasePlanChannelStatus
from .channel import ChannelOutput
class PurchasePlanChannelOutput(pydantic.BaseModel):
id: uuid.UUID
status: PurchasePlanChannelStatus
planned_cost: float | None = None
comment: str | None = None
channel: ChannelOutput
class GetPurchasePlanChannelsInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID
class GetPurchasePlanChannelsOutput(pydantic.BaseModel):
channels: list[PurchasePlanChannelOutput]
class AttachChannelToPurchasePlanInput(pydantic.BaseModel):
username: str
status: PurchasePlanChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None
class UpdatePurchasePlanChannelInput(pydantic.BaseModel):
status: PurchasePlanChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None

View File

@@ -8,14 +8,15 @@ if typing.TYPE_CHECKING:
from src import domain from src import domain
from .analytics.get_channel_analytics import get_channel_analytics
from .analytics.get_creatives_analytics import get_creatives_analytics from .analytics.get_creatives_analytics import get_creatives_analytics
from .analytics.get_external_channels_analytics import get_external_channels_analytics
from .analytics.get_placements_analytics import get_placements_analytics from .analytics.get_placements_analytics import get_placements_analytics
from .analytics.get_spending_analytics import get_spending_analytics from .analytics.get_spending_analytics import get_spending_analytics
from .auth.get_me import get_me from .auth.get_me import get_me
from .auth.telegram_login import telegram_login from .auth.telegram_login import telegram_login
from .auth.telegram_start import telegram_start from .auth.telegram_start import telegram_start
from .auth.validate_login_token import validate_login_token from .auth.validate_login_token import validate_login_token
from .channel.get_channels import get_channels
from .creative.create_creative import create_creative from .creative.create_creative import create_creative
from .creative.delete_creative import delete_creative from .creative.delete_creative import delete_creative
from .creative.get_creative import get_creative from .creative.get_creative import get_creative
@@ -25,23 +26,22 @@ from .creative.tg_create_creative_2 import tg_create_creative_2
from .creative.tg_create_creative_3 import tg_create_creative_3 from .creative.tg_create_creative_3 import tg_create_creative_3
from .creative.tg_create_creative_4 import tg_create_creative_4 from .creative.tg_create_creative_4 import tg_create_creative_4
from .creative.update_creative import update_creative from .creative.update_creative import update_creative
from .external_channel.create_external_channel import create_external_channel
from .external_channel.delete_external_channel import delete_external_channel
from .external_channel.get_external_channels import get_external_channels
from .external_channel.update_external_channel import update_external_channel
from .external_channel.update_external_channel_links import update_external_channel_links
from .placement.create_placement import create_placement from .placement.create_placement import create_placement
from .placement.delete_placement import delete_placement from .placement.delete_placement import delete_placement
from .placement.get_placement import get_placement from .placement.get_placement import get_placement
from .placement.get_placements import get_placements from .placement.get_placements import get_placements
from .placement.update_placement import update_placement from .placement.update_placement import update_placement
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
from .project.get_workspace_projects import get_workspace_projects
from .project.tg_add_project_1 import tg_add_project_1
from .project.tg_add_project_2 import tg_add_project_2
from .project.update_project_permissions import update_project_permissions
from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan
from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels
from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel
from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_channel
from .subscription.handle_subscription import handle_subscription from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription from .subscription.handle_unsubscription import handle_unsubscription
from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_chan_by_tg_id
from .target_channel.get_user_target_chans import get_user_target_chans
from .target_channel.tg_add_target_chan_1 import tg_add_target_chan_1
from .target_channel.tg_add_target_chan_2 import tg_add_target_chan_2
from .target_channel.update_target_chan_permissions import update_target_chan_permissions
from .views.get_views_history import get_views_history from .views.get_views_history import get_views_history
from .workspace.create_workspace import create_workspace from .workspace.create_workspace import create_workspace
from .workspace.delete_workspace import delete_workspace from .workspace.delete_workspace import delete_workspace
@@ -82,46 +82,74 @@ class Database(typing.Protocol):
async def mark_token_as_used(self, token: str) -> None: ... async def mark_token_as_used(self, token: str) -> None: ...
async def get_target_channel( async def update_user(self, user: domain.User) -> None: ...
self, workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
) -> domain.TargetChannel | None: ...
async def get_target_channel_for_user_by_telegram( async def get_channel(
self, user_id: UUID, telegram_id: int self,
) -> domain.TargetChannel | None: ... workspace_id: UUID,
channel_id: UUID | None = None,
telegram_id: int | None = None,
username: str | None = None,
) -> domain.Channel | None: ...
async def upsert_target_channel(self, channel: domain.TargetChannel) -> None: ... async def get_workspace_channels(self, workspace_id: UUID) -> list[domain.Channel]: ...
async def get_workspace_target_channels(self, workspace_id: UUID) -> list[domain.TargetChannel]: ... async def create_channel(self, channel: domain.Channel) -> None: ...
async def update_target_channel(self, channel: domain.TargetChannel) -> None: ... async def update_channel(self, channel: domain.Channel) -> None: ...
async def get_external_channel( async def delete_channel(self, channel_id: UUID) -> None: ...
self, workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None
) -> domain.ExternalChannel | None: ...
async def create_external_channel(self, channel: domain.ExternalChannel) -> None: ... async def search_channels(self, username_query: str | None = None) -> list[domain.Channel]: ...
async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ... async def get_project(
self, workspace_id: UUID, project_id: UUID | None = None, channel_id: UUID | None = None
) -> domain.Project | None: ...
async def get_workspace_external_channels(self, workspace_id: UUID) -> list[domain.ExternalChannel]: ... async def get_project_for_user_by_telegram(
self, user_id: UUID, channel_telegram_id: int
) -> domain.Project | None: ...
async def add_external_channel_to_targets( async def get_project_by_channel_telegram(self, channel_telegram_id: int) -> domain.Project | None: ...
self, external_channel: domain.ExternalChannel, target_channel_ids: list[UUID]
) -> None: ...
async def remove_external_channel_from_target(self, external_channel_id: UUID, target_channel_id: UUID) -> None: ... async def create_project(self, project: domain.Project) -> None: ...
async def delete_external_channel(self, channel_id: UUID) -> None: ... async def update_project(self, project: domain.Project) -> None: ...
async def update_external_channel(self, channel: domain.ExternalChannel) -> None: ... async def get_workspace_projects(self, workspace_id: UUID) -> list[domain.Project]: ...
async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ...
async def get_purchase_plan(self, workspace_id: UUID, plan_id: UUID) -> domain.PurchasePlan | None: ...
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: ...
async def get_purchase_plan_channels(self, plan_id: UUID) -> list[domain.PurchasePlanChannel]: ...
async def get_purchase_plan_channel(
self, plan_channel_id: UUID, plan_id: UUID
) -> domain.PurchasePlanChannel | None: ...
async def add_channel_to_purchase_plan(
self,
plan_id: UUID,
channel_id: UUID,
*,
status: domain.PurchasePlanChannelStatus | None = None,
planned_cost: float | None = None,
comment: str | None = None,
) -> domain.PurchasePlanChannel: ...
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: ...
async def remove_channel_from_purchase_plan(self, plan_id: UUID, channel_id: UUID) -> None: ...
async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ... async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
async def update_creative(self, creative: domain.Creative) -> None: ... async def update_creative(self, creative: domain.Creative) -> None: ...
async def get_workspace_creatives( async def get_workspace_creatives(
self, workspace_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False self, workspace_id: UUID, project_id: UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]: ... ) -> list[domain.Creative]: ...
async def delete_creative(self, creative_id: UUID) -> None: ... async def delete_creative(self, creative_id: UUID) -> None: ...
@@ -135,8 +163,8 @@ class Database(typing.Protocol):
async def get_workspace_placements( async def get_workspace_placements(
self, self,
workspace_id: UUID, workspace_id: UUID,
target_channel_id: UUID | None = None, project_id: UUID | None = None,
external_channel_id: UUID | None = None, placement_channel_id: UUID | None = None,
creative_id: UUID | None = None, creative_id: UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> list[domain.Placement]: ... ) -> list[domain.Placement]: ...
@@ -145,10 +173,6 @@ class Database(typing.Protocol):
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ... async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ...
async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: ...
async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None: ...
async def create_subscription(self, subscription: domain.Subscription) -> None: ... async def create_subscription(self, subscription: domain.Subscription) -> None: ...
async def get_subscription_by_subscriber_and_placement( async def get_subscription_by_subscriber_and_placement(
@@ -157,12 +181,12 @@ class Database(typing.Protocol):
async def update_subscription(self, subscription: domain.Subscription) -> None: ... async def update_subscription(self, subscription: domain.Subscription) -> None: ...
async def get_active_subscriptions_by_subscriber_and_channel( async def get_active_subscriptions_by_subscriber_and_project(
self, subscriber_id: UUID, channel_telegram_id: int self, subscriber_id: UUID, project_id: UUID
) -> list[domain.Subscription]: ... ) -> list[domain.Subscription]: ...
async def get_active_subscription_by_subscriber_and_channel( async def get_active_subscription_by_subscriber_and_project(
self, subscriber_id: UUID, channel_telegram_id: int self, subscriber_id: UUID, project_id: UUID
) -> domain.Subscription | None: ... ) -> domain.Subscription | None: ...
async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ... async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ...
@@ -229,20 +253,29 @@ class Usecase:
return workspace return workspace
async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan:
plan = await self.database.get_active_purchase_plan(project.id)
if plan:
return plan
plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id)
await self.database.create_purchase_plan(plan)
return plan
validate_login_token = validate_login_token validate_login_token = validate_login_token
telegram_login = telegram_login telegram_login = telegram_login
telegram_start = telegram_start telegram_start = telegram_start
get_me = get_me get_me = get_me
tg_add_target_chan_1 = tg_add_target_chan_1 tg_add_project_1 = tg_add_project_1
tg_add_target_chan_2 = tg_add_target_chan_2 tg_add_project_2 = tg_add_project_2
get_user_target_chans = get_user_target_chans get_workspace_projects = get_workspace_projects
disconnect_target_chan_by_tg_id = disconnect_target_chan_by_tg_id disconnect_project_by_tg_id = disconnect_project_by_tg_id
update_target_chan_permissions = update_target_chan_permissions update_project_permissions = update_project_permissions
get_external_channels = get_external_channels get_channels = get_channels
create_external_channel = create_external_channel attach_channel_to_purchase_plan = attach_channel_to_purchase_plan
delete_external_channel = delete_external_channel get_purchase_plan_channels = get_purchase_plan_channels
update_external_channel = update_external_channel update_purchase_plan_channel = update_purchase_plan_channel
update_external_channel_links = update_external_channel_links remove_purchase_plan_channel = remove_purchase_plan_channel
get_creatives = get_creatives get_creatives = get_creatives
get_creative = get_creative get_creative = get_creative
create_creative = create_creative create_creative = create_creative
@@ -262,7 +295,7 @@ class Usecase:
get_views_history = get_views_history get_views_history = get_views_history
get_placements_analytics = get_placements_analytics get_placements_analytics = get_placements_analytics
get_creatives_analytics = get_creatives_analytics get_creatives_analytics = get_creatives_analytics
get_external_channels_analytics = get_external_channels_analytics get_channel_analytics = get_channel_analytics
get_spending_analytics = get_spending_analytics get_spending_analytics = get_spending_analytics
get_workspaces = get_workspaces get_workspaces = get_workspaces
create_workspace = create_workspace create_workspace = create_workspace

View File

@@ -1,11 +0,0 @@
from .get_creatives_analytics import get_creatives_analytics
from .get_external_channels_analytics import get_external_channels_analytics
from .get_placements_analytics import get_placements_analytics
from .get_spending_analytics import get_spending_analytics
__all__ = (
'get_placements_analytics',
'get_creatives_analytics',
'get_external_channels_analytics',
'get_spending_analytics',
)

View File

@@ -11,21 +11,21 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def get_external_channels_analytics( async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput:
self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput
) -> dto.GetExternalChannelsAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id) await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_id: if input.project_id:
target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not target_channel: if not project:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.ProjectNotFound(input.project_id)
external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) plan = await self.ensure_active_purchase_plan(project)
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
channels = [pc.channel for pc in plan_channels]
else: else:
external_channels = await self.database.get_workspace_external_channels(input.workspace_id) channels = await self.database.get_workspace_channels(input.workspace_id)
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_placements(
input.workspace_id, input.target_channel_id, include_archived=False input.workspace_id, input.project_id, include_archived=False
) )
@dataclass @dataclass
@@ -35,10 +35,10 @@ async def get_external_channels_analytics(
total_views: int = 0 total_views: int = 0
placements_count: int = 0 placements_count: int = 0
channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in external_channels} channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in channels}
for p in placements: for p in placements:
stats = channel_stats.get(p.external_channel_id) stats = channel_stats.get(p.placement_channel_id)
if stats is None: if stats is None:
continue continue
@@ -48,7 +48,7 @@ async def get_external_channels_analytics(
stats.placements_count += 1 stats.placements_count += 1
result = [] result = []
for ch in external_channels: for ch in channels:
stats = channel_stats[ch.id] stats = channel_stats[ch.id]
avg_cpf = ( avg_cpf = (
@@ -61,7 +61,7 @@ async def get_external_channels_analytics(
) )
result.append( result.append(
dto.ExternalChannelAnalyticsOutput( dto.ChannelAnalyticsOutput(
id=ch.id, id=ch.id,
title=ch.title, title=ch.title,
username=ch.username, username=ch.username,
@@ -74,4 +74,4 @@ async def get_external_channels_analytics(
) )
) )
return dto.GetExternalChannelsAnalyticsOutput(external_channels=result) return dto.GetChannelAnalyticsOutput(channels=result)

View File

@@ -16,16 +16,16 @@ async def get_creatives_analytics(
) -> dto.GetCreativesAnalyticsOutput: ) -> dto.GetCreativesAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id) await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_id: if input.project_id:
target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not target_channel: if not project:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.ProjectNotFound(input.project_id)
creatives = await self.database.get_workspace_creatives( creatives = await self.database.get_workspace_creatives(
input.workspace_id, input.target_channel_id, include_archived=False input.workspace_id, input.project_id, include_archived=False
) )
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_placements(
input.workspace_id, input.target_channel_id, include_archived=False input.workspace_id, input.project_id, include_archived=False
) )
@dataclass @dataclass

View File

@@ -26,22 +26,22 @@ async def get_placements_analytics(
) -> dto.GetPlacementsAnalyticsOutput: ) -> dto.GetPlacementsAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id) await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_id: if input.project_id:
target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not target_channel: if not project:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.ProjectNotFound(input.project_id)
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_placements(
input.workspace_id, input.target_channel_id, include_archived=False input.workspace_id, input.project_id, include_archived=False
) )
result = [ result = [
dto.PlacementAnalyticsOutput( dto.PlacementAnalyticsOutput(
id=p.id, id=p.id,
target_channel_id=p.target_channel_id, project_id=p.project_id,
target_channel_title=p.target_channel.title, project_channel_title=p.project.channel.title,
external_channel_id=p.external_channel_id, placement_channel_id=p.placement_channel_id,
external_channel_title=p.external_channel.title, placement_channel_title=p.placement_channel.title,
creative_id=p.creative_id, creative_id=p.creative_id,
creative_name=p.creative.name, creative_name=p.creative.name,
placement_date=p.placement_date, placement_date=p.placement_date,

View File

@@ -35,13 +35,13 @@ async def get_spending_analytics(
) -> dto.GetSpendingAnalyticsOutput: ) -> dto.GetSpendingAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id) await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_id: if input.project_id:
target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not target_channel: if not project:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.ProjectNotFound(input.project_id)
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_placements(
input.workspace_id, input.target_channel_id, include_archived=False input.workspace_id, input.project_id, include_archived=False
) )
filtered = [ filtered = [

View File

@@ -0,0 +1,28 @@
import logging
from typing import TYPE_CHECKING
from src import dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> dto.GetChannelsOutput:
channels = await self.database.search_channels(username_query=input.username)
log.debug('Found %s channels for username query: %s', len(channels), input.username)
return dto.GetChannelsOutput(
channels=[
dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
verification_status=channel.verification_status,
)
for channel in channels
]
)

View File

@@ -15,16 +15,16 @@ async def create_creative(
) -> dto.CreativeOutput: ) -> dto.CreativeOutput:
await self.ensure_workspace_access(workspace_id, user_id) await self.ensure_workspace_access(workspace_id, user_id)
target_channel = await self.database.get_target_channel(workspace_id, channel_id=input.target_channel_id) project = await self.database.get_project(workspace_id, project_id=input.project_id)
if not target_channel: if not project:
log.warning('User %s attempted to create creative for unavailable target %s', user_id, input.target_channel_id) log.warning('User %s attempted to create creative for unavailable project %s', user_id, input.project_id)
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.ProjectNotFound(input.project_id)
creative = domain.Creative( creative = domain.Creative(
name=input.name, name=input.name,
text=input.text, text=input.text,
status=domain.CreativeStatus.ACTIVE, status=domain.CreativeStatus.ACTIVE,
target_channel_id=target_channel.id, project_id=project.id,
workspace_id=workspace_id, workspace_id=workspace_id,
) )
await self.database.create_creative(creative) await self.database.create_creative(creative)
@@ -33,8 +33,8 @@ async def create_creative(
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
target_channel_id=target_channel.id, project_id=project.id,
target_channel_title=target_channel.title, project_channel_title=project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
placements_count=creative.placements_count, placements_count=creative.placements_count,

View File

@@ -20,8 +20,8 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
target_channel_id=creative.target_channel_id, project_id=creative.project_id,
target_channel_title=creative.target_channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
placements_count=creative.placements_count, placements_count=creative.placements_count,

View File

@@ -10,7 +10,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.Ge
await self.ensure_workspace_access(input.workspace_id, input.user_id) await self.ensure_workspace_access(input.workspace_id, input.user_id)
creatives = await self.database.get_workspace_creatives( creatives = await self.database.get_workspace_creatives(
input.workspace_id, input.target_channel_id, input.include_archived input.workspace_id, input.project_id, input.include_archived
) )
return dto.GetCreativesOutput( return dto.GetCreativesOutput(
@@ -19,8 +19,8 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.Ge
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
target_channel_id=creative.target_channel_id, project_id=creative.project_id,
target_channel_title=creative.target_channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
placements_count=creative.placements_count, placements_count=creative.placements_count,

View File

@@ -36,18 +36,23 @@ async def tg_create_creative_1(self: 'Usecase', telegram_id: int, chat_id: int)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
channels = await self.database.get_workspace_target_channels(workspace.id) projects = await self.database.get_workspace_projects(workspace.id)
if not channels: if not projects:
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
'В выбранном рабочем пространстве нет подключенных каналов. Добавьте канал и попробуйте снова.', 'В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.',
chat_id, chat_id,
) )
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
buttons = [ buttons = [
[InlineKeyboardButton(text=channel.title, callback_data=f'tg_create_creative_3:{channel.id}')] [
for channel in channels InlineKeyboardButton(
text=project.channel.title or project.channel.username or 'Unnamed Channel',
callback_data=f'tg_create_creative_3:{project.id}',
)
]
for project in projects
] ]
await self.telegram_bot.send_message_with_inline_keyboard( await self.telegram_bot.send_message_with_inline_keyboard(

View File

@@ -47,18 +47,23 @@ async def tg_create_creative_2(
await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) await self.telegram_bot.edit_message_reply_markup(chat_id, message_id)
channels = await self.database.get_workspace_target_channels(workspace.id) projects = await self.database.get_workspace_projects(workspace.id)
if not channels: if not projects:
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
'В выбранном рабочем пространстве нет подключенных каналов. Добавьте канал и попробуйте снова.', 'В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.',
chat_id, chat_id,
) )
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
buttons = [ buttons = [
[InlineKeyboardButton(text=channel.title, callback_data=f'tg_create_creative_3:{channel.id}')] [
for channel in channels InlineKeyboardButton(
text=project.channel.title or project.channel.username or 'Unnamed Channel',
callback_data=f'tg_create_creative_3:{project.id}',
)
]
for project in projects
] ]
await self.telegram_bot.send_message_with_inline_keyboard( await self.telegram_bot.send_message_with_inline_keyboard(

View File

@@ -22,11 +22,11 @@ async def tg_create_creative_3(
log.warning('Invalid callback data format: %s', callback_data) log.warning('Invalid callback data format: %s', callback_data)
return return
channel_id_str = callback_data.split(':', 1)[1] project_id_str = callback_data.split(':', 1)[1]
try: try:
channel_id = uuid.UUID(channel_id_str) project_id = uuid.UUID(project_id_str)
except ValueError: except ValueError:
log.error('Invalid channel UUID in callback data: %s', channel_id_str) log.error('Invalid project UUID in callback data: %s', project_id_str)
return return
user = await self.database.get_user(telegram_id=telegram_id) user = await self.database.get_user(telegram_id=telegram_id)
@@ -53,13 +53,13 @@ async def tg_create_creative_3(
workspace_id = workspace.id workspace_id = workspace.id
if not workspace_id: if not workspace_id:
log.error('Workspace ID missing for user %s when selecting channel %s', telegram_id, channel_id) log.error('Workspace ID missing for user %s when selecting project %s', telegram_id, project_id)
await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id) await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
channel = await self.database.get_target_channel(workspace_id, channel_id=channel_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not channel: if not project:
await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id) await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
@@ -68,12 +68,14 @@ async def tg_create_creative_3(
telegram_id, telegram_id,
domain.TelegramStateEnum.CREATIVE_WAITING_NAME, domain.TelegramStateEnum.CREATIVE_WAITING_NAME,
{ {
'target_channel_id': str(channel_id), 'project_id': str(project_id),
'channel_title': channel.title, 'channel_title': project.channel.title,
'workspace_id': str(workspace_id), 'workspace_id': str(workspace_id),
}, },
) )
await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) await self.telegram_bot.edit_message_reply_markup(chat_id, message_id)
await self.telegram_bot.send_message(f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id) await self.telegram_bot.send_message(
f'✅ Канал выбран: {project.channel.title}\n\n📝 Введите название креатива:', chat_id
)

View File

@@ -38,20 +38,20 @@ async def tg_create_creative_4(
) )
elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT: elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT:
target_channel_id_str = state.context.get('target_channel_id') project_id_str = state.context.get('project_id')
creative_name = state.context.get('creative_name') creative_name = state.context.get('creative_name')
workspace_id_str = state.context.get('workspace_id') workspace_id_str = state.context.get('workspace_id')
if not target_channel_id_str or not creative_name: if not project_id_str or not creative_name:
log.error('Missing context data for creative creation: %s', state.context) log.error('Missing context data for creative creation: %s', state.context)
await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id) await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
try: try:
target_channel_id = uuid.UUID(target_channel_id_str) project_id = uuid.UUID(project_id_str)
except ValueError: except ValueError:
log.error('Invalid target_channel_id in context: %s', target_channel_id_str) log.error('Invalid project_id in context: %s', project_id_str)
await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id) await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
@@ -78,8 +78,8 @@ async def tg_create_creative_4(
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
channel = await self.database.get_target_channel(workspace_id, channel_id=target_channel_id) project = await self.database.get_project(workspace_id, project_id=project_id)
if not channel: if not project:
await self.telegram_bot.send_message('❌ Канал не найден.', chat_id) await self.telegram_bot.send_message('❌ Канал не найден.', chat_id)
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
@@ -92,7 +92,7 @@ async def tg_create_creative_4(
text=creative_text, text=creative_text,
status=domain.CreativeStatus.ACTIVE, status=domain.CreativeStatus.ACTIVE,
workspace_id=workspace_id, workspace_id=workspace_id,
target_channel_id=target_channel_id, project_id=project_id,
) )
async with self.database.transaction(): async with self.database.transaction():
@@ -104,7 +104,7 @@ async def tg_create_creative_4(
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
f'✅ Креатив успешно создан!\n\n' f'✅ Креатив успешно создан!\n\n'
f'📝 Название: {creative.name}\n' f'📝 Название: {creative.name}\n'
f'📢 Канал: {channel.title}\n\n' f'📢 Канал: {project.channel.title}\n\n'
f'💬 Текст:\n{text_preview}', f'💬 Текст:\n{text_preview}',
chat_id, chat_id,
) )

View File

@@ -37,8 +37,8 @@ async def update_creative(
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,
text=creative.text, text=creative.text,
target_channel_id=creative.target_channel_id, project_id=creative.project_id,
target_channel_title=creative.target_channel.title, project_channel_title=creative.project.channel.title,
created_at=creative.created_at, created_at=creative.created_at,
status=creative.status, status=creative.status,
placements_count=creative.placements_count, placements_count=creative.placements_count,

View File

@@ -1,53 +0,0 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def create_external_channel(
self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.ExternalChannelOutput:
await self.ensure_workspace_access(workspace_id, user_id)
existing_channel = await self.database.get_external_channel(workspace_id, telegram_id=input.telegram_id)
if existing_channel:
raise domain.ExternalChannelAlreadyExists(input.telegram_id)
for target_id in input.target_channel_ids:
target_channel = await self.database.get_target_channel(workspace_id, channel_id=target_id)
if not target_channel:
raise domain.TargetChannelNotFound(target_id)
async with self.database.transaction():
channel = domain.ExternalChannel(
telegram_id=input.telegram_id,
title=input.title,
username=input.username,
description=input.description,
subscribers_count=input.subscribers_count,
workspace_id=workspace_id,
)
await self.database.create_external_channel(channel)
if input.target_channel_ids:
await self.database.add_external_channel_to_targets(channel, input.target_channel_ids)
log.info(
'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids)
)
return dto.ExternalChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
description=channel.description,
subscribers_count=channel.subscribers_count,
)

View File

@@ -1,20 +0,0 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
channel = await self.database.get_external_channel(input.workspace_id, channel_id=input.channel_id)
if not channel:
raise domain.ExternalChannelNotFound(input.channel_id)
await self.database.delete_external_channel(input.channel_id)
log.info('User %s deleted external channel %s', input.user_id, input.channel_id)

View File

@@ -1,34 +0,0 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
target_channel = await self.database.get_target_channel(input.workspace_id, channel_id=input.target_channel_id)
if not target_channel:
log.warning('Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id)
raise domain.TargetChannelNotFound(input.target_channel_id)
channels = await self.database.get_external_channels_for_target(input.target_channel_id)
return dto.GetExternalChannelsOutput(
external_channels=[
dto.ExternalChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
description=channel.description,
subscribers_count=channel.subscribers_count,
)
for channel in channels
]
)

View File

@@ -1,47 +0,0 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_external_channel(
self: 'Usecase',
channel_id: uuid.UUID,
input: dto.UpdateExternalChannelInput,
user_id: uuid.UUID,
workspace_id: uuid.UUID,
) -> dto.ExternalChannelOutput:
await self.ensure_workspace_access(workspace_id, user_id)
external_channel = await self.database.get_external_channel(workspace_id, channel_id=channel_id)
if not external_channel:
log.warning('External channel %s not found', channel_id)
raise domain.ExternalChannelNotFound(channel_id)
if input.title is not None:
external_channel.title = input.title
if input.username is not None:
external_channel.username = input.username
if input.description is not None:
external_channel.description = input.description
if input.subscribers_count is not None:
external_channel.subscribers_count = input.subscribers_count
await self.database.update_external_channel(external_channel)
log.info('External channel %s updated', channel_id)
return dto.ExternalChannelOutput(
id=external_channel.id,
telegram_id=external_channel.telegram_id,
title=external_channel.title,
username=external_channel.username,
description=external_channel.description,
subscribers_count=external_channel.subscribers_count,
)

View File

@@ -1,54 +0,0 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_external_channel_links(
self: 'Usecase',
channel_id: uuid.UUID,
input: dto.UpdateExternalChannelLinksInput,
user_id: uuid.UUID,
workspace_id: uuid.UUID,
) -> dto.ExternalChannelOutput:
await self.ensure_workspace_access(workspace_id, user_id)
external_channel = await self.database.get_external_channel(workspace_id, channel_id=channel_id)
if not external_channel:
log.warning('External channel %s not found', channel_id)
raise domain.ExternalChannelNotFound(channel_id)
for target_id in input.add_target_channel_ids:
target_channel = await self.database.get_target_channel(workspace_id, channel_id=target_id)
if not target_channel:
log.warning('User %s attempted to link external channel to target %s they do not own', user_id, target_id)
raise domain.TargetChannelNotFound(target_id)
async with self.database.transaction():
if input.add_target_channel_ids:
await self.database.add_external_channel_to_targets(external_channel, input.add_target_channel_ids)
for target_id in input.remove_target_channel_ids:
await self.database.remove_external_channel_from_target(channel_id, target_id)
log.info(
'External channel %s links updated: %s added, %s removed',
channel_id,
len(input.add_target_channel_ids),
len(input.remove_target_channel_ids),
)
return dto.ExternalChannelOutput(
id=external_channel.id,
telegram_id=external_channel.telegram_id,
title=external_channel.title,
username=external_channel.username,
description=external_channel.description,
subscribers_count=external_channel.subscribers_count,
)

View File

@@ -15,24 +15,30 @@ async def create_placement(
) -> dto.PlacementOutput: ) -> dto.PlacementOutput:
await self.ensure_workspace_access(workspace_id, user_id) await self.ensure_workspace_access(workspace_id, user_id)
target_channel = await self.database.get_target_channel(workspace_id, channel_id=input.target_channel_id) project = await self.database.get_project(workspace_id, project_id=input.project_id)
if not target_channel: if not project:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.ProjectNotFound(input.project_id)
external_channel = await self.database.get_external_channel(workspace_id, channel_id=input.external_channel_id) placement_channel = await self.database.get_channel(workspace_id, channel_id=input.placement_channel_id)
if not external_channel: if not placement_channel:
raise domain.ExternalChannelNotFound(input.external_channel_id) raise domain.ChannelNotFound(input.placement_channel_id)
creative = await self.database.get_creative(workspace_id, input.creative_id) creative = await self.database.get_creative(workspace_id, input.creative_id)
if not creative: if not creative:
raise domain.CreativeNotFound(input.creative_id) raise domain.CreativeNotFound(input.creative_id)
if creative.project_id != project.id:
raise domain.CreativeNotFound(input.creative_id)
requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL requires_approval = input.invite_link_type == domain.InviteLinkType.APPROVAL
invite_link = await self.telegram_bot.create_chat_invite_link(target_channel.telegram_id, requires_approval)
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
placement = domain.Placement( placement = domain.Placement(
target_channel_id=input.target_channel_id, project_id=input.project_id,
external_channel_id=input.external_channel_id, placement_channel_id=input.placement_channel_id,
creative_id=input.creative_id, creative_id=input.creative_id,
workspace_id=workspace_id, workspace_id=workspace_id,
placement_date=input.placement_date, placement_date=input.placement_date,
@@ -51,10 +57,10 @@ async def create_placement(
return dto.PlacementOutput( return dto.PlacementOutput(
id=placement.id, id=placement.id,
target_channel_id=placement.target_channel_id, project_id=placement.project_id,
target_channel_title=target_channel.title, project_channel_title=project.channel.title,
external_channel_id=placement.external_channel_id, placement_channel_id=placement.placement_channel_id,
external_channel_title=external_channel.title, placement_channel_title=placement_channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=creative.name, creative_name=creative.name,
placement_date=placement.placement_date, placement_date=placement.placement_date,

View File

@@ -19,10 +19,10 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
return dto.PlacementOutput( return dto.PlacementOutput(
id=placement.id, id=placement.id,
target_channel_id=placement.target_channel_id, project_id=placement.project_id,
target_channel_title=placement.target_channel.title, project_channel_title=placement.project.channel.title,
external_channel_id=placement.external_channel_id, placement_channel_id=placement.placement_channel_id,
external_channel_title=placement.external_channel.title, placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, creative_name=placement.creative.name,
placement_date=placement.placement_date, placement_date=placement.placement_date,

View File

@@ -11,8 +11,8 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_placements(
input.workspace_id, input.workspace_id,
target_channel_id=input.target_channel_id, project_id=input.project_id,
external_channel_id=input.external_channel_id, placement_channel_id=input.placement_channel_id,
creative_id=input.creative_id, creative_id=input.creative_id,
include_archived=input.include_archived, include_archived=input.include_archived,
) )
@@ -21,10 +21,10 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
placements=[ placements=[
dto.PlacementOutput( dto.PlacementOutput(
id=placement.id, id=placement.id,
target_channel_id=placement.target_channel_id, project_id=placement.project_id,
target_channel_title=placement.target_channel.title, project_channel_title=placement.project.channel.title,
external_channel_id=placement.external_channel_id, placement_channel_id=placement.placement_channel_id,
external_channel_title=placement.external_channel.title, placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, creative_name=placement.creative.name,
placement_date=placement.placement_date, placement_date=placement.placement_date,

View File

@@ -37,10 +37,10 @@ async def update_placement(
return dto.PlacementOutput( return dto.PlacementOutput(
id=placement.id, id=placement.id,
target_channel_id=placement.target_channel_id, project_id=placement.project_id,
target_channel_title=placement.target_channel.title, project_channel_title=placement.project.channel.title,
external_channel_id=placement.external_channel_id, placement_channel_id=placement.placement_channel_id,
external_channel_title=placement.external_channel.title, placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id, creative_id=placement.creative_id,
creative_name=placement.creative.name, creative_name=placement.creative.name,
placement_date=placement.placement_date, placement_date=placement.placement_date,

View File

@@ -0,0 +1,26 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def disconnect_project_by_tg_id(self: 'Usecase', input: dto.DisconnectProjectByTgIdInput) -> None:
user = await self.database.get_user(telegram_id=input.user_telegram_id)
if not user:
log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel')
return
project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
if not project:
log.warning(f'Project channel {input.telegram_id} not found')
raise domain.ProjectNotFound()
project.status = domain.ProjectStatus.ARCHIVED
await self.database.update_project(project)
log.info('Project %s archived for channel %s', project.id, input.telegram_id)

View File

@@ -0,0 +1,27 @@
from typing import TYPE_CHECKING
from src import dto
if TYPE_CHECKING:
from .. import Usecase
async def get_workspace_projects(
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
) -> dto.GetWorkspaceProjectsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
projects = await self.database.get_workspace_projects(input.workspace_id)
return dto.GetWorkspaceProjectsOutput(
projects=[
dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
)
for project in projects
]
)

View File

@@ -11,9 +11,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def tg_add_target_chan_1( async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None:
self: 'Usecase', input: dto.ConnectTargetChanInput
) -> dto.ConnectTargetChanOutput | None:
permissions = input.bot_permissions permissions = input.bot_permissions
if not permissions.is_admin: if not permissions.is_admin:
@@ -71,17 +69,37 @@ async def tg_add_target_chan_1(
if len(workspaces) == 1: if len(workspaces) == 1:
workspace = workspaces[0] workspace = workspaces[0]
channel = domain.TargetChannel(
channel = await self.database.get_channel(workspace.id, telegram_id=input.telegram_id)
if channel:
channel.title = input.title
channel.username = input.username
await self.database.update_channel(channel)
else:
channel = domain.Channel(
telegram_id=input.telegram_id, telegram_id=input.telegram_id,
title=input.title, title=input.title,
username=input.username, username=input.username,
workspace_id=workspace.id, workspace_id=workspace.id,
status=domain.TargetChannelStatus.ACTIVE,
) )
await self.database.create_channel(channel)
project = await self.database.get_project(workspace.id, channel_id=channel.id)
if project:
project.status = domain.ProjectStatus.ACTIVE
await self.database.update_project(project)
else:
project = domain.Project(
workspace_id=workspace.id,
channel_id=channel.id,
status=domain.ProjectStatus.ACTIVE,
)
await self.database.create_project(project)
await self.ensure_active_purchase_plan(project)
await self.database.upsert_target_channel(channel)
log.info( log.info(
'Channel %s connected/updated successfully in workspace %s by user %s', 'Project for channel %s connected/updated successfully in workspace %s by user %s',
input.telegram_id, input.telegram_id,
workspace.id, workspace.id,
input.user_telegram_id, input.user_telegram_id,
@@ -91,17 +109,16 @@ async def tg_add_target_chan_1(
f'✅ Канал "{input.title}" добавлен в рабочее пространство "{workspace.name}".', input.user_telegram_id f'✅ Канал "{input.title}" добавлен в рабочее пространство "{workspace.name}".', input.user_telegram_id
) )
return dto.ConnectTargetChanOutput( return dto.ProjectOutput(
id=channel.id, id=project.id,
telegram_id=channel.telegram_id, telegram_id=channel.telegram_id,
title=channel.title, title=channel.title,
username=channel.username, username=channel.username,
status=channel.status, status=project.status,
is_active=channel.status == domain.TargetChannelStatus.ACTIVE,
) )
current_state = await self.database.get_telegram_state(user.telegram_id) current_state = await self.database.get_telegram_state(user.telegram_id)
if current_state and current_state.state == domain.TelegramStateEnum.TARGET_CHANNEL_WAITING_WORKSPACE: if current_state and current_state.state == domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE:
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
'⚠️ Сначала завершите выбор рабочего пространства для предыдущего канала.', user.telegram_id '⚠️ Сначала завершите выбор рабочего пространства для предыдущего канала.', user.telegram_id
) )
@@ -122,20 +139,17 @@ async def tg_add_target_chan_1(
await self.database.set_telegram_state( await self.database.set_telegram_state(
user.telegram_id, user.telegram_id,
domain.TelegramStateEnum.TARGET_CHANNEL_WAITING_WORKSPACE, domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE,
context, context,
) )
buttons = [ buttons = [
[InlineKeyboardButton(text=workspace.name, callback_data=f'tg_add_target_chan_2:{workspace.id}')] [InlineKeyboardButton(text=workspace.name, callback_data=f'tg_add_project_2:{workspace.id}')]
for workspace in workspaces for workspace in workspaces
] ]
await self.telegram_bot.send_message_with_inline_keyboard( await self.telegram_bot.send_message_with_inline_keyboard(
( (f'✨ Подключение канала\n\nВыберите рабочее пространство, в которое добавить канал "{input.title}":'),
'✨ Подключение канала\n\n'
f'Выберите рабочее пространство, в которое добавить канал "{input.title}":'
),
user.telegram_id, user.telegram_id,
buttons, buttons,
) )

View File

@@ -10,12 +10,12 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def tg_add_target_chan_2( async def tg_add_project_2(
self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int
) -> None: ) -> None:
state = await self.database.get_telegram_state(telegram_id) state = await self.database.get_telegram_state(telegram_id)
if not state or state.state != domain.TelegramStateEnum.TARGET_CHANNEL_WAITING_WORKSPACE: if not state or state.state != domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE:
log.debug('User %s has no active target channel workspace selection flow', telegram_id) log.debug('User %s has no active project workspace selection flow', telegram_id)
return return
context = state.context or {} context = state.context or {}
@@ -28,8 +28,8 @@ async def tg_add_target_chan_2(
except ValueError: except ValueError:
previous_state = None previous_state = None
if not callback_data.startswith('tg_add_target_chan_2:'): if not callback_data.startswith('tg_add_project_2:'):
log.warning('Invalid target channel workspace callback data: %s', callback_data) log.warning('Invalid project workspace callback data: %s', callback_data)
return return
workspace_id_str = callback_data.split(':', 1)[1] workspace_id_str = callback_data.split(':', 1)[1]
@@ -44,9 +44,9 @@ async def tg_add_target_chan_2(
await self.database.clear_telegram_state(telegram_id) await self.database.clear_telegram_state(telegram_id)
return return
channel_data = state.context.get('channel_data') if state.context else None channel_data = context.get('channel_data')
if not channel_data: if not channel_data:
log.error('Missing channel data for user %s in target channel flow', telegram_id) log.error('Missing channel data for user %s in project flow', telegram_id)
await self.telegram_bot.send_message('Не удалось подключить канал. Попробуйте снова.', chat_id) await self.telegram_bot.send_message('Не удалось подключить канал. Попробуйте снова.', chat_id)
if previous_state: if previous_state:
await self.database.set_telegram_state(telegram_id, previous_state, previous_context) await self.database.set_telegram_state(telegram_id, previous_state, previous_context)
@@ -65,15 +65,31 @@ async def tg_add_target_chan_2(
await self.telegram_bot.send_message('У вас нет доступа к выбранному рабочему пространству.', chat_id) await self.telegram_bot.send_message('У вас нет доступа к выбранному рабочему пространству.', chat_id)
return return
channel = domain.TargetChannel( telegram_channel_id = int(channel_data['telegram_id'])
telegram_id=int(channel_data['telegram_id']), channel = await self.database.get_channel(workspace.id, telegram_id=telegram_channel_id)
if channel:
channel.title = str(channel_data['title'])
channel.username = channel_data.get('username')
await self.database.update_channel(channel)
else:
channel = domain.Channel(
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, workspace_id=workspace.id,
status=domain.TargetChannelStatus.ACTIVE,
) )
await self.database.create_channel(channel)
project = await self.database.get_project(workspace.id, channel_id=channel.id)
if project:
project.status = domain.ProjectStatus.ACTIVE
await self.database.update_project(project)
else:
project = domain.Project(workspace_id=workspace.id, channel_id=channel.id, status=domain.ProjectStatus.ACTIVE)
await self.database.create_project(project)
await self.ensure_active_purchase_plan(project)
await self.database.upsert_target_channel(channel)
log.info( log.info(
'Channel %s connected/updated successfully in workspace %s by user %s', 'Channel %s connected/updated successfully in workspace %s by user %s',
channel.telegram_id, channel.telegram_id,

View File

@@ -9,7 +9,7 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTargetChanPermissionsInput) -> None: async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPermissionsInput) -> None:
missing_permissions = [] missing_permissions = []
if not input.permissions.can_invite_users: if not input.permissions.can_invite_users:
missing_permissions.append('Создание инвайт-ссылок') missing_permissions.append('Создание инвайт-ссылок')
@@ -22,31 +22,37 @@ async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTarge
log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions') log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
return return
channel = await self.database.get_target_channel_for_user_by_telegram(user.id, input.telegram_id) project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)
if not channel: if not project:
log.warning(f'Target channel {input.telegram_id} not found when permissions changed') log.warning(f'Project channel {input.telegram_id} not found when permissions changed')
raise domain.TargetChannelNotFound() raise domain.ProjectNotFound()
if not missing_permissions: if not missing_permissions:
if channel.status != domain.TargetChannelStatus.ACTIVE: if project.status != domain.ProjectStatus.ACTIVE:
channel.status = domain.TargetChannelStatus.ACTIVE project.status = domain.ProjectStatus.ACTIVE
await self.database.update_target_channel(channel) await self.database.update_project(project)
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!',
input.user_telegram_id, input.user_telegram_id,
) )
log.info(f'Target channel {input.telegram_id} reactivated - all permissions granted') log.info(f'Project channel {input.telegram_id} reactivated - all permissions granted')
return return
if channel.status == domain.TargetChannelStatus.ACTIVE: if project.status == domain.ProjectStatus.ACTIVE:
channel.status = domain.TargetChannelStatus.INACTIVE project.status = domain.ProjectStatus.INACTIVE
await self.database.update_target_channel(channel) await self.database.update_project(project)
missed_permissions = '\n'.join(f'{p}' for p in missing_permissions) missed_permissions = '\n'.join(f'{p}' for p in missing_permissions)
await self.telegram_bot.send_message( await self.telegram_bot.send_message(
f'⚠️ Канал "{input.chat_title}" был деактивирован.\ (
\n\оту убрали необходимые права:\n{missed_permissions}', f'⚠️ Канал "{input.chat_title}" был деактивирован.\n\n'
f'Боту убрали необходимые права:\n{missed_permissions}'
),
input.user_telegram_id, input.user_telegram_id,
) )
log.warning(f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}') log.warning(
'Project channel %s deactivated due to missing permissions: %s',
input.telegram_id,
missing_permissions,
)

View File

@@ -0,0 +1,67 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def attach_channel_to_purchase_plan(
self: 'Usecase',
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.AttachChannelToPurchasePlanInput,
) -> dto.PurchasePlanChannelOutput:
await self.ensure_workspace_access(workspace_id, user_id)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
plan = await self.ensure_active_purchase_plan(project)
# Поиск канала по username
channel = await self.database.get_channel(workspace_id, 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,
)
await self.database.create_channel(channel)
log.info('Created unverified channel with username %s', input.username)
else:
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,
channel.id,
status=input.status,
planned_cost=input.planned_cost,
comment=input.comment,
)
log.info('Channel %s attached to purchase plan %s (project %s)', channel.id, plan.id, project.id)
return dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
verification_status=channel.verification_status,
),
)

View File

@@ -0,0 +1,43 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_purchase_plan_channels(
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
) -> dto.GetPurchasePlanChannelsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
plan = await self.ensure_active_purchase_plan(project)
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
log.debug('Fetched %s channels for purchase plan %s (project %s)', len(plan_channels), plan.id, project.id)
return dto.GetPurchasePlanChannelsOutput(
channels=[
dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=plan_channel.channel.id,
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
]
)

View File

@@ -0,0 +1,33 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def remove_purchase_plan_channel(
self: 'Usecase',
channel_id: uuid.UUID,
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
await self.ensure_workspace_access(workspace_id, user_id)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
plan = await self.ensure_active_purchase_plan(project)
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
if not plan_channel:
raise domain.PurchasePlanChannelNotFound(channel_id)
await self.database.remove_channel_from_purchase_plan(plan.id, plan_channel.channel_id)
log.info('Removed channel %s from purchase plan %s (project %s)', plan_channel.channel_id, plan.id, project.id)

View File

@@ -0,0 +1,71 @@
import logging
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def update_purchase_plan_channel(
self: 'Usecase',
channel_id: uuid.UUID,
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.UpdatePurchasePlanChannelInput,
) -> dto.PurchasePlanChannelOutput:
await self.ensure_workspace_access(workspace_id, user_id)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
plan = await self.ensure_active_purchase_plan(project)
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
if not plan_channel:
raise domain.PurchasePlanChannelNotFound(channel_id)
updated_fields: dict[str, object | None] = {}
if input.status is not None:
plan_channel.status = input.status
updated_fields['status'] = input.status
if input.planned_cost is not None:
plan_channel.planned_cost = input.planned_cost
updated_fields['planned_cost'] = input.planned_cost
if input.comment is not None:
plan_channel.comment = input.comment
updated_fields['comment'] = input.comment
if updated_fields:
await self.database.update_purchase_plan_channel(plan_channel)
log.info(
'Updated purchase plan channel %s for project %s fields %s',
channel_id,
project.id,
', '.join(updated_fields.keys()),
)
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)
if channel is None:
raise domain.ChannelNotFound(plan_channel.channel_id)
return dto.PurchasePlanChannelOutput(
id=plan_channel.id,
status=plan_channel.status,
planned_cost=plan_channel.planned_cost,
comment=plan_channel.comment,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
verification_status=channel.verification_status,
),
)

View File

@@ -22,16 +22,23 @@ async def handle_subscription(
log.warning('Placement not found for invite_link: %s', invite_link) log.warning('Placement not found for invite_link: %s', invite_link)
return return
subscriber = domain.Subscriber( subscriber = await self.database.get_user(telegram_id=user_telegram_id)
if not subscriber:
subscriber = domain.User(
telegram_id=user_telegram_id, telegram_id=user_telegram_id,
username=username, username=username,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
) )
await self.database.upsert_subscriber(subscriber) await self.database.create_user(subscriber)
else:
subscriber.username = username or subscriber.username
subscriber.first_name = first_name or subscriber.first_name
subscriber.last_name = last_name or subscriber.last_name
await self.database.update_user(subscriber)
active_subscription = await self.database.get_active_subscription_by_subscriber_and_channel( active_subscription = await self.database.get_active_subscription_by_subscriber_and_project(
subscriber.id, placement.target_channel.telegram_id subscriber.id, placement.project_id
) )
if active_subscription: if active_subscription:
@@ -43,7 +50,7 @@ async def handle_subscription(
'ignoring new subscription attempt via placement %s', 'ignoring new subscription attempt via placement %s',
subscriber.id, subscriber.id,
user_telegram_id, user_telegram_id,
placement.target_channel_id, placement.project_id,
active_subscription.placement_id, active_subscription.placement_id,
placement.id, placement.id,
) )
@@ -72,7 +79,6 @@ async def handle_subscription(
subscription = domain.Subscription( subscription = domain.Subscription(
placement_id=placement.id, placement_id=placement.id,
subscriber_id=subscriber.id, subscriber_id=subscriber.id,
target_channel_id=placement.target_channel_id,
invite_link=invite_link, invite_link=invite_link,
) )
async with self.database.transaction(): async with self.database.transaction():

View File

@@ -12,14 +12,17 @@ log = logging.getLogger(__name__)
async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None: async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None:
subscriber = await self.database.get_subscriber(user_telegram_id) subscriber = await self.database.get_user(telegram_id=user_telegram_id)
if not subscriber: if not subscriber:
log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) log.warning('Subscriber not found for telegram_id: %s', user_telegram_id)
return return
subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_channel( project = await self.database.get_project_by_channel_telegram(channel_telegram_id)
subscriber.id, channel_telegram_id if not project:
) log.warning('Project not found for channel telegram_id: %s', channel_telegram_id)
return
subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_project(subscriber.id, project.id)
if not subscriptions: if not subscriptions:
log.info( log.info(

View File

@@ -1,26 +0,0 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.DisconnectTargetChanByTgIdInput) -> None:
user = await self.database.get_user(telegram_id=input.user_telegram_id)
if not user:
log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel')
return
channel = await self.database.get_target_channel_for_user_by_telegram(user.id, input.telegram_id)
if not channel:
log.warning(f'Target channel {input.telegram_id} not found')
raise domain.TargetChannelNotFound()
channel.status = domain.TargetChannelStatus.INACTIVE
await self.database.update_target_channel(channel)
log.info(f'Target channel {input.telegram_id} deactivated')

View File

@@ -1,26 +0,0 @@
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
channels = await self.database.get_workspace_target_channels(input.workspace_id)
return dto.GetUserTargetChansOutput(
target_channels=[
dto.ConnectTargetChanOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
status=channel.status,
is_active=channel.status == domain.TargetChannelStatus.ACTIVE,
)
for channel in channels
]
)