change domen

This commit is contained in:
Artem Tsyrulnikov
2026-01-02 15:51:54 +03:00
parent c05ac84b38
commit 0577d2a00e
63 changed files with 1941 additions and 2186 deletions

View File

@@ -4,6 +4,9 @@ test:
env: env:
cp .env.example .env cp .env.example .env
run:
uv run uvicorn src.main:app
up: up:
GIT_COMMIT=$$(git rev-parse --short HEAD) docker compose up --build -d --force-recreate GIT_COMMIT=$$(git rev-parse --short HEAD) docker compose up --build -d --force-recreate
docker compose logs -f docker compose logs -f

View File

@@ -1,324 +0,0 @@
# ruff: noqa
# mypy: ignore-errors
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "channel" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"telegram_id" BIGINT NOT NULL UNIQUE,
"title" VARCHAR(255) NOT NULL,
"username" VARCHAR(255) NOT NULL UNIQUE,
"access_hash" BIGINT,
"pts" INT
);
CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id");
CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username");
CREATE TABLE IF NOT EXISTS "post" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"message_id" INT NOT NULL,
"text" TEXT NOT NULL,
"deleted_from_channel_at" TIMESTAMPTZ,
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id")
);
CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id");
CREATE TABLE IF NOT EXISTS "post_views_history" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"views_count" INT NOT NULL,
"fetched_at" TIMESTAMPTZ NOT NULL,
"post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at")
);
CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at");
CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id");
CREATE TABLE IF NOT EXISTS "telegram_state" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"telegram_id" BIGINT NOT NULL UNIQUE,
"state" VARCHAR(26) NOT NULL,
"context" JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id");
COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_WORKSPACE: creative_waiting_workspace\nCREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text\nPROJECT_WAITING_WORKSPACE: project_waiting_workspace';
CREATE TABLE IF NOT EXISTS "user" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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 INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id");
CREATE TABLE IF NOT EXISTS "workspace" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"name" VARCHAR(255) NOT NULL
);
CREATE TABLE IF NOT EXISTS "project" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"name" VARCHAR(255) NOT NULL,
"text" TEXT NOT NULL,
"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_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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"placement_date" TIMESTAMPTZ NOT NULL,
"cost" DOUBLE PRECISION,
"comment" TEXT,
"invite_link_type" VARCHAR(8) NOT NULL,
"invite_link" VARCHAR(512) NOT NULL,
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
"creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
"placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL,
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id");
CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id");
CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id");
CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id");
CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id");
COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';
COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "purchase_plan" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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,
CONSTRAINT "uid_subscriptio_placeme_8fcb8f" UNIQUE ("placement_id", "subscriber_id")
);
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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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\nBLOCKED: blocked';
CREATE TABLE IF NOT EXISTS "workspace_user_permission" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"permission" VARCHAR(24) NOT NULL,
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
);
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"permission" VARCHAR(24) NOT NULL,
"channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE,
"creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE,
"placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE,
"project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE,
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_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 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
CREATE TABLE IF NOT EXISTS "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

@@ -0,0 +1,335 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "channel"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"telegram_id" BIGINT NOT NULL UNIQUE,
"title" VARCHAR(255) NOT NULL,
"username" VARCHAR(255) NOT NULL UNIQUE,
"access_hash" BIGINT,
"pts" INT
);
CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id");
CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username");
CREATE TABLE IF NOT EXISTS "post"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"message_id" INT NOT NULL,
"text" TEXT NOT NULL,
"deleted_from_channel_at" TIMESTAMPTZ,
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id")
);
CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id");
CREATE TABLE IF NOT EXISTS "post_views_history"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"views_count" INT NOT NULL,
"fetched_at" TIMESTAMPTZ NOT NULL,
"post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at")
);
CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at");
CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id");
CREATE TABLE IF NOT EXISTS "user"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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 INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id");
CREATE TABLE IF NOT EXISTS "workspace"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"name" VARCHAR(255) NOT NULL
);
CREATE TABLE IF NOT EXISTS "project"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
"purchase_invite_type_default" VARCHAR(10) NOT NULL DEFAULT 'approval',
"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';
COMMENT ON COLUMN "project"."purchase_invite_type_default" IS 'PUBLIC: public\nAPPROVAL: approval';
CREATE TABLE IF NOT EXISTS "creative"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"name" VARCHAR(255) NOT NULL,
"text" TEXT NOT NULL,
"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_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 "purchase"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
"creative_id" UUID NOT NULL REFERENCES "creative" ("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_purchase_creativ_6829cd" ON "purchase" ("creative_id");
CREATE INDEX IF NOT EXISTS "idx_purchase_project_95b1c2" ON "purchase" ("project_id");
CREATE INDEX IF NOT EXISTS "idx_purchase_workspa_8784a5" ON "purchase" ("workspace_id");
CREATE INDEX IF NOT EXISTS "idx_purchase_project_dee224" ON "purchase" ("project_id", "status");
COMMENT ON COLUMN "purchase"."status" IS 'ACTIVE: active\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "purchase_channel"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"status" VARCHAR(11) NOT NULL DEFAULT 'planned',
"planned_cost" DOUBLE PRECISION,
"comment" TEXT,
"invite_link" VARCHAR(512) NOT NULL,
"invite_link_type" VARCHAR(8) NOT NULL,
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_purchase_ch_purchas_f7c86e" UNIQUE ("purchase_id", "channel_id")
);
CREATE INDEX IF NOT EXISTS "idx_purchase_ch_channel_2b1264" ON "purchase_channel" ("channel_id");
CREATE INDEX IF NOT EXISTS "idx_purchase_ch_purchas_1fe84a" ON "purchase_channel" ("purchase_id");
COMMENT ON COLUMN "purchase_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed';
COMMENT ON COLUMN "purchase_channel"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';
CREATE TABLE IF NOT EXISTS "placement"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"wanted_placement_date" TIMESTAMPTZ NOT NULL,
"cost" DOUBLE PRECISION,
"comment" TEXT,
"invite_link" VARCHAR(512) NOT NULL,
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
"creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
"placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL,
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
"purchase_id" UUID REFERENCES "purchase" ("id") ON DELETE CASCADE,
"purchase_channel_id" UUID REFERENCES "purchase_channel" ("id") ON DELETE CASCADE,
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id");
CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id");
CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id");
CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id");
CREATE INDEX IF NOT EXISTS "idx_placement_purchas_3e3775" ON "placement" ("purchase_id");
CREATE INDEX IF NOT EXISTS "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id");
CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id");
COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived';
CREATE TABLE IF NOT EXISTS "subscription"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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,
CONSTRAINT "uid_subscriptio_placeme_8fcb8f" UNIQUE ("placement_id", "subscriber_id")
);
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"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"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\nBLOCKED: blocked';
CREATE TABLE IF NOT EXISTS "workspace_user_permission"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"permission" VARCHAR(24) NOT NULL,
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
);
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope"
(
"id" UUID NOT NULL PRIMARY KEY,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ,
"permission" VARCHAR(24) NOT NULL,
"channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE,
"creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE,
"placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE,
"project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE,
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id");
CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_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 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
CREATE TABLE IF NOT EXISTS "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,103 +0,0 @@
# ruff: noqa
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
-- Add CHECK constraint: exactly one FK must be set
ALTER TABLE "workspace_user_permission_scope"
ADD CONSTRAINT "check_exactly_one_scope"
CHECK (
(("project_id" IS NOT NULL)::int +
("creative_id" IS NOT NULL)::int +
("placement_id" IS NOT NULL)::int +
("channel_id" IS NOT NULL)::int) = 1
);
COMMENT ON TABLE "workspace_user_permission_scope"
IS 'Permission scoped to specific entity (project/creative/placement/channel)';
"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "workspace_user_permission_scope"
DROP CONSTRAINT IF EXISTS "check_exactly_one_scope";
"""
MODELS_STATE = (
'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'
)

View File

@@ -0,0 +1,99 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
-- Add CHECK constraint: exactly one FK must be set
ALTER TABLE "workspace_user_permission_scope"
ADD CONSTRAINT "check_exactly_one_scope"
CHECK (
(("project_id" IS NOT NULL)::int +
("creative_id" IS NOT NULL)::int +
("placement_id" IS NOT NULL)::int +
("channel_id" IS NOT NULL)::int) = 1
);
COMMENT ON TABLE "workspace_user_permission_scope"
IS 'Permission scoped to specific entity (project/creative/placement/channel)'; \
"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "workspace_user_permission_scope"
DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; \
"""
MODELS_STATE = (
'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'
)

View File

@@ -1,92 +0,0 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "placement" DROP COLUMN "invite_link_type";
ALTER TABLE "project" ADD "invite_link_type" VARCHAR(10) NOT NULL DEFAULT 'approval';
COMMENT ON COLUMN "project"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "project" DROP COLUMN "invite_link_type";
ALTER TABLE "placement" ADD "invite_link_type" VARCHAR(8) NOT NULL;
COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';"""
MODELS_STATE = (
"eJztXWtv2zgW/SuGP80A2SJxnCYTLBawHTXV1LENP9LZaQpBkRlHG1nySHLSoOh/X1JvSp"
"Ssl23Jvl+KlC9L55LUPYeX5M/mUpsjxfjQexZVFSnN68bPpiouEf4jnHXSaIqrlZ9BEkzx"
"UbHKSoFCj4api5KJk59ExUA4aY4MSZdXpqypOFVdKwpJ1CRcUFYXftJalf9ZI8HUFsh8Rj"
"rO+PYdJ8vqHP1Ahvvf1YvwJCNlTj2sPCe/baUL5vvKSpvN+JtPVknyc4+CpCnrpeqXXr2b"
"z5rqFV+v5fkHUofkLZCKdNFE88BrkKd0XthNsp8YJ5j6GnmPOvcT5uhJXCsEjOa/n9aqRD"
"BoWL9E/mn/p5kBHklTCbSyahIsfv6y38p/Zyu1SX6q97kz/u384+/WW2qGudCtTAuR5i+r"
"omiKdlULVx9ISUfktQXRjAJ6g3NMeYnYoNI1Q+DOnaof3D/ygOwm+Cj7PcyF2YUvH6ZN/A"
"7zoaq8OxZMwHjK33GTaeduRN5kaRj/KBZEnSlHclpW6nso9TfbJBoeH/a48RppfOWnnxvk"
"v42/hwMubDiv3PTvJnkmcW1qgqq9CeI80NncVBcYXNI37Ho1z2lYuiYYdq+GdR7etyuei1"
"E+u9I1S7Cr87Q7NGtNzOi+duIANbE5Frq4FFjfsq684FWTbcdQxZAhMVTb+aAVHJAL8iv/"
"+qPVOj+/bJ2ef7y6aF9eXlydXuGy1iNFsy4TzN3lb/nBlLYeSfhFYyybCoqii30cPQZbt0"
"IIVfwqFZ3oluIPQUHqwnzG/21dXCRgdt8ZW54CLhXq9wMnq2Xn0SCuDaRbf2fAMVinHCi3"
"3kG3D6QoScgwhGfReM424kMVc4343U/W+xnyK9OIghuLrFO6Xoi2ztqX7avzj20PSC8lCT"
"8bK0Kpnl4CXIAkPIrSy5uozwUqJwCqIkpoiVQWtl2n7qcvY6SI1itFEXXI5chtp5qT6S+3"
"e7ipLAeM+AFFYcBN1BkBXfsfkgqDYLdSZxzWuoTnZCTg4aEKjiZSFBWnTTxS1IAUU1eEkL"
"6UDQM3LRiSRiajQuB81fQXY4WnkBl2L0Ze2xPSdCUn7FiQyKSrtbS4aTiatWwtwymiKi6s"
"pya/TX7JVfCIMCO/oiZL3XPzThLlvWAp0PdA3wMZCPQ9MCzoe6Dvpdf3skom5colB6E8me"
"gHYxRMcWqcMvqD1fcrC2FSd+f+mlI93QXqt7vOX79Tvb0/HNy6xQPA9vrDbghPwxTNNcMH"
"J52SU9dLC1UeP6KoSiiCrl97d/g2sefpOKI0xs1Ob8rfc9cNu8CDStDBCTc4BTMonGR3j4"
"zd+CpFJ76K7cJX4Q7sMGWmxB/vztK1ynRro5CXrKIWcWJ92N5cnpcRuHC9I4EuIukxkIzC"
"+EnTkbxQv6D3yLDfwL6rDF+EauNkXXzzKGakj+D3tB0nC+jOpNe54ZqsQVwCgqVKXnvCj5"
"6c2OiBpAwyWSkgbVMm62sLWZ1qL0htMoSyQO5JklSmkHKC6RUEtQzUMhBVQC0Dw4JaBmpZ"
"hmg49wuaOlLLrQDhRQ6E6MdKxhbJMRbomvWc4w5pMKyNfJ8qA+azCplQz6heBaqAcEXAKE"
"FxmTnNVBW0jXJLoFNk1Vq2SZ591YXBnSlJJp46r6hiQJyBONfV9zgYfgXE+UANC8T5UB1N"
"7ysqEMizGjNau54D9ZAsKjm7A0Kur6KJMWEvboWQ6Z5IjbqNv5vhrNvnGqMx1+Mn/HBAm8"
"fKJEk4QbYd4THX6YeEEElbum5l2sihQJVcelKlMNxK7JCsvmLEBUVWX7LIdKFqdYnMouW6"
"i7NWCrkOl4qV66w8CMbaazCWu6shoywTqnYk0gzbxXD2OGWNZ4upf4xQ4uys6PlVCgC2+Q"
"NVTbwggDIPbBBAWZYODcF/WYP/Er8cJeBY6q7YfeEY80XcjGhwa2ZRIANN1RbJkHe2GUCI"
"iA5DmCMimq1QZJ4R0x2DsC/XZfMo9j0zCrMJN20MZv1+ukBoY/3oPWXBwN9JoKlq8moIh6"
"5qOLQ1GFmLuc4gTVjHdUuUuoT7rUl/GpfIMPCTk/99h+XdLTjBJ7C8e+irgLC8e6CGheXd"
"Q10MDHz2IoaMPdWOrlTOAaE7GI0lnG4HpwdsbwXQnRmedG3p6Rb5JxhGMzDb7Dv0INf6zl"
"Gu6iRoxqB1UgpdCoWzqhvGK8T9g8P0VUZvhvAsG6amy0XlEULw70mDn6323iv9sdu9JkJB"
"E6OPhOFL1kqEoPnet6GcBFeZkSk92+48yCYgmwC7BtkEDAuyyaETGfsTK2lrVhhwrG4Sqn"
"WswknAZ8g4Cuiau5nddscIazcK9hZtWCmzFQj+2mmcQ3UpfFygw743ITsRdSxW5gfbJZCx"
"QKGSGVg4piYgggANAxoG3jrQMDAs0LBDd0APeY8bP3BTZLXS+94CWzBtBHOag9XOLg2zwv"
"7aq6gwTDOadft877qxWj8qsoTNMBqNh/edPjZDsFJGM5ydprDD2WmsIUhWaAciLK+m9+dg"
"NxMcB18Yvi1sfoDV/W2s7rv7eAouZpe7p2k/ux/gYHz2DZsl3qxZZ0hgc8weAkGCfYclN4"
"b6VoLmGOzR5SuPIW3xW1DjdOjUdxAcQXAEXQoEx+M1LAiOIDjWT3CspLgIB/SApAWSVqUk"
"GbjhsGC0S0k3HDrSWImyRamC4cERcxedDfw8AGJKmh48vKr0rRrUD0GwEHB3oHjA3cGwwN"
"2LzoFVMuOhcnfy0VYdHh4KSel3BgPC1Z0iblCKRd+toBSSNub+5HpTkqYj4nOSNH4g4IK3"
"Y24yIaFFAi5LUDYe1N7wbtTnrOKStlxZnTePBHB2liaw5Sw+sOUsogLYLylkvrogXBGuMI"
"ArDKzX3MYBJhB+lUurYlGU1EIfo+6RQJi0ryi8CFxUdSk9tGFf0guju0BE1i4jsrYp1lDH"
"0zJUmvDxtfHyjBEuWbYq453PbaPo/N6jfRcniDIgygB3B1EGDAuizKGLMtW+9q1kFwtufS"
"sUoDIbTGbdSW/Md4lEtVY9rymfSpUG/QTwo9gHnyjPFydaHaanfZ9wEnLUU+sSoXpHoklQ"
"MxHFaTJgF6l4JOAlCTrBvTZFxZxy9+3s/YartCqO361KAJHsZqk1fpFBViUhZ4ofZKGLyw"
"l2R1CToeTQBU6SpBzTKSoYXtlt7ogBrQa0GqD0oNUcr2FBqzlUMuR9Slnfsq68iD33NFSx"
"nHNPN3/QCg5I+9TTP1qt8/PL1un5x6uL9uXlxdWpd/xpNCvpHNQuf0uOQqWsFz0b1fNS8s"
"o3Oz42h4lxszfmOkS7Eb52+Ck/uBW+DsdfJqNOj7tueDeqvomyiQ0gePsJHtRIPewnDAZc"
"n1HLWYZk1Bl07lg/QxBmlCahHYzS5LKeB3U0HpIAKtZruFH8kbfIo0W1PqbQolrhse9rUS"
"QrHOCjsu8n+nMyHMQF+KgxVxTNVGzXb3NZMk8aimyY37emBfou6ONaVjCwxgfys1vyQgkW"
"1OQbiQIKB/yEZlXSQJdF3ffDmyxWyqBLLluNZ0lrtwRwI+BG4EIDNwLDAjcCbgTcqErciD"
"gp1t9MehQz/QXq1CRsPUQNLi7ScIOLi3hyQPJCN3DIumEKWbGkawGaLpqKmANMqtJxY5mB"
"PQVA1xYyRkx7QUUP7uuTlqakoWp6P7Eb3xmrm1bjBfEIh0nXFRFrcdiKEyvrDEPeaq3GmA"
"SO7gBg2MAQh6HMMy9rBso25Sn/+BuGRkWdjRMvVFEyK6hVoFZFwQVRA9QqMCyoVaBWxQ7Q"
"rGS1EE/dxxCsEFGFmwfg5gEGFnb4QFEkyjwOcT84wA0MYUiAmgM13xs1d7pLEkH3e1QKmu"
"4ITeWz9egduKR7wPEJQOSB7wGRB8MCkT8GIl/D7f4rpM6JeSIGbI64wQ0/uL1uOEUe1E6v"
"x42sAylFSUIr0z7T8n74xT7S8lV7ybf3fys34M6Fx/eMW50jFY9kq3M4qicjbIEqRwgY3O"
"1R1rZ6uNvjpPDdHv4UVgKItd9WH5nQNwPobr04dugCk3qVziKgFaMkXWTzLhtaygJNBDQR"
"0EQqxblAEzkaw4ImAppIZTSR1Ecg8oN73tJDHEfzQe32hz1LDnlUNCmnHJK0NceVQy5j5Z"
"DLyDmIwOiB0QOjrwatys7ogZAWJqSB+BakL2XDKL4rh6KZI6/VanqJ6UJ/vJcQDElblRbx"
"QgM0IU3XDKWd6RmBfrRJ2aC7XFqNQ1jR1bYmdwQdCP8nQfYA2QPYMcgeYFiQPQ5d9qC/tH"
"mkD7qFfZ8h2Lm54wfCp1m/f90Q50tZFZ5wOe9Mvokw5jo33jl8hkBGaCD365ifcoHsN102"
"A+f/udW93RlOfT/facAv4LQw6nd63B038J/A29bgPoNfwn0Kv4jTSmfQ6f93yvfcRrC7pr"
"ybsuS24eeTnjOcTYVen+99mQRLvsnYQmtTkBRZeiH3t9qHJXqvZp+Q6L2Zm+u+mJttPVEe"
"2ajVTrOtph2/q6YdVo6Y/lx2NeT41KQ0kohQErHfQgz93rWROiw+hzltesbmceActM3m5u"
"WTN2BmW5gGgJkdvAMPzOxADQvMDJgZMDNgZtVkZvSt32ndMbpWAbds8wRUGSpGoeZeK5AR"
"Nrra8eFWgWsX64qccy1FRtyoWseHGuhOoDtVU3dijO0SoEx/bsy+BvTmC0CpKWszeO43tQ"
"T0MhzFVFn4Qi5Gis63pytoK4tg9htoHW+4jB7ot1RX+GhqUCXRvYN0WXpuMuR1J+ckSUgX"
"/TKV0ctjL59Ie+eEY8BiOnlBnc++cqJ11r5sX51/bHs3TXgpSVHs7mUS8fL4K9LjFRo2eo"
"EqR34wYnCWI0MjA4hO8XoCeHZ6mgJAXCoWQCuPcXUf6zu74eo+da9X922NgRzGJX2//g/p"
"CzE0"
)

View File

@@ -257,37 +257,60 @@ class Postgres(DatabaseBase, Database):
return await query.order_by('created_at').all() return await query.order_by('created_at').all()
async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None: async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE) return await domain.Purchase.get_or_none(
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
)
async def get_purchase_plan(self, workspace_id: uuid.UUID, plan_id: uuid.UUID) -> domain.PurchasePlan | None: async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id) return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id)
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]:
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 ( return (
await domain.PurchasePlanChannel.filter(id=plan_channel_id, purchase_plan_id=plan_id) await domain.Purchase.filter(workspace_id=workspace_id, project_id=project_id)
.prefetch_related('channels', 'channels__channel')
.all()
)
async def create_purchase(self, purchase: domain.Purchase) -> None:
await purchase.save()
async def get_purchase_channels(self, purchase_id: uuid.UUID) -> list[domain.PurchaseChannel]:
return await domain.PurchaseChannel.filter(purchase_id=purchase_id).prefetch_related('channel').all()
async def get_purchase_channel(
self, purchase_channel_id: uuid.UUID, purchase_id: uuid.UUID
) -> domain.PurchaseChannel | None:
return (
await domain.PurchaseChannel.filter(id=purchase_channel_id, purchase_id=purchase_id)
.prefetch_related('channel') .prefetch_related('channel')
.first() .first()
) )
async def add_channel_to_purchase_plan( async def get_purchase_channels_by_project(self, project_id: uuid.UUID) -> list[domain.PurchaseChannel]:
return (
await domain.PurchaseChannel.filter(
purchase__project_id=project_id, purchase__status=domain.PurchaseStatus.ACTIVE
)
.prefetch_related('channel')
.all()
)
async def add_channel_to_purchase(
self, self,
plan_id: uuid.UUID, purchase_id: uuid.UUID,
channel_id: uuid.UUID, channel_id: uuid.UUID,
invite_link: str,
invite_link_type: domain.purchase.InviteLinkType,
*, *,
status: domain.PurchasePlanChannelStatus | None = None, status: domain.PurchaseChannelStatus | None = None,
planned_cost: float | None = None, planned_cost: float | None = None,
comment: str | None = None, comment: str | None = None,
) -> domain.PurchasePlanChannel: ) -> domain.PurchaseChannel:
defaults: dict[str, object | None] = {} defaults: dict[str, object | None] = {
'invite_link': invite_link,
'invite_link_type': invite_link_type,
}
if status is not None: if status is not None:
defaults['status'] = status defaults['status'] = status
if planned_cost is not None: if planned_cost is not None:
@@ -295,24 +318,24 @@ class Postgres(DatabaseBase, Database):
if comment is not None: if comment is not None:
defaults['comment'] = comment defaults['comment'] = comment
plan_channel, created = await domain.PurchasePlanChannel.get_or_create( purchase_channel, created = await domain.PurchaseChannel.get_or_create(
purchase_plan_id=plan_id, purchase_id=purchase_id,
channel_id=channel_id, channel_id=channel_id,
defaults=defaults, defaults=defaults,
) )
if not created and defaults: if not created and defaults:
for field, value in defaults.items(): for field, value in defaults.items():
setattr(plan_channel, field, value) setattr(purchase_channel, field, value)
await plan_channel.save() await purchase_channel.save()
return plan_channel return purchase_channel
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None:
await plan_channel.save() await purchase_channel.save()
async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None: async def remove_channel_from_purchase(self, purchase_id: uuid.UUID, channel_id: uuid.UUID) -> None:
await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id, channel_id=channel_id).delete() await domain.PurchaseChannel.filter(purchase_id=purchase_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(
@@ -485,27 +508,6 @@ class Postgres(DatabaseBase, Database):
return results return results
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
return await domain.TelegramState.get_or_none(telegram_id=telegram_id)
async def set_telegram_state(
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
) -> domain.TelegramState:
existing = await self.get_telegram_state(telegram_id)
if existing:
existing.state = state
existing.context = context
existing.updated_at = timezone.now()
await existing.save()
return existing
new_state = await domain.TelegramState.create(telegram_id=telegram_id, state=state, context=context)
return new_state
async def clear_telegram_state(self, telegram_id: int) -> None:
await domain.TelegramState.filter(telegram_id=telegram_id).delete()
# Count methods # Count methods
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int: async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int:
return await domain.Placement.filter(creative_id=creative_id).count() return await domain.Placement.filter(creative_id=creative_id).count()

View File

@@ -7,7 +7,7 @@ from src.controller.http_v1.creatives import creatives_router
from src.controller.http_v1.internal import internal_router from src.controller.http_v1.internal import internal_router
from src.controller.http_v1.placements import placements_router from src.controller.http_v1.placements import placements_router
from src.controller.http_v1.projects import projects_router from src.controller.http_v1.projects import projects_router
from src.controller.http_v1.purchase_plans import purchase_plan_channels_router from src.controller.http_v1.purchases import purchase_router
from src.controller.http_v1.views import views_router from src.controller.http_v1.views import views_router
from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router
from src.controller.http_v1.workspace_members import workspace_members_router from src.controller.http_v1.workspace_members import workspace_members_router
@@ -21,7 +21,7 @@ api_v1_router.include_router(auth_router)
api_v1_router.include_router(internal_router) api_v1_router.include_router(internal_router)
api_v1_router.include_router(channels_router) api_v1_router.include_router(channels_router)
api_v1_router.include_router(projects_router) api_v1_router.include_router(projects_router)
api_v1_router.include_router(purchase_plan_channels_router) api_v1_router.include_router(purchase_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

@@ -48,40 +48,4 @@ async def get_placement(
return await deps.get_usecase().get_placement(input=input) return await deps.get_usecase().get_placement(input=input)
@placements_router.post('') # Placement creation/updates are handled by worker; API exposes only read endpoints.
async def create_placement(
request: dto.CreatePlacementInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput:
return await deps.get_usecase().create_placement(request, current_user.user_id, workspace_id)
@placements_router.patch('/{placement_id}')
async def update_placement(
placement_id: uuid.UUID,
request: dto.UpdatePlacementInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput:
return await deps.get_usecase().update_placement(
placement_id=placement_id,
input=request,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
@placements_router.delete('/{placement_id}')
async def delete_placement(
placement_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None:
input = dto.DeletePlacementInput(
placement_id=placement_id,
user_id=current_user.user_id,
workspace_id=workspace_id,
)
await deps.get_usecase().delete_placement(input)

View File

@@ -12,9 +12,8 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[
@projects_router.get('') @projects_router.get('')
async def get_workspace_projects( async def get_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)],
) -> Page[dto.ProjectOutput]: ) -> Page[dto.ProjectOutput]:
input = dto.GetWorkspaceProjectsInput( input = dto.GetWorkspaceProjectsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
@@ -32,18 +31,9 @@ async def update_project_invite_link_type(
input: dto.UpdateProjectInviteLinkTypeInput, input: dto.UpdateProjectInviteLinkTypeInput,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ProjectOutput: ) -> dto.ProjectOutput:
project = await deps.get_usecase().update_project_invite_link_type( return await deps.get_usecase().update_project_invite_link_type(
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_id, project_id=project_id,
invite_link_type=input.invite_link_type, purchase_invite_type_default=input.purchase_invite_type_default,
user_id=current_user.user_id, user_id=current_user.user_id,
) )
return dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
invite_link_type=project.invite_link_type,
)

View File

@@ -1,76 +0,0 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
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)],
) -> Page[dto.PurchasePlanChannelOutput]:
input = dto.GetPurchasePlanChannelsInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
result = await deps.get_usecase().get_purchase_plan_channels(input=input)
return paginate(result) # type: ignore[no-any-return]
@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

@@ -0,0 +1,60 @@
import uuid
from typing import Annotated
from fastapi import Depends
from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto
from src.adapter.jwt import JWTPayload
purchase_router = APIRouter(
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase',
tags=['purchase'],
)
@purchase_router.post('')
async def create_purchase(
request: dto.CreatePurchaseInput,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchaseOutput:
return await deps.get_usecase().create_purchase(
project_id=project_id,
workspace_id=workspace_id,
user_id=current_user.user_id,
input=request,
)
@purchase_router.get('')
async def get_purchases(
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> Page[dto.PurchaseOutput]:
input = dto.GetPurchasesInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
)
result = await deps.get_usecase().get_purchases(input=input)
return paginate(result) # type: ignore[no-any-return]
@purchase_router.get('/{purchase_id}')
async def get_purchase(
purchase_id: uuid.UUID,
workspace_id: uuid.UUID,
project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchaseOutput:
input = dto.GetPurchaseInput(
user_id=current_user.user_id,
workspace_id=workspace_id,
project_id=project_id,
purchase_id=purchase_id,
)
return await deps.get_usecase().get_purchase(input=input)

View File

@@ -15,18 +15,16 @@ __all__ = (
'Channel', 'Channel',
'Project', 'Project',
'ProjectStatus', 'ProjectStatus',
'PurchasePlan', 'Purchase',
'PurchasePlanChannel', 'PurchaseChannel',
'PurchasePlanStatus', 'PurchaseStatus',
'PurchasePlanChannelStatus', 'PurchaseChannelStatus',
'Creative', 'Creative',
'replace_invite_link_with_tag', 'replace_invite_link_with_tag',
'Placement', 'Placement',
'Post', 'Post',
'Subscription', 'Subscription',
'PostViewsHistory', 'PostViewsHistory',
'TelegramState',
'TelegramStateEnum',
'ChannelNotFound', 'ChannelNotFound',
'ProjectNotFound', 'ProjectNotFound',
'CreativeStatus', 'CreativeStatus',
@@ -45,8 +43,8 @@ __all__ = (
'LoginTokenExpired', 'LoginTokenExpired',
'LoginTokenAlreadyUsed', 'LoginTokenAlreadyUsed',
'ProjectNotFound', 'ProjectNotFound',
'PurchasePlanNotFound', 'PurchaseNotFound',
'PurchasePlanChannelNotFound', 'PurchaseChannelNotFound',
'ChannelNotFound', 'ChannelNotFound',
'ChannelAlreadyExists', 'ChannelAlreadyExists',
'ChannelNoAdminRights', 'ChannelNoAdminRights',
@@ -74,8 +72,8 @@ from .error import (
LoginTokenNotFound, LoginTokenNotFound,
PlacementNotFound, PlacementNotFound,
ProjectNotFound, ProjectNotFound,
PurchasePlanChannelNotFound, PurchaseChannelNotFound,
PurchasePlanNotFound, PurchaseNotFound,
TelegramChannelNotFound, TelegramChannelNotFound,
UserByUsernameNotFound, UserByUsernameNotFound,
UserNotFound, UserNotFound,
@@ -89,15 +87,15 @@ from .login_token import LoginToken
from .placement import Placement, PlacementStatus, PostViewsAvailability from .placement import Placement, PlacementStatus, PostViewsAvailability
from .post import Post from .post import Post
from .post_views_history import PostViewsHistory from .post_views_history import PostViewsHistory
from .project import InviteLinkType, Project, ProjectStatus from .project import Project, ProjectStatus
from .purchase_plan import ( from .purchase import (
PurchasePlan, InviteLinkType,
PurchasePlanChannel, Purchase,
PurchasePlanChannelStatus, PurchaseChannel,
PurchasePlanStatus, PurchaseChannelStatus,
PurchaseStatus,
) )
from .subscription import Subscription, SubscriptionStatus from .subscription import Subscription, SubscriptionStatus
from .telegram_state import TelegramState, TelegramStateEnum
from .user import User from .user import User
from .workspace import ( from .workspace import (
PermissionKey, PermissionKey,

View File

@@ -43,16 +43,16 @@ def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException: def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
if plan_id is None: if purchase_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found')
def PurchasePlanChannelNotFound(plan_channel_id: uuid.UUID | None = None) -> HTTPException: def PurchaseChannelNotFound(purchase_channel_id: uuid.UUID | None = None) -> HTTPException:
if plan_channel_id is None: if purchase_channel_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException: def ChannelAlreadyExists(telegram_id: int) -> HTTPException:

View File

@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from .creative import Creative from .creative import Creative
from .post import Post from .post import Post
from .project import Project from .project import Project
from .purchase import Purchase, PurchaseChannel
from .workspace import Workspace from .workspace import Workspace
@@ -27,35 +28,44 @@ class PostViewsAvailability(str, enum.Enum):
class Placement(TimestampedModel): class Placement(TimestampedModel):
placement_date = fields.DatetimeField() wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True) cost = fields.FloatField(null=True)
comment = fields.TextField(null=True) comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512) invite_link = fields.CharField(max_length=512)
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
) )
placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'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
) )
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
) )
purchase: fields.ForeignKeyRelation['Purchase'] | None = fields.ForeignKeyField(
'models.Purchase', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
)
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] | None = fields.ForeignKeyField(
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
)
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True 'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
project_id: UUID
placement_channel_id: UUID
creative_id: UUID
workspace_id: UUID workspace_id: UUID
project_id: UUID
creative_id: UUID
placement_channel_id: UUID
purchase_id: UUID | None
post_id: UUID | None post_id: UUID | None
purchase_channel_id: UUID | None
class Meta: class Meta:
table = 'placement' table = 'placement'

View File

@@ -4,6 +4,7 @@ from uuid import UUID
from tortoise import fields from tortoise import fields
from .purchase import InviteLinkType
from .base import TimestampedModel from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -17,25 +18,21 @@ class ProjectStatus(str, enum.Enum):
ARCHIVED = 'archived' ARCHIVED = 'archived'
class InviteLinkType(str, enum.Enum):
PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом
class Project(TimestampedModel): class Project(TimestampedModel):
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
invite_link_type = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10) purchase_invite_type_default = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True 'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True
) )
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING: if TYPE_CHECKING:
workspace_id: UUID
channel_id: UUID channel_id: UUID
workspace_id: UUID
class Meta: class Meta:
table = 'project' table = 'project'

74
src/domain/purchase.py Normal file
View File

@@ -0,0 +1,74 @@
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 .creative import Creative
from .project import Project
from .workspace import Workspace
class PurchaseStatus(str, enum.Enum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class PurchaseChannelStatus(str, enum.Enum):
PLANNED = 'planned'
APPROVED = 'approved'
REJECTED = 'rejected'
IN_PROGRESS = 'in_progress'
COMPLETED = 'completed'
class Purchase(TimestampedModel):
status = fields.CharEnumField(PurchaseStatus, default=PurchaseStatus.ACTIVE)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='purchases', on_delete=fields.CASCADE, index=True
)
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True
)
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.Workspace', related_name='purchases', on_delete=fields.CASCADE, index=True
)
class Meta:
table = 'purchase'
indexes = (('project', 'status'),)
class InviteLinkType(str, enum.Enum):
PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом
class PurchaseChannel(TimestampedModel):
status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED)
planned_cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512)
invite_link_type = fields.CharEnumField(InviteLinkType)
purchase: fields.ForeignKeyRelation['Purchase'] = fields.ForeignKeyField(
'models.Purchase', related_name='channels', on_delete=fields.CASCADE, index=True
)
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
'models.Channel', related_name='purchase_channels', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
purchase_id: uuid.UUID
channel_id: uuid.UUID
class Meta:
table = 'purchase_channel'
unique_together = (('purchase_id', 'channel_id'),)

View File

@@ -1,61 +0,0 @@
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):
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):
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,22 +0,0 @@
import enum
from tortoise import fields
from .base import TimestampedModel
class TelegramStateEnum(str, enum.Enum):
CREATIVE_WAITING_WORKSPACE = 'creative_waiting_workspace'
CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel'
CREATIVE_WAITING_NAME = 'creative_waiting_name'
CREATIVE_WAITING_TEXT = 'creative_waiting_text'
PROJECT_WAITING_WORKSPACE = 'project_waiting_workspace'
class TelegramState(TimestampedModel):
telegram_id = fields.BigIntField(unique=True, index=True)
state = fields.CharEnumField(TelegramStateEnum)
context = fields.JSONField(default=dict)
class Meta:
table = 'telegram_state'

View File

@@ -9,18 +9,19 @@ __all__ = (
'ProjectOutput', 'ProjectOutput',
'ValidateLoginTokenInput', 'ValidateLoginTokenInput',
'ValidateLoginTokenOutput', 'ValidateLoginTokenOutput',
'TelegramLoginInput',
'ChannelBotPermissions', 'ChannelBotPermissions',
'ChannelOutput', 'ChannelOutput',
'GetChannelInput', 'GetChannelInput',
'GetChannelsInput', 'GetChannelsInput',
'GetChannelsOutput', 'GetChannelsOutput',
'AttachChannelToWorkspaceInput', 'AttachChannelToWorkspaceInput',
'PurchasePlanChannelOutput', 'PurchaseOutput',
'GetPurchasePlanChannelsInput', 'PurchaseChannelOutput',
'GetPurchasePlanChannelsOutput', 'CreatePurchaseInput',
'AttachChannelToPurchasePlanInput', 'CreatePurchaseChannelInput',
'UpdatePurchasePlanChannelInput', 'GetPurchasesInput',
'GetPurchasesOutput',
'GetPurchaseInput',
'CreativeOutput', 'CreativeOutput',
'GetCreativesInput', 'GetCreativesInput',
'GetCreativesOutput', 'GetCreativesOutput',
@@ -32,9 +33,6 @@ __all__ = (
'GetPlacementsInput', 'GetPlacementsInput',
'GetPlacementsOutput', 'GetPlacementsOutput',
'GetPlacementInput', 'GetPlacementInput',
'CreatePlacementInput',
'UpdatePlacementInput',
'DeletePlacementInput',
'PostViewsHistoryOutput', 'PostViewsHistoryOutput',
'GetViewsHistoryInput', 'GetViewsHistoryInput',
'GetViewsHistoryOutput', 'GetViewsHistoryOutput',
@@ -104,15 +102,7 @@ from .creative import (
GetCreativesOutput, GetCreativesOutput,
UpdateCreativeInput, UpdateCreativeInput,
) )
from .placement import ( from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput
CreatePlacementInput,
DeletePlacementInput,
GetPlacementInput,
GetPlacementsInput,
GetPlacementsOutput,
PlacementOutput,
UpdatePlacementInput,
)
from .project import ( from .project import (
ChannelBotPermissions, ChannelBotPermissions,
ConnectProjectInput, ConnectProjectInput,
@@ -124,14 +114,15 @@ from .project import (
UpdateProjectInviteLinkTypeInput, UpdateProjectInviteLinkTypeInput,
UpdateProjectPermissionsInput, UpdateProjectPermissionsInput,
) )
from .purchase_plan import ( from .purchase import (
AttachChannelToPurchasePlanInput, CreatePurchaseChannelInput,
GetPurchasePlanChannelsInput, CreatePurchaseInput,
GetPurchasePlanChannelsOutput, GetPurchaseInput,
PurchasePlanChannelOutput, GetPurchasesInput,
UpdatePurchasePlanChannelInput, GetPurchasesOutput,
PurchaseChannelOutput,
PurchaseOutput,
) )
from .telegram_login import TelegramLoginInput
from .user import UserOutput from .user import UserOutput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
from .views import ( from .views import (
@@ -162,4 +153,4 @@ from .workspace import (
class CreateLoginTokenRequest(BaseModel): class CreateLoginTokenRequest(BaseModel):
telegram_id: int telegram_id: int
username: str | None username: str | None

View File

@@ -18,10 +18,12 @@ class PlacementOutput(pydantic.BaseModel):
cost: float | None cost: float | None
comment: str | None comment: str | None
ad_post_url: str | None ad_post_url: str | None
invite_link_type: domain.InviteLinkType invite_link_type: domain.purchase.InviteLinkType
invite_link: str invite_link: str
status: domain.PlacementStatus status: domain.PlacementStatus
subscriptions_count: int subscriptions_count: int
purchase_id: uuid.UUID | None = None
purchase_channel_id: uuid.UUID | None = None
created_at: datetime.datetime created_at: datetime.datetime

View File

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

53
src/dto/purchase.py Normal file
View File

@@ -0,0 +1,53 @@
import uuid
import pydantic
from src.domain.purchase import InviteLinkType, PurchaseChannelStatus, PurchaseStatus
from .channel import ChannelOutput
class PurchaseChannelOutput(pydantic.BaseModel):
id: uuid.UUID
status: PurchaseChannelStatus
planned_cost: float | None = None
comment: str | None = None
invite_link: str
invite_link_type: InviteLinkType
channel: ChannelOutput
class PurchaseOutput(pydantic.BaseModel):
id: uuid.UUID
status: PurchaseStatus
creative_id: uuid.UUID
channels: list[PurchaseChannelOutput]
class CreatePurchaseChannelInput(pydantic.BaseModel):
username: str = pydantic.Field(pattern=r'^[^@]')
status: PurchaseChannelStatus | None = None
planned_cost: float | None = None
comment: str | None = None
class CreatePurchaseInput(pydantic.BaseModel):
creative_id: uuid.UUID
channels: list[CreatePurchaseChannelInput]
class GetPurchasesInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID
class GetPurchasesOutput(pydantic.BaseModel):
purchases: list[PurchaseOutput]
class GetPurchaseInput(pydantic.BaseModel):
user_id: uuid.UUID
workspace_id: uuid.UUID
project_id: uuid.UUID
purchase_id: uuid.UUID

View File

@@ -1,38 +0,0 @@
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 = pydantic.Field(pattern=r'^[^@]')
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

@@ -1,7 +0,0 @@
import pydantic
class TelegramLoginInput(pydantic.BaseModel):
telegram_id: int
username: str | None
chat_id: int

View File

@@ -3,7 +3,6 @@ import typing
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID from uuid import UUID
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from aiogram.types import InlineKeyboardButton from aiogram.types import InlineKeyboardButton
@@ -13,8 +12,8 @@ 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_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_jwt_by_telegram_id import get_jwt_by_telegram_id
from .auth.create_telegram_login_token import create_telegram_login_token from .auth.create_telegram_login_token import create_telegram_login_token
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
from .auth.get_me import get_me from .auth.get_me import get_me
from .auth.validate_login_token import validate_login_token from .auth.validate_login_token import validate_login_token
from .channel.attach_channel_to_workspace import attach_channel_to_workspace from .channel.attach_channel_to_workspace import attach_channel_to_workspace
@@ -25,22 +24,18 @@ from .creative.delete_creative import delete_creative
from .creative.get_creative import get_creative from .creative.get_creative import get_creative
from .creative.get_creatives import get_creatives from .creative.get_creatives import get_creatives
from .creative.update_creative import update_creative from .creative.update_creative import update_creative
from .placement.create_placement import create_placement
from .placement.delete_placement import delete_placement
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
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 .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
from .project.get_project import get_project from .project.get_project import get_project
from .project.get_workspace_projects import get_workspace_projects from .project.get_workspace_projects import get_workspace_projects
from .project.tg_add_project import tg_add_project from .project.tg_add_project import tg_add_project
from .project.update_project_invite_link_type import update_project_invite_link_type from .project.update_project_invite_link_type import update_project_invite_link_type
from .project.update_project_permissions import update_project_permissions 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.create_purchase import create_purchase
from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels from .purchase.get_purchase import get_purchase
from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel from .purchase.get_purchases import get_purchases
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 .views.get_views_history import get_views_history from .views.get_views_history import get_views_history
@@ -155,31 +150,37 @@ class Database(typing.Protocol):
self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None
) -> list[domain.Project]: ... ) -> list[domain.Project]: ...
async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ... async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ...
async def get_purchase_plan(self, workspace_id: UUID, plan_id: UUID) -> domain.PurchasePlan | None: ... async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: ... async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ...
async def get_purchase_plan_channels(self, plan_id: UUID) -> list[domain.PurchasePlanChannel]: ... async def create_purchase(self, purchase: domain.Purchase) -> None: ...
async def get_purchase_plan_channel( async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ...
self, plan_channel_id: UUID, plan_id: UUID
) -> domain.PurchasePlanChannel | None: ...
async def add_channel_to_purchase_plan( async def get_purchase_channel(
self, purchase_channel_id: UUID, purchase_id: UUID
) -> domain.PurchaseChannel | None: ...
async def get_purchase_channels_by_project(self, project_id: UUID) -> list[domain.PurchaseChannel]: ...
async def add_channel_to_purchase(
self, self,
plan_id: UUID, purchase_id: UUID,
channel_id: UUID, channel_id: UUID,
invite_link: str,
invite_link_type: domain.purchase.InviteLinkType,
*, *,
status: domain.PurchasePlanChannelStatus | None = None, status: domain.PurchaseChannelStatus | None = None,
planned_cost: float | None = None, planned_cost: float | None = None,
comment: str | None = None, comment: str | None = None,
) -> domain.PurchasePlanChannel: ... ) -> domain.PurchaseChannel: ...
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: ... async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ...
async def remove_channel_from_purchase_plan(self, plan_id: UUID, channel_id: UUID) -> None: ... async def remove_channel_from_purchase(self, purchase_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: ...
@@ -197,10 +198,6 @@ class Database(typing.Protocol):
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ... async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
async def create_placement(self, placement: domain.Placement) -> None: ...
async def update_placement(self, placement: domain.Placement) -> None: ...
async def get_workspace_placements( async def get_workspace_placements(
self, self,
workspace_id: UUID, workspace_id: UUID,
@@ -264,14 +261,6 @@ class Database(typing.Protocol):
async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ... async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ...
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ...
async def set_telegram_state(
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
) -> domain.TelegramState: ...
async def clear_telegram_state(self, telegram_id: int) -> None: ...
class TelegramBotWriter(typing.Protocol): class TelegramBotWriter(typing.Protocol):
async def send_message(self, text: str, chat_id: int) -> None: ... async def send_message(self, text: str, chat_id: int) -> None: ...
@@ -362,14 +351,14 @@ class Usecase:
return workspace return workspace
async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan: async def ensure_active_purchase(self, project: domain.Project, creative: domain.Creative) -> domain.Purchase:
plan = await self.database.get_active_purchase_plan(project.id) purchase = await self.database.get_active_purchase(project.id, creative.id)
if plan: if purchase:
return plan return purchase
plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id) purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id)
await self.database.create_purchase_plan(plan) await self.database.create_purchase(purchase)
return plan return purchase
validate_login_token = validate_login_token validate_login_token = validate_login_token
create_telegram_login_token = create_telegram_login_token create_telegram_login_token = create_telegram_login_token
@@ -384,10 +373,9 @@ class Usecase:
get_channels = get_channels get_channels = get_channels
get_channel = get_channel get_channel = get_channel
attach_channel_to_workspace = attach_channel_to_workspace attach_channel_to_workspace = attach_channel_to_workspace
attach_channel_to_purchase_plan = attach_channel_to_purchase_plan create_purchase = create_purchase
get_purchase_plan_channels = get_purchase_plan_channels get_purchases = get_purchases
update_purchase_plan_channel = update_purchase_plan_channel get_purchase = get_purchase
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
@@ -395,9 +383,6 @@ class Usecase:
delete_creative = delete_creative delete_creative = delete_creative
get_placements = get_placements get_placements = get_placements
get_placement = get_placement get_placement = get_placement
create_placement = create_placement
update_placement = update_placement
delete_placement = delete_placement
fetch_placement_post_cycle = fetch_placement_post_cycle fetch_placement_post_cycle = fetch_placement_post_cycle
handle_subscription = handle_subscription handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription handle_unsubscription = handle_unsubscription

View File

@@ -25,9 +25,8 @@ async def get_channel_analytics(
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
plan = await self.ensure_active_purchase_plan(project) purchase_channels = await self.database.get_purchase_channels_by_project(project.id)
plan_channels = await self.database.get_purchase_plan_channels(plan.id) channels = [pc.channel for pc in purchase_channels]
channels = [pc.channel for pc in plan_channels]
allowed_project_filter = None allowed_project_filter = None
else: else:
allowed_project_filter = allowed_project_ids allowed_project_filter = allowed_project_ids

View File

@@ -61,7 +61,7 @@ async def get_placements_analytics(
placement_channel_title=p.placement_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.wanted_placement_date,
cost=p.cost, cost=p.cost,
subscriptions_count=subscriptions_counts.get(p.id, 0), subscriptions_count=subscriptions_counts.get(p.id, 0),
views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None, views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None,

View File

@@ -56,8 +56,8 @@ async def get_spending_analytics(
filtered = [ filtered = [
p p
for p in placements for p in placements
if (not input.date_from or p.placement_date >= input.date_from) if (not input.date_from or p.wanted_placement_date >= input.date_from)
and (not input.date_to or p.placement_date <= input.date_to) and (not input.date_to or p.wanted_placement_date <= input.date_to)
] ]
total_cost = 0.0 total_cost = 0.0
@@ -82,7 +82,7 @@ async def get_spending_analytics(
period_data: dict[str, PeriodData] = defaultdict(PeriodData) period_data: dict[str, PeriodData] = defaultdict(PeriodData)
for p in filtered: for p in filtered:
period = _format_period(p.placement_date, input.grouping) period = _format_period(p.wanted_placement_date, input.grouping)
pd = period_data[period] pd = period_data[period]
if p.cost is not None: if p.cost is not None:

View File

@@ -41,12 +41,10 @@ async def attach_channel_to_workspace(
await self.database.create_project(project) await self.database.create_project(project)
log.info('Project created for channel %s in workspace %s', channel.id, workspace.id) log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
await self.ensure_active_purchase_plan(project)
return dto.ProjectOutput( return dto.ProjectOutput(
id=project.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=project.status, status=project.status,
) )

View File

@@ -2,6 +2,7 @@ import logging
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import domain.purchase
from src import domain, dto from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -35,7 +36,7 @@ async def create_placement(
raise domain.CreativeNotFound(input.creative_id) raise domain.CreativeNotFound(input.creative_id)
# Берем тип ссылки из настроек проекта # Берем тип ссылки из настроек проекта
requires_approval = project.invite_link_type == domain.InviteLinkType.APPROVAL requires_approval = project.purchase_invite_type_default == domain.purchase.InviteLinkType.APPROVAL
if project.channel.telegram_id is None: if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id) raise domain.ChannelNotFound(project.channel.id)
@@ -64,11 +65,11 @@ async def create_placement(
placement_channel_title=placement_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.wanted_placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=None, ad_post_url=None,
invite_link_type=project.invite_link_type, invite_link_type=project.purchase_invite_type_default,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=0, subscriptions_count=0,

View File

@@ -12,52 +12,56 @@ log = logging.getLogger(__name__)
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None: async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
log.debug('Starting fetch_placement_post_cycle') log.debug('Starting fetch_placement_post_cycle')
placements_without_post = ( purchase_channels = (
await domain.Placement.filter( await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE)
post_id__isnull=True, .prefetch_related('channel', 'purchase')
)
.prefetch_related('placement_channel')
.all() .all()
) )
if not placements_without_post: if not purchase_channels:
log.debug('No placements without posts found') log.debug('No active purchase channels found')
return return
log.info(f'Found {len(placements_without_post)} placements without posts') created_count = 0
updated_count = 0 for purchase_channel in purchase_channels:
if purchase_channel.channel is None or purchase_channel.purchase is None:
for placement in placements_without_post: log.warning('Purchase channel %s missing channel or purchase, skipping', purchase_channel.id)
try:
matching_post = await domain.Post.filter(
channel_id=placement.placement_channel_id,
text__contains=placement.invite_link,
deleted_from_channel_at__isnull=True,
).first()
if matching_post:
# Validate channel consistency
if matching_post.channel_id != placement.placement_channel_id:
log.error(
'Post %s channel_id mismatch: expected %s, got %s. Skipping placement %s',
matching_post.id,
placement.placement_channel_id,
matching_post.channel_id,
placement.id,
)
continue
placement.post = matching_post
await placement.save()
updated_count += 1
log.info(
f'Linked post {matching_post.id} (message_id={matching_post.message_id}) '
f'to placement {placement.id}'
)
except Exception as e:
log.exception(f'Error processing placement {placement.id}: {e}')
continue continue
log.info(f'Fetch placement post cycle completed. Updated {updated_count} placements') posts = await domain.Post.filter(
channel_id=purchase_channel.channel_id,
text__contains=purchase_channel.invite_link,
deleted_from_channel_at__isnull=True,
).all()
for post in posts:
existing = await domain.Placement.filter(post_id=post.id).first()
if existing:
continue
placement = domain.Placement(
wanted_placement_date=post.created_at,
cost=None,
comment=None,
invite_link=purchase_channel.invite_link,
workspace_id=purchase_channel.purchase.workspace_id,
project_id=purchase_channel.purchase.project_id,
creative_id=purchase_channel.purchase.creative_id,
placement_channel_id=purchase_channel.channel_id,
purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
post=post,
status=domain.PlacementStatus.ACTIVE,
)
await self.database.create_placement(placement)
created_count += 1
log.info(
'Created placement %s for purchase_channel %s from post %s',
placement.id,
purchase_channel.id,
post.id,
)
log.info('Fetch placement post cycle completed. Created %s placements', created_count)

View File

@@ -33,11 +33,11 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
placement_channel_title=placement.placement_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.wanted_placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=ad_post_url, ad_post_url=ad_post_url,
invite_link_type=placement.project.invite_link_type, invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=subscriptions_count, subscriptions_count=subscriptions_count,

View File

@@ -42,11 +42,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
placement_channel_title=placement.placement_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.wanted_placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=ad_post_url, ad_post_url=ad_post_url,
invite_link_type=placement.project.invite_link_type, invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=subscriptions_counts.get(placement.id, 0), subscriptions_count=subscriptions_counts.get(placement.id, 0),

View File

@@ -27,7 +27,7 @@ async def update_placement(
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id) context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
if input.placement_date is not None: if input.placement_date is not None:
placement.placement_date = input.placement_date placement.wanted_placement_date = input.placement_date
if input.cost is not None: if input.cost is not None:
placement.cost = input.cost placement.cost = input.cost
if input.comment is not None: if input.comment is not None:
@@ -49,11 +49,11 @@ async def update_placement(
placement_channel_title=placement.placement_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.wanted_placement_date,
cost=placement.cost, cost=placement.cost,
comment=placement.comment, comment=placement.comment,
ad_post_url=ad_post_url, ad_post_url=ad_post_url,
invite_link_type=placement.project.invite_link_type, invite_link_type=placement.project.purchase_invite_type_default,
invite_link=placement.invite_link, invite_link=placement.invite_link,
status=placement.status, status=placement.status,
subscriptions_count=subscriptions_count, subscriptions_count=subscriptions_count,

View File

@@ -20,5 +20,5 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec
title=project.channel.title, title=project.channel.title,
username=project.channel.username, username=project.channel.username,
status=project.status, status=project.status,
invite_link_type=project.invite_link_type, purchase_invite_type_default=project.purchase_invite_type_default,
) )

View File

@@ -6,9 +6,7 @@ if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
async def get_workspace_projects( async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]:
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
) -> list[dto.ProjectOutput]:
context = await self.ensure_workspace_permission( context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
) )
@@ -24,7 +22,7 @@ async def get_workspace_projects(
title=project.channel.title, title=project.channel.title,
username=project.channel.username, username=project.channel.username,
status=project.status, status=project.status,
invite_link_type=project.invite_link_type, purchase_invite_type_default=project.purchase_invite_type_default,
) )
for project in projects for project in projects
] ]

View File

@@ -104,8 +104,6 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
) )
await self.database.create_project(project) await self.database.create_project(project)
await self.ensure_active_purchase_plan(project)
log.info( log.info(
'Project for 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,

View File

@@ -2,7 +2,7 @@ import logging
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import domain from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -14,9 +14,9 @@ async def update_project_invite_link_type(
self: 'Usecase', self: 'Usecase',
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID,
invite_link_type: domain.InviteLinkType, purchase_invite_type_default: domain.purchase.InviteLinkType,
user_id: uuid.UUID, user_id: uuid.UUID,
) -> domain.Project: ) -> dto.ProjectOutput:
await self.ensure_workspace_permission( await self.ensure_workspace_permission(
workspace_id, workspace_id,
user_id, user_id,
@@ -24,19 +24,19 @@ async def update_project_invite_link_type(
for_project_id=project_id, for_project_id=project_id,
) )
async with self.database.transaction(): project = await self.database.get_project(workspace_id, project_id=project_id)
project = await self.database.get_project(workspace_id, project_id=project_id) if not project:
if not project: raise domain.ProjectNotFound(project_id)
raise domain.ProjectNotFound(project_id)
project.invite_link_type = invite_link_type project.purchase_invite_type_default = purchase_invite_type_default
await project.save()
log.info( await self.database.update_project(project)
'Project %s invite_link_type updated to %s by user %s',
project_id,
invite_link_type,
user_id,
)
return project return dto.ProjectOutput(
id=project.id,
telegram_id=project.channel.telegram_id,
title=project.channel.title,
username=project.channel.username,
status=project.status,
purchase_invite_type_default=project.purchase_invite_type_default,
)

View File

@@ -0,0 +1,117 @@
import logging
import random
import uuid
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
def _build_purchase_output(
purchase: domain.Purchase, purchase_channels: list[domain.PurchaseChannel]
) -> dto.PurchaseOutput:
channels_output: list[dto.PurchaseChannelOutput] = []
for purchase_channel in purchase_channels:
channel = purchase_channel.channel
if channel is None:
log.warning('Purchase channel %s has no channel prefetched', purchase_channel.id)
continue
channels_output.append(
dto.PurchaseChannelOutput(
id=purchase_channel.id,
status=purchase_channel.status,
planned_cost=purchase_channel.planned_cost,
comment=purchase_channel.comment,
invite_link=purchase_channel.invite_link,
invite_link_type=purchase_channel.invite_link_type,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
),
)
)
return dto.PurchaseOutput(
id=purchase.id,
status=purchase.status,
creative_id=purchase.creative_id,
channels=channels_output,
)
async def create_purchase(
self: 'Usecase',
project_id: uuid.UUID,
workspace_id: uuid.UUID,
user_id: uuid.UUID,
input: dto.CreatePurchaseInput,
) -> dto.PurchaseOutput:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
creative = await self.database.get_creative(workspace_id, input.creative_id)
if not creative or creative.project_id != project.id:
raise domain.CreativeNotFound(input.creative_id)
existing_purchase = await self.database.get_active_purchase(project.id, creative.id)
if existing_purchase:
channels = await self.database.get_purchase_channels(existing_purchase.id)
return _build_purchase_output(existing_purchase, channels)
purchase = domain.Purchase(workspace_id=workspace_id, project_id=project.id, creative_id=creative.id)
await self.database.create_purchase(purchase)
if project.channel.telegram_id is None:
raise domain.ChannelNotFound(project.channel.id)
invite_link_type = project.purchase_invite_type_default
requires_approval = invite_link_type == domain.purchase.InviteLinkType.APPROVAL
purchase_channels: list[domain.PurchaseChannel] = []
for channel_input in input.channels:
channel = await self.database.get_channel(username=channel_input.username)
if not channel:
channel = domain.Channel(
username=channel_input.username,
telegram_id=random.randint(1, 10000),
title='parser_response.title',
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', channel_input.username, channel.telegram_id)
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
purchase_channel = await self.database.add_channel_to_purchase(
purchase.id,
channel.id,
invite_link=invite_link,
invite_link_type=invite_link_type,
status=channel_input.status,
planned_cost=channel_input.planned_cost,
comment=channel_input.comment,
)
purchase_channel.channel = channel
purchase_channels.append(purchase_channel)
log.info(
'Purchase %s created for project %s (creative %s) with %s channels',
purchase.id,
project.id,
creative.id,
len(purchase_channels),
)
return _build_purchase_output(purchase, purchase_channels)

View File

@@ -0,0 +1,30 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
from .create_purchase import _build_purchase_output
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
purchase = await self.database.get_purchase(input.workspace_id, input.purchase_id)
if not purchase or purchase.project_id != project.id:
raise domain.PurchaseNotFound(input.purchase_id)
channels = await self.database.get_purchase_channels(purchase.id)
return _build_purchase_output(purchase, channels)

View File

@@ -0,0 +1,32 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
from .create_purchase import _build_purchase_output
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> list[dto.PurchaseOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
purchases = await self.database.get_project_purchases(input.workspace_id, project.id)
log.debug('Fetched %s purchases for project %s', len(purchases), project.id)
output: list[dto.PurchaseOutput] = []
for purchase in purchases:
channels = await self.database.get_purchase_channels(purchase.id)
output.append(_build_purchase_output(purchase, channels))
return output

View File

@@ -1,67 +0,0 @@
import logging
import random
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:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project)
channel = await self.database.get_channel(username=input.username)
if not channel:
# parser_response = await self.parser.fetch_telegram_channel(input.username)
# if not parser_response:
# raise domain.TelegramChannelNotFound(input.username)
channel = domain.Channel(
username=input.username,
telegram_id=random.randint(1, 10000),
title='parser_response.title',
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', input.username, channel.telegram_id)
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,
),
)

View File

@@ -1,44 +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_purchase_plan_channels(
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
) -> list[dto.PurchasePlanChannelOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
)
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, 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.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,
),
)
for plan_channel in plan_channels
]

View File

@@ -1,35 +0,0 @@
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:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, 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

@@ -1,72 +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_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:
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
project = await self.database.get_project(workspace_id, project_id=project_id)
if not project:
raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, 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(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,
),
)

View File

@@ -141,12 +141,12 @@ type Workspace struct {
} }
type Project struct { type Project struct {
ID string `json:"id"` ID string `json:"id"`
TelegramID int64 `json:"telegram_id"` TelegramID int64 `json:"telegram_id"`
Title string `json:"title"` Title string `json:"title"`
Username *string `json:"username"` Username *string `json:"username"`
Status string `json:"status"` Status string `json:"status"`
InviteLinkType string `json:"invite_link_type"` // "public" или "approval" PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
} }
type Page struct { type Page struct {
@@ -231,7 +231,7 @@ func (c *Client) UpdateProjectInviteLinkType(
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID) path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID)
payload := map[string]string{ payload := map[string]string{
"invite_link_type": inviteLinkType, "purchase_invite_type_default": inviteLinkType,
} }
var project Project var project Project
@@ -306,13 +306,14 @@ func (c *Client) AcceptWorkspaceInvite(
// ============================================================================ // ============================================================================
type Creative struct { type Creative struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"project_id"` Name string `json:"name"`
Name string `json:"name"` Text string `json:"text"`
Text string `json:"text"` ProjectID string `json:"project_id"`
IsArchived bool `json:"is_archived"` ProjectChannelTitle string `json:"project_channel_title"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` Status string `json:"status"`
PlacementsCount int `json:"placements_count"`
} }
type CreativesPage struct { type CreativesPage struct {
@@ -406,9 +407,9 @@ func (c *Client) CreateCreative(
} }
type UpdateCreativeInput struct { type UpdateCreativeInput struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Text *string `json:"text,omitempty"` Text *string `json:"text,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"` Status *string `json:"status,omitempty"`
} }
func (c *Client) UpdateCreative( func (c *Client) UpdateCreative(
@@ -456,17 +457,24 @@ func (c *Client) DeleteCreative(
// ============================================================================ // ============================================================================
type Placement struct { type Placement struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
PlacementChannelID string `json:"placement_channel_id"` ProjectChannelTitle *string `json:"project_channel_title"`
CreativeID *string `json:"creative_id"` PlacementChannelID string `json:"placement_channel_id"`
Name string `json:"name"` PlacementChannelTitle *string `json:"placement_channel_title"`
Price *int `json:"price"` CreativeID string `json:"creative_id"`
Status string `json:"status"` CreativeName string `json:"creative_name"`
InviteLink *string `json:"invite_link"` PlacementDate string `json:"placement_date"`
IsArchived bool `json:"is_archived"` Cost *float64 `json:"cost"`
CreatedAt string `json:"created_at"` Comment *string `json:"comment"`
UpdatedAt string `json:"updated_at"` AdPostURL *string `json:"ad_post_url"`
InviteLinkType string `json:"invite_link_type"`
InviteLink string `json:"invite_link"`
Status string `json:"status"`
SubscriptionsCount int `json:"subscriptions_count"`
PurchaseID *string `json:"purchase_id"`
PurchaseChannelID *string `json:"purchase_channel_id"`
CreatedAt string `json:"created_at"`
} }
type PlacementsPage struct { type PlacementsPage struct {
@@ -537,84 +545,10 @@ func (c *Client) GetPlacement(
return &placement, err return &placement, err
} }
type CreatePlacementInput struct { // Note: Placement creation/updates are handled by worker; API exposes only read endpoints.
ProjectID string `json:"project_id"`
PlacementChannelID string `json:"placement_channel_id"`
CreativeID *string `json:"creative_id,omitempty"`
Name string `json:"name"`
Price *int `json:"price,omitempty"`
}
func (c *Client) CreatePlacement(
ctx context.Context,
jwt string,
workspaceID string,
input CreatePlacementInput,
) (*Placement, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/placements", workspaceID)
var placement Placement
err := c.do(
ctx,
http.MethodPost,
path,
input,
&placement,
withBearer(jwt),
)
return &placement, err
}
type UpdatePlacementInput struct {
Name *string `json:"name,omitempty"`
Price *int `json:"price,omitempty"`
Status *string `json:"status,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
}
func (c *Client) UpdatePlacement(
ctx context.Context,
jwt string,
workspaceID string,
placementID string,
input UpdatePlacementInput,
) (*Placement, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
var placement Placement
err := c.do(
ctx,
http.MethodPatch,
path,
input,
&placement,
withBearer(jwt),
)
return &placement, err
}
func (c *Client) DeletePlacement(
ctx context.Context,
jwt string,
workspaceID string,
placementID string,
) error {
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
return c.do(
ctx,
http.MethodDelete,
path,
nil,
nil,
withBearer(jwt),
)
}
// ============================================================================ // ============================================================================
// Purchase Plan // Purchases (Закупы)
// ============================================================================ // ============================================================================
type Channel struct { type Channel struct {
@@ -624,24 +558,67 @@ type Channel struct {
Username *string `json:"username"` Username *string `json:"username"`
} }
type PurchasePlanChannel struct { type Purchase struct {
ID string `json:"id"` ID string `json:"id"`
Channel Channel `json:"channel"` Status string `json:"status"`
Status string `json:"status"` CreativeID string `json:"creative_id"`
PlannedCost *float64 `json:"planned_cost"` Channels []PurchaseChannelOut `json:"channels"`
Comment *string `json:"comment"`
} }
func (c *Client) GetPurchasePlanChannels( type PurchaseChannelOut struct {
ID string `json:"id"`
Status string `json:"status"`
PlannedCost *float64 `json:"planned_cost"`
Comment *string `json:"comment"`
InviteLink string `json:"invite_link"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
}
type CreatePurchaseChannelInput struct {
Username string `json:"username"`
Status *string `json:"status,omitempty"`
PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"`
}
type CreatePurchaseInput struct {
CreativeID string `json:"creative_id"`
Channels []CreatePurchaseChannelInput `json:"channels"`
}
func (c *Client) CreatePurchase(
ctx context.Context, ctx context.Context,
jwt string, jwt string,
workspaceID string, workspaceID string,
projectID string, projectID string,
) ([]PurchasePlanChannel, error) { input CreatePurchaseInput,
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID) ) (*Purchase, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
var purchase Purchase
err := c.do(
ctx,
http.MethodPost,
path,
input,
&purchase,
withBearer(jwt),
)
return &purchase, err
}
func (c *Client) GetPurchases(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
) ([]Purchase, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
var resp struct { var resp struct {
Items []PurchasePlanChannel `json:"items"` Items []Purchase `json:"items"`
} }
err := c.do( err := c.do(
@@ -656,81 +633,26 @@ func (c *Client) GetPurchasePlanChannels(
return resp.Items, err return resp.Items, err
} }
type AttachChannelToPurchasePlanInput struct { func (c *Client) GetPurchase(
Username string `json:"username"`
Status *string `json:"status,omitempty"`
PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"`
}
func (c *Client) AttachChannelToPurchasePlan(
ctx context.Context, ctx context.Context,
jwt string, jwt string,
workspaceID string, workspaceID string,
projectID string, projectID string,
input AttachChannelToPurchasePlanInput, purchaseID string,
) (*PurchasePlanChannel, error) { ) (*Purchase, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID) path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID)
var planChannel PurchasePlanChannel var purchase Purchase
err := c.do( err := c.do(
ctx, ctx,
http.MethodPost, http.MethodGet,
path,
input,
&planChannel,
withBearer(jwt),
)
return &planChannel, err
}
type UpdatePurchasePlanChannelInput struct {
Status *string `json:"status,omitempty"`
PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"`
}
func (c *Client) UpdatePurchasePlanChannel(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
channelID string,
input UpdatePurchasePlanChannelInput,
) (*PurchasePlanChannel, error) {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID)
var planChannel PurchasePlanChannel
err := c.do(
ctx,
http.MethodPatch,
path,
input,
&planChannel,
withBearer(jwt),
)
return &planChannel, err
}
func (c *Client) RemoveChannelFromPurchasePlan(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
channelID string,
) error {
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID)
return c.do(
ctx,
http.MethodDelete,
path, path,
nil, nil,
nil, &purchase,
withBearer(jwt), withBearer(jwt),
) )
return &purchase, err
} }
// ============================================================================ // ============================================================================

View File

@@ -101,6 +101,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
// Удаляем кнопки у предыдущего сообщения перед отправкой нового // Удаляем кнопки у предыдущего сообщения перед отправкой нового
if b.LastMessageID != 0 { if b.LastMessageID != 0 {
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
b.cleanupKeyboard(b.LastMessageID) b.cleanupKeyboard(b.LastMessageID)
} }
@@ -120,6 +121,7 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
if res.Result != nil { if res.Result != nil {
b.LastMessageID = res.Result.ID b.LastMessageID = res.Result.ID
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
} }
} }
@@ -278,6 +280,7 @@ func (b *Bot) cleanupCallbackUI(u *echotron.Update) {
if u.CallbackQuery.Message != nil { if u.CallbackQuery.Message != nil {
callbackMessageID := u.CallbackQuery.Message.ID callbackMessageID := u.CallbackQuery.Message.ID
// Если это сообщение последнее, не удаляем клавиатуру
if b.LastMessageID == callbackMessageID { if b.LastMessageID == callbackMessageID {
return return
} }

View File

@@ -1,122 +0,0 @@
package screens
import (
"fmt"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
type AddPlacement struct {
WorkspaceID string
BackState bot.State
// Выбранные данные
SelectedProject *backend.Project
SelectedCreative *backend.Creative
}
func (s *AddPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
text := `<b> Создать закуп</b>
Шаг 1: Выберите проект и креатив
Нажмите на кнопки ниже для выбора:`
var buttons [][]echotron.InlineKeyboardButton
// Кнопка выбора проекта
projectButtonText := "Проект: Не выбран"
if s.SelectedProject != nil {
projectButtonText = fmt.Sprintf("Проект: %s", s.SelectedProject.Title)
}
buttons = append(buttons, bot.Row(
bot.Button(projectButtonText, "select_project"),
))
// Кнопка выбора креатива
creativeButtonText := "Креатив: Не выбран"
if s.SelectedCreative != nil {
creativeButtonText = fmt.Sprintf("Креатив: %s", s.SelectedCreative.Name)
}
buttons = append(buttons, bot.Row(
bot.Button(creativeButtonText, "select_creative"),
))
// Кнопки навигации
var navRow []echotron.InlineKeyboardButton
navRow = append(navRow, bot.Button("« Назад", "cancel"))
// Кнопка "Далее" активна только если выбран проект
if s.SelectedProject != nil {
navRow = append(navRow, bot.Button("Далее →", "next"))
}
buttons = append(buttons, navRow)
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *AddPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "cancel":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "select_project":
// Переходим к выбору проекта
b.SetState(&SelectProjectForPlacement{
WorkspaceID: s.WorkspaceID,
AddPlacementState: s,
}, bot.NewMessage)
case "select_creative":
if s.SelectedProject == nil {
// Если проект не выбран, показываем предупреждение
b.Edit(`<b>⚠️ Сначала выберите проект</b>
Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_add_placement")),
))
return
}
// Переходим к выбору креатива
b.SetState(&SelectCreativeForPlacement{
WorkspaceID: s.WorkspaceID,
ProjectID: s.SelectedProject.ID,
AddPlacementState: s,
}, bot.NewMessage)
case "back_to_add_placement":
s.Enter(b, bot.EditMessage)
case "next":
if s.SelectedProject == nil {
return
}
// Переходим к управлению планом закупа (добавление/удаление каналов)
b.SetState(&PlacementPurchasePlan{
WorkspaceID: s.WorkspaceID,
ProjectID: s.SelectedProject.ID,
AddPlacementState: s,
}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *AddPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,160 @@
package screens
import (
"context"
"fmt"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
const addPurchaseCreativesPerPage = 5
type AddPurchase struct {
WorkspaceID string
ProjectID string
CurrentPage int
BackState bot.State
}
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderCreativeSelection(b, mode, jwt)
}
func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
// Получаем список активных креативов
page, err := b.Backend.GetCreatives(
context.Background(),
jwt,
s.WorkspaceID,
&s.ProjectID,
false, // только активные
s.CurrentPage+1,
addPurchaseCreativesPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
return
}
text := "<b> Создание закупа</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += `Для создания закупа сначала нужно создать креатив.
<i>Вернитесь назад и создайте креатив в разделе "Креативы"</i>`
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
} else {
pageInfo := ""
if page.Pages > 1 {
pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
}
text += fmt.Sprintf(`<b>Шаг 1/2: Выбор креатива</b>%s
<i>Выберите креатив для закупа:</i>
`, pageInfo)
// Кнопки с креативами
for _, creative := range page.Items {
buttons = append(buttons, bot.Row(
bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
))
}
// Пагинация
var navRow []echotron.InlineKeyboardButton
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev"))
}
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
}
if len(navRow) > 0 {
buttons = append(buttons, navRow)
}
// Кнопка назад
buttons = append(buttons, bot.Row(
bot.Button("← Отмена", "back"),
))
}
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *AddPurchase) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
default:
// Проверяем, не выбрали ли креатив
if len(data) > 9 && data[:9] == "creative:" {
creativeID := data[9:]
// Переходим к выбору каналов
b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
CreativeID: creativeID,
Channels: []PurchaseChannelInput{},
BackState: s.BackState,
}, bot.EditMessage)
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -46,9 +46,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.Name = &creative.Name s.Name = &creative.Name
if creative.Text != "" { if creative.Text != "" {
s.Text = &creative.Text s.Text = &creative.Text
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
} else {
log.Info().Msg("Creative text is empty from backend")
} }
// Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД) // Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД)
log.Info().
Str("creative_id", s.CreativeID).
Str("name", creative.Name).
Bool("has_text", creative.Text != "").
Bool("s_text_is_nil", s.Text == nil).
Msg("Sending creative preview and panel")
// Отправляем превью и панель управления // Отправляем превью и панель управления
s.SendCreativePreview(b) s.SendCreativePreview(b)
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)

View File

@@ -2,6 +2,7 @@ package screens
import ( import (
"fmt" "fmt"
"strings"
"example.com/m/bot" "example.com/m/bot"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
@@ -43,8 +44,24 @@ type CreativeEditorFields struct {
// GetCreativeText возвращает текст для превью креатива // GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string { func (e *CreativeEditorFields) GetCreativeText() string {
if e.Text != nil && *e.Text != "" { if e.Text != nil && *e.Text != "" {
// Проверяем что текст не является "визуально пустым" HTML
// Например: "<a class=\"tg-link\"></a>" - пустая ссылка
text := strings.TrimSpace(*e.Text)
// Убираем пустые HTML-теги (простая проверка)
if text == "<a class=\"tg-link\"></a>" || text == "" {
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default")
return `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
return *e.Text return *e.Text
} }
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
return `<i>💭 Я твой креатив return `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже. У меня пока нет текста, добавь его ниже.
@@ -136,7 +153,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
} }
} else { } else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew) // Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не обновлять LastMessageID и не затронуть cleanup // чтобы не затронуть LastMessageID и не затронуть cleanup
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard, ReplyMarkup: keyboard,
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
@@ -319,12 +336,15 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
} else { } else {
// Редактируем существующую панель // Редактируем существующую панель
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel") log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
// Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID b.LastMessageID = *e.ControlPanelMessageID
keyboard := bot.Keyboard(buttons...) keyboard := bot.Keyboard(buttons...)
b.Edit(text, keyboard) b.Edit(text, keyboard)
// Восстанавливаем оригинальный LastMessageID
b.LastMessageID = oldLastMessageID b.LastMessageID = oldLastMessageID
log.Info().Msg("Control panel updated") log.Info().Msg("Control panel updated")
} }

View File

@@ -104,24 +104,20 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
buttons = append(buttons, row) buttons = append(buttons, row)
} }
// Ряд: Пагинация + Добавить // Ряд: Пагинация + Добавить (динамическая ширина)
var navRow []echotron.InlineKeyboardButton var navRow []echotron.InlineKeyboardButton
// Левая стрелка (или пустая кнопка) // Левая стрелка (только если есть предыдущая страница)
if s.CurrentPage > 0 { if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev")) navRow = append(navRow, bot.Button("←", "prev"))
} else {
navRow = append(navRow, bot.Button(" ", "empty"))
} }
// Кнопка "Добавить креатив" всегда в центре // Кнопка "Добавить креатив" всегда в центре
navRow = append(navRow, bot.Button(" Добавить", "add_creative")) navRow = append(navRow, bot.Button(" Добавить", "add_creative"))
// Правая стрелка (или пустая кнопка) // Правая стрелка (только если есть следующая страница)
if s.CurrentPage+1 < page.Pages { if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next")) navRow = append(navRow, bot.Button("→", "next"))
} else {
navRow = append(navRow, bot.Button(" ", "empty"))
} }
buttons = append(buttons, navRow) buttons = append(buttons, navRow)

View File

@@ -19,11 +19,11 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data { switch u.CallbackQuery.Data {
case "my_projects": case "my_projects":
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage) b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage)
case "support": case "support":
// b.SetState(&Support{}, bot.NewMessage) // b.SetState(&Support{}, bot.NewMessage)
case "placements": case "placements":
b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage) b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.NewMessage)
case "dashboard": case "dashboard":
// b.SetState(&Dashboard{}, bot.NewMessage) // b.SetState(&Dashboard{}, bot.NewMessage)
default: default:

View File

@@ -66,27 +66,37 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
return return
} }
text := "Мои закупы • Управление размещениями\n\n" text := "<b>📍 Мои размещения</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 { if len(page.Items) == 0 {
text += "У вас пока нет закупов" text += `У вас пока нет размещений.
} else {
text += fmt.Sprintf("Выберите закуп для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
// Закупы по 2 в ряду <i>Размещения создаются автоматически при создании закупа.</i>`
} else {
text += fmt.Sprintf("Выберите размещение для деталей (стр. %d из %d):\n\n", s.CurrentPage+1, page.Pages)
// Размещения по 2 в ряду
for i := 0; i < len(page.Items); i += 2 { for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton var row []echotron.InlineKeyboardButton
// Первый закуп в ряду // Первый placement в ряду
placement := page.Items[i] placement := page.Items[i]
row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID))) placementName := placement.CreativeName
if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" {
placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle)
}
row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
// Второй закуп в ряду (если есть) // Второй placement в ряду (если есть)
if i+1 < len(page.Items) { if i+1 < len(page.Items) {
placement = page.Items[i+1] placement = page.Items[i+1]
row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID))) placementName = placement.CreativeName
if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" {
placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle)
}
row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
} else { } else {
// Добавляем пустую кнопку для выравнивания // Добавляем пустую кнопку для выравнивания
row = append(row, bot.Button(" ", "empty")) row = append(row, bot.Button(" ", "empty"))
@@ -121,12 +131,11 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
// Кнопки управления // Кнопки управления
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("Статистика", "statistics"), bot.Button("📊 Статистика", "statistics"),
bot.Button("Создать закуп", "add_placement"),
)) ))
// Кнопка возврата в главное меню // Кнопка возврата в главное меню
buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu"))) buttons = append(buttons, bot.Row(bot.Button("← Назад", "back")))
keyboard := bot.Keyboard(buttons...) keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(text, keyboard, mode)
@@ -167,8 +176,12 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.CurrentPage++ s.CurrentPage++
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)
case data == "menu": case data == "back":
b.SetState(&MainMenu{}, bot.EditMessage) if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
} else {
b.SetState(&MainMenu{}, bot.EditMessage)
}
case data == "page_info", data == "empty": case data == "page_info", data == "empty":
// Неактивные кнопки - ничего не делаем // Неактивные кнопки - ничего не делаем
@@ -176,16 +189,10 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
case data == "statistics": case data == "statistics":
// TODO: реализовать экран статистики // TODO: реализовать экран статистики
b.Edit("📊 Статистика закупов\n\nЭта функция в разработке", bot.Keyboard( b.Edit("📊 Статистика размещений\n\nЭта функция в разработке", bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_placements")), bot.Row(bot.Button("« Назад", "back_to_placements")),
)) ))
case data == "add_placement":
b.SetState(&AddPlacement{
WorkspaceID: s.WorkspaceID,
BackState: s,
}, bot.EditMessage)
case data == "back_to_placements": case data == "back_to_placements":
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)

View File

@@ -115,24 +115,20 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
buttons = append(buttons, row) buttons = append(buttons, row)
} }
// Ряд: Пагинация + Добавить // Ряд: Пагинация + Добавить (динамическая ширина)
var navRow []echotron.InlineKeyboardButton var navRow []echotron.InlineKeyboardButton
// Левая стрелка (или пустая кнопка) // Левая стрелка (только если есть предыдущая страница)
if s.CurrentPage > 0 { if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev")) navRow = append(navRow, bot.Button("←", "prev"))
} else {
navRow = append(navRow, bot.Button(" ", "empty"))
} }
// Кнопка "Добавить проект" всегда в центре // Кнопка "Добавить проект" всегда в центре
navRow = append(navRow, bot.Button(" Добавить", "add_project")) navRow = append(navRow, bot.Button(" Добавить", "add_project"))
// Правая стрелка (или пустая кнопка) // Правая стрелка (только если есть следующая страница)
if s.CurrentPage+1 < page.Pages { if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next")) navRow = append(navRow, bot.Button("→", "next"))
} else {
navRow = append(navRow, bot.Button(" ", "empty"))
} }
buttons = append(buttons, navRow) buttons = append(buttons, navRow)
@@ -224,7 +220,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
Project: project, Project: project,
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
}, bot.EditMessage) }, bot.NewMessage)
} }
case strings.HasPrefix(data, "workspace:"): case strings.HasPrefix(data, "workspace:"):

View File

@@ -1,222 +0,0 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type PlacementPurchasePlan struct {
WorkspaceID string
ProjectID string
AddPlacementState *AddPlacement
}
func (s *PlacementPurchasePlan) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderChannels(b, mode, jwt)
}
func (s *PlacementPurchasePlan) renderChannels(b *bot.Bot, mode bot.RenderMode, jwt string) {
channels, err := b.Backend.GetPurchasePlanChannels(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchase plan channels")
b.SendNew("❌ Не удалось загрузить каналы", bot.Keyboard())
return
}
text := "<b>📋 План закупа — Каналы</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(channels) == 0 {
text += "Список каналов пуст.\n\n"
} else {
text += "Нажмите на канал для удаления:\n\n"
for _, ch := range channels {
channelName := "Без названия"
if ch.Channel.Title != nil {
channelName = *ch.Channel.Title
} else if ch.Channel.Username != nil {
channelName = "@" + *ch.Channel.Username
}
// Показываем статус и стоимость
statusEmoji := "📌"
switch ch.Status {
case "planned":
statusEmoji = "📝"
case "approved":
statusEmoji = "✅"
case "rejected":
statusEmoji = "❌"
case "in_progress":
statusEmoji = "🔄"
case "completed":
statusEmoji = "✔️"
}
buttonText := fmt.Sprintf("%s %s", statusEmoji, channelName)
if ch.PlannedCost != nil {
buttonText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost)
}
buttons = append(buttons, bot.Row(
bot.Button(buttonText, fmt.Sprintf("remove:%s", ch.ID)),
))
}
}
// Инструкция по добавлению канала
text += "\n<b>Добавление канала:</b>\nОтправьте username канала (без @)"
// Навигация
var navRow []echotron.InlineKeyboardButton
navRow = append(navRow, bot.Button("« Назад", "back"))
navRow = append(navRow, bot.Button("Далее →", "next"))
buttons = append(buttons, navRow)
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *PlacementPurchasePlan) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *PlacementPurchasePlan) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "back":
if s.AddPlacementState != nil {
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case data == "next":
// TODO: переход к следующему шагу (детали размещения)
b.Edit("🚧 Следующий шаг в разработке", bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_purchase_plan")),
))
case data == "back_to_purchase_plan":
s.Enter(b, bot.EditMessage)
case strings.HasPrefix(data, "remove:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
purchasePlanChannelID := parts[1] // ID записи PurchasePlanChannel
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
return
}
// Удаляем канал из плана закупа
err = b.Backend.RemoveChannelFromPurchasePlan(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
purchasePlanChannelID,
)
if err != nil {
log.Error().Err(err).Str("purchase_plan_channel_id", purchasePlanChannelID).Msg("Failed to remove channel")
b.Edit("❌ Не удалось удалить канал", bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_to_purchase_plan")),
))
return
}
// Обновляем экран
s.Enter(b, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PlacementPurchasePlan) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
username := strings.TrimSpace(u.Message.Text)
// Убираем @ если пользователь его добавил
username = strings.TrimPrefix(username, "@")
if username == "" {
return
}
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
// Отправляем запрос на добавление канала
input := backend.AttachChannelToPurchasePlanInput{
Username: username,
}
_, err = b.Backend.AttachChannelToPurchasePlan(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
input,
)
if err != nil {
log.Error().Err(err).Str("username", username).Msg("Failed to attach channel")
// Проверяем, содержит ли ошибка информацию о том, что канал не найден
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") {
b.SendNew(fmt.Sprintf("❌ Канал @%s не найден в Telegram.\n\nПроверьте правильность username и попробуйте еще раз.", username), bot.Keyboard())
} else {
b.SendNew("❌ Не удалось добавить канал. Попробуйте позже.", bot.Keyboard())
}
return
}
// Обновляем экран со списком каналов
s.Enter(b, bot.NewMessage)
}
func (s *PlacementPurchasePlan) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -68,37 +68,32 @@ func (s *ProjectDetails) formatProjectDetails() string {
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
// Первый ряд: Креативы и Создать закуп // Первый ряд: Креативы и Закупы
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
bot.Button("Создать закуп", fmt.Sprintf("create_placement:%s", s.Project.ID)), bot.Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
)) ))
// Второй ряд: План закупов // Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
buttons = append(buttons, bot.Row(
bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)),
))
// Третий ряд: Тип приглашений (с подтверждением)
var linkTypeButtonText string var linkTypeButtonText string
if s.confirmingLinkTypeChange { if s.confirmingLinkTypeChange {
// Показываем подтверждение // Показываем подтверждение
var newType string var newType string
if s.Project.InviteLinkType == "public" { if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "С одобрением" newType = "По заявке"
} else { } else {
newType = "Открытая" newType = "Свободное"
} }
linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType) linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType)
} else { } else {
// Показываем текущий тип приглашений // Показываем текущий тип вступления
var currentType string var currentType string
if s.Project.InviteLinkType == "public" { if s.Project.PurchaseInviteTypeDefault == "public" {
currentType = "Открытая" currentType = "Свободное"
} else { } else {
currentType = "С одобрением" currentType = "По заявке"
} }
linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType) linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType)
} }
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)), bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
@@ -135,12 +130,13 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
BackState: s, BackState: s,
}, bot.NewMessage) }, bot.NewMessage)
case strings.HasPrefix(data, "create_placement:"): case strings.HasPrefix(data, "purchases:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
// TODO: реализовать создание закупа b.SetState(&Purchases{
b.Edit("🚧 Создать закуп\n\nФункция в разработке", bot.Keyboard( WorkspaceID: s.WorkspaceID,
bot.Row(bot.Button("← Назад", "back_to_project")), ProjectID: s.Project.ID,
)) BackState: s,
}, bot.NewMessage)
case data == "back_to_project": case data == "back_to_project":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
@@ -154,7 +150,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
} else { } else {
// Второе нажатие - меняем тип ссылки // Второе нажатие - меняем тип ссылки
var newType string var newType string
if s.Project.InviteLinkType == "public" { if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "approval" newType = "approval"
} else { } else {
newType = "public" newType = "public"
@@ -163,7 +159,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
log.Info(). log.Info().
Str("workspace_id", s.WorkspaceID). Str("workspace_id", s.WorkspaceID).
Str("project_id", s.Project.ID). Str("project_id", s.Project.ID).
Str("old_type", s.Project.InviteLinkType). Str("old_type", s.Project.PurchaseInviteTypeDefault).
Str("new_type", newType). Str("new_type", newType).
Msg("Updating project invite link type") Msg("Updating project invite link type")

View File

@@ -0,0 +1,170 @@
package screens
import (
"context"
"fmt"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type PurchaseDetails struct {
WorkspaceID string
ProjectID string
PurchaseID string
BackState bot.State
}
func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderDetails(b, mode, jwt)
}
func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) {
purchase, err := b.Backend.GetPurchase(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
s.PurchaseID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchase")
b.SendNew("❌ Не удалось загрузить закуп", bot.Keyboard())
return
}
// Получаем информацию о креативе
creative, err := b.Backend.GetCreative(
context.Background(),
jwt,
s.WorkspaceID,
purchase.CreativeID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creative")
creative = nil
}
text := "<b>📦 Детали закупа</b>\n\n"
// Информация о креативе
if creative != nil {
text += fmt.Sprintf("<b>Креатив:</b> %s\n", creative.Name)
} else {
text += fmt.Sprintf("<b>Креатив ID:</b> %s\n", purchase.CreativeID)
}
// Статус закупа
statusText := "Активный"
if purchase.Status == "archived" {
statusText = "Архивирован"
}
text += fmt.Sprintf("<b>Статус:</b> %s\n\n", statusText)
// Информация о каналах
if len(purchase.Channels) == 0 {
text += "<i>Каналы не добавлены</i>\n"
} else {
text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels))
for i, ch := range purchase.Channels {
// Определяем статус emoji
statusEmoji := "📌"
switch ch.Status {
case "planned":
statusEmoji = "📝"
case "approved":
statusEmoji = "✅"
case "rejected":
statusEmoji = "❌"
case "in_progress":
statusEmoji = "🔄"
case "completed":
statusEmoji = "✔️"
}
// Название канала
channelName := "Без названия"
if ch.Channel.Title != nil {
channelName = *ch.Channel.Title
} else if ch.Channel.Username != nil {
channelName = "@" + *ch.Channel.Username
}
text += fmt.Sprintf("%d. %s <b>%s</b>\n", i+1, statusEmoji, channelName)
// Стоимость
if ch.PlannedCost != nil {
text += fmt.Sprintf(" 💰 Стоимость: %.0f₽\n", *ch.PlannedCost)
}
// Комментарий
if ch.Comment != nil && *ch.Comment != "" {
text += fmt.Sprintf(" 💬 %s\n", *ch.Comment)
}
// Тип invite link
inviteLinkTypeText := "публичная"
if ch.InviteLinkType == "approval" {
inviteLinkTypeText = "с одобрением"
}
text += fmt.Sprintf(" 🔗 Ссылка: %s\n", inviteLinkTypeText)
text += "\n"
}
}
var buttons [][]echotron.InlineKeyboardButton
// Кнопка назад
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *PurchaseDetails) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,158 @@
package screens
import (
"context"
"fmt"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type Purchases struct {
WorkspaceID string
ProjectID string
BackState bot.State
}
func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderPurchases(b, mode, jwt)
}
func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) {
purchases, err := b.Backend.GetPurchases(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchases")
b.SendNew("❌ Не удалось загрузить закупы", bot.Keyboard())
return
}
text := "<b>📦 Закупы</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(purchases) == 0 {
text += `Список закупов пуст.
<i>Закуп — это план размещения рекламы в каналах:</i>
▸ Выбор креатива
▸ Список каналов для размещения
▸ Планируемая стоимость
▸ Статус выполнения
<i>Создайте первый закуп 👇</i>`
} else {
text += "Управление закупами для проекта.\n\n"
text += "<i>Выберите закуп для деталей:</i>\n\n"
for i, purchase := range purchases {
// Определяем статус emoji
statusEmoji := "📌"
switch purchase.Status {
case "active":
statusEmoji = "🟢"
case "archived":
statusEmoji = "📦"
}
// Считаем каналы по статусам
totalChannels := len(purchase.Channels)
completedChannels := 0
for _, ch := range purchase.Channels {
if ch.Status == "completed" {
completedChannels++
}
}
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
statusEmoji,
i+1,
completedChannels,
totalChannels,
)
buttons = append(buttons, bot.Row(
bot.Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)),
))
}
}
// Кнопка создания нового закупа
buttons = append(buttons, bot.Row(
bot.Button(" Создать закуп", "add_purchase"),
))
// Кнопка назад
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *Purchases) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "add_purchase":
b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
BackState: s,
}, bot.EditMessage)
default:
// Проверяем, не нажали ли на конкретный закуп
if len(data) > 9 && data[:9] == "purchase:" {
purchaseID := data[9:]
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchaseID,
BackState: s,
}, bot.NewMessage)
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -0,0 +1,214 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type PurchaseChannelInput struct {
Username string
PlannedCost *float64
Comment *string
}
type SelectChannelsForPurchase struct {
WorkspaceID string
ProjectID string
CreativeID string
Channels []PurchaseChannelInput
BackState bot.State
}
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderChannelSelection(b, mode)
}
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<b> Создание закупа</b>\n\n"
text += "<b>Шаг 2/2: Добавление каналов</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы.
<i>Отправьте username канала (без @)</i>
Например: <code>channel_name</code>
После добавления всех каналов нажмите "Создать закуп"`
} else {
text += fmt.Sprintf("<b>Добавлено каналов: %d</b>\n\n", len(s.Channels))
for i, ch := range s.Channels {
channelText := fmt.Sprintf("%d. @%s", i+1, ch.Username)
if ch.PlannedCost != nil {
channelText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost)
}
text += channelText + "\n"
}
text += "\n<i>Добавьте еще каналы или создайте закуп</i>"
// Кнопка создания закупа (доступна только если есть каналы)
buttons = append(buttons, bot.Row(
bot.Button("✅ Создать закуп", "create"),
))
}
// Кнопки навигации
buttons = append(buttons, bot.Row(
bot.Button("← Назад", "back"),
))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectChannelsForPurchase) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
// Возвращаемся к выбору креатива
b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
BackState: s.BackState,
}, bot.EditMessage)
case "create":
if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")),
))
return
}
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.Edit("❌ Ошибка авторизации", bot.Keyboard())
return
}
// Создаем закуп
s.createPurchase(b, jwt)
case "back_to_select":
s.Enter(b, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
// Преобразуем каналы в формат API
var apiChannels []backend.CreatePurchaseChannelInput
for _, ch := range s.Channels {
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
Username: ch.Username,
PlannedCost: ch.PlannedCost,
Comment: ch.Comment,
})
}
input := backend.CreatePurchaseInput{
CreativeID: s.CreativeID,
Channels: apiChannels,
}
purchase, err := b.Backend.CreatePurchase(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
input,
)
if err != nil {
log.Error().Err(err).Msg("Failed to create purchase")
b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")),
))
return
}
// Показываем успешное создание
successText := fmt.Sprintf(`✅ <b>Закуп создан!</b>
<b>ID:</b> %s
<b>Креатив:</b> %s
<b>Каналов:</b> %d
Закуп создан и готов к работе.`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
b.Edit(successText, bot.Keyboard(
bot.Row(bot.Button("✅ Готово", "done")),
))
}
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
username := strings.TrimSpace(u.Message.Text)
// Убираем @ если пользователь его добавил
username = strings.TrimPrefix(username, "@")
if username == "" {
return
}
// Проверяем, не добавлен ли уже этот канал
for _, ch := range s.Channels {
if ch.Username == username {
b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard())
return
}
}
// Добавляем канал в список
s.Channels = append(s.Channels, PurchaseChannelInput{
Username: username,
})
// Обновляем экран
s.Enter(b, bot.NewMessage)
}
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
// Обрабатываем callback после успешного создания
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
// Возвращаемся к списку закупов
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
}

View File

@@ -1,188 +0,0 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
const creativesForPlacementPerPage = 6
type SelectCreativeForPlacement struct {
WorkspaceID string
ProjectID string
CurrentPage int
AddPlacementState *AddPlacement
}
func (s *SelectCreativeForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderCreatives(b, mode, jwt)
}
func (s *SelectCreativeForPlacement) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) {
page, err := b.Backend.GetCreatives(
context.Background(),
jwt,
s.WorkspaceID,
&s.ProjectID,
false, // includeArchived
s.CurrentPage+1,
creativesForPlacementPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
return
}
text := "<b>Выбор креатива</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += "У выбранного проекта пока нет креативов.\n\nСоздайте креатив через меню проекта."
} else {
text += fmt.Sprintf("Выберите креатив (стр. %d из %d):", s.CurrentPage+1, page.Pages)
// Креативы по 2 в ряду
for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton
// Первый креатив в ряду
creative := page.Items[i]
row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID)))
// Второй креатив в ряду (если есть)
if i+1 < len(page.Items) {
creative = page.Items[i+1]
row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID)))
} else {
row = append(row, bot.Button(" ", "empty"))
}
buttons = append(buttons, row)
}
// Кнопки пагинации
var navRow []echotron.InlineKeyboardButton
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
navRow = append(navRow, pageButton)
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
buttons = append(buttons, navRow)
}
// Опция пропустить выбор креатива (креатив опционален)
buttons = append(buttons, bot.Row(bot.Button("Без креатива", "skip")))
// Кнопка возврата
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectCreativeForPlacement) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *SelectCreativeForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "page_info", data == "empty":
return
case data == "skip":
// Пропускаем выбор креатива
if s.AddPlacementState != nil {
s.AddPlacementState.SelectedCreative = nil
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case data == "back":
if s.AddPlacementState != nil {
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case strings.HasPrefix(data, "select:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
creativeID := parts[1]
// Получаем JWT
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
return
}
// Загружаем полные данные креатива
creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, creativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", creativeID).Msg("Failed to get creative")
return
}
// Сохраняем выбранный креатив в состояние AddPlacement
if s.AddPlacementState != nil {
s.AddPlacementState.SelectedCreative = creative
b.SetState(s.AddPlacementState, bot.EditMessage)
}
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectCreativeForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectCreativeForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -1,164 +0,0 @@
package screens
import (
"context"
"fmt"
"strings"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
const projectsForPlacementPerPage = 6
type SelectProjectForPlacement struct {
WorkspaceID string
CurrentPage int
AddPlacementState *AddPlacement
}
func (s *SelectProjectForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
return
}
s.renderProjects(b, mode, jwt)
}
func (s *SelectProjectForPlacement) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsForPlacementPerPage)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard())
return
}
text := "<b>Выбор проекта</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
text += "У вас пока нет проектов"
} else {
text += fmt.Sprintf("Выберите проект (стр. %d из %d):", s.CurrentPage+1, page.Pages)
// Проекты по 2 в ряду
for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton
// Первый проект в ряду
project := page.Items[i]
row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID)))
// Второй проект в ряду (если есть)
if i+1 < len(page.Items) {
project = page.Items[i+1]
row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID)))
} else {
row = append(row, bot.Button(" ", "empty"))
}
buttons = append(buttons, row)
}
// Кнопки пагинации
var navRow []echotron.InlineKeyboardButton
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
navRow = append(navRow, pageButton)
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
buttons = append(buttons, navRow)
}
// Кнопка возврата
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectProjectForPlacement) ensureJWT(b *bot.Bot) (string, error) {
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
b.Session.JWT = jwt
return jwt, nil
}
func (s *SelectProjectForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "page_info", data == "empty":
return
case data == "back":
if s.AddPlacementState != nil {
b.SetState(s.AddPlacementState, bot.EditMessage)
}
case strings.HasPrefix(data, "select:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
projectID := parts[1]
// Загружаем полные данные проекта
project, err := b.Backend.GetProject(context.Background(), s.WorkspaceID, projectID)
if err != nil {
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
return
}
// Сохраняем выбранный проект в состояние AddPlacement
if s.AddPlacementState != nil {
s.AddPlacementState.SelectedProject = project
// Сбрасываем креатив при смене проекта
s.AddPlacementState.SelectedCreative = nil
b.SetState(s.AddPlacementState, bot.EditMessage)
}
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectProjectForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectProjectForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }