diff --git a/Makefile b/Makefile
index d3626b8..5662db2 100644
--- a/Makefile
+++ b/Makefile
@@ -4,6 +4,9 @@ test:
env:
cp .env.example .env
+run:
+ uv run uvicorn src.main:app
+
up:
GIT_COMMIT=$$(git rev-parse --short HEAD) docker compose up --build -d --force-recreate
docker compose logs -f
diff --git a/migrations/models/0_20251223102745_init.py b/migrations/models/0_20251223102745_init.py
deleted file mode 100644
index e1e50b3..0000000
--- a/migrations/models/0_20251223102745_init.py
+++ /dev/null
@@ -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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'
-)
diff --git a/migrations/models/0_20260102162851_init.py b/migrations/models/0_20260102162851_init.py
new file mode 100644
index 0000000..0cc8634
--- /dev/null
+++ b/migrations/models/0_20260102162851_init.py
@@ -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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'
+)
diff --git a/migrations/models/1_20251223103601_add_check_constraint.py b/migrations/models/1_20251223103601_add_check_constraint.py
deleted file mode 100644
index d143a3c..0000000
--- a/migrations/models/1_20251223103601_add_check_constraint.py
+++ /dev/null
@@ -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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'
-)
diff --git a/migrations/models/1_20260102184542_add_check_constraint.py b/migrations/models/1_20260102184542_add_check_constraint.py
new file mode 100644
index 0000000..f26ab76
--- /dev/null
+++ b/migrations/models/1_20260102184542_add_check_constraint.py
@@ -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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'
+)
diff --git a/migrations/models/2_20251230191746_move_invite_link_type_to_project.py b/migrations/models/2_20251230191746_move_invite_link_type_to_project.py
deleted file mode 100644
index 40a6cd4..0000000
--- a/migrations/models/2_20251230191746_move_invite_link_type_to_project.py
+++ /dev/null
@@ -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 = (
- "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"
- "CzE0"
-)
diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py
index 31da633..fb1761e 100644
--- a/src/adapter/postgres.py
+++ b/src/adapter/postgres.py
@@ -257,37 +257,60 @@ class Postgres(DatabaseBase, Database):
return await query.order_by('created_at').all()
- async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None:
- return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE)
+ async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
+ 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:
- return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id)
+ async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
+ return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id)
- async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None:
- await plan.save()
-
- async def get_purchase_plan_channels(self, plan_id: uuid.UUID) -> list[domain.PurchasePlanChannel]:
- return await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id).prefetch_related('channel').all()
-
- async def get_purchase_plan_channel(
- self, plan_channel_id: uuid.UUID, plan_id: uuid.UUID
- ) -> domain.PurchasePlanChannel | None:
+ async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]:
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')
.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,
- plan_id: uuid.UUID,
+ purchase_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,
comment: str | None = None,
- ) -> domain.PurchasePlanChannel:
- defaults: dict[str, object | None] = {}
+ ) -> domain.PurchaseChannel:
+ defaults: dict[str, object | None] = {
+ 'invite_link': invite_link,
+ 'invite_link_type': invite_link_type,
+ }
if status is not None:
defaults['status'] = status
if planned_cost is not None:
@@ -295,24 +318,24 @@ class Postgres(DatabaseBase, Database):
if comment is not None:
defaults['comment'] = comment
- plan_channel, created = await domain.PurchasePlanChannel.get_or_create(
- purchase_plan_id=plan_id,
+ purchase_channel, created = await domain.PurchaseChannel.get_or_create(
+ purchase_id=purchase_id,
channel_id=channel_id,
defaults=defaults,
)
if not created and defaults:
for field, value in defaults.items():
- setattr(plan_channel, field, value)
- await plan_channel.save()
+ setattr(purchase_channel, field, value)
+ await purchase_channel.save()
- return plan_channel
+ return purchase_channel
- async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None:
- await plan_channel.save()
+ async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None:
+ await purchase_channel.save()
- async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None:
- await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id, channel_id=channel_id).delete()
+ async def remove_channel_from_purchase(self, purchase_id: uuid.UUID, channel_id: uuid.UUID) -> None:
+ 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:
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
- 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
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int:
return await domain.Placement.filter(creative_id=creative_id).count()
diff --git a/src/controller/http_v1/__init__.py b/src/controller/http_v1/__init__.py
index aa5fa1e..e506ebf 100644
--- a/src/controller/http_v1/__init__.py
+++ b/src/controller/http_v1/__init__.py
@@ -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.placements import placements_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.workspace_invites import workspace_invites_router, workspace_invites_global_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(channels_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(placements_router)
api_v1_router.include_router(views_router)
diff --git a/src/controller/http_v1/placements.py b/src/controller/http_v1/placements.py
index b014838..1b66f82 100644
--- a/src/controller/http_v1/placements.py
+++ b/src/controller/http_v1/placements.py
@@ -48,40 +48,4 @@ async def get_placement(
return await deps.get_usecase().get_placement(input=input)
-@placements_router.post('')
-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)
+# Placement creation/updates are handled by worker; API exposes only read endpoints.
diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py
index c72f15a..f81b82a 100644
--- a/src/controller/http_v1/projects.py
+++ b/src/controller/http_v1/projects.py
@@ -12,9 +12,8 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[
@projects_router.get('')
-async def get_workspace_projects(
- workspace_id: uuid.UUID,
- current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
+async def get_projects(
+ workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)]
) -> Page[dto.ProjectOutput]:
input = dto.GetWorkspaceProjectsInput(
user_id=current_user.user_id,
@@ -32,18 +31,9 @@ async def update_project_invite_link_type(
input: dto.UpdateProjectInviteLinkTypeInput,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> 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,
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,
)
-
- 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,
- )
diff --git a/src/controller/http_v1/purchase_plans.py b/src/controller/http_v1/purchase_plans.py
deleted file mode 100644
index 84bb4ac..0000000
--- a/src/controller/http_v1/purchase_plans.py
+++ /dev/null
@@ -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,
- )
diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py
new file mode 100644
index 0000000..6271908
--- /dev/null
+++ b/src/controller/http_v1/purchases.py
@@ -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)
diff --git a/src/domain/__init__.py b/src/domain/__init__.py
index 984583c..44ff8d3 100644
--- a/src/domain/__init__.py
+++ b/src/domain/__init__.py
@@ -15,18 +15,16 @@ __all__ = (
'Channel',
'Project',
'ProjectStatus',
- 'PurchasePlan',
- 'PurchasePlanChannel',
- 'PurchasePlanStatus',
- 'PurchasePlanChannelStatus',
+ 'Purchase',
+ 'PurchaseChannel',
+ 'PurchaseStatus',
+ 'PurchaseChannelStatus',
'Creative',
'replace_invite_link_with_tag',
'Placement',
'Post',
'Subscription',
'PostViewsHistory',
- 'TelegramState',
- 'TelegramStateEnum',
'ChannelNotFound',
'ProjectNotFound',
'CreativeStatus',
@@ -45,8 +43,8 @@ __all__ = (
'LoginTokenExpired',
'LoginTokenAlreadyUsed',
'ProjectNotFound',
- 'PurchasePlanNotFound',
- 'PurchasePlanChannelNotFound',
+ 'PurchaseNotFound',
+ 'PurchaseChannelNotFound',
'ChannelNotFound',
'ChannelAlreadyExists',
'ChannelNoAdminRights',
@@ -74,8 +72,8 @@ from .error import (
LoginTokenNotFound,
PlacementNotFound,
ProjectNotFound,
- PurchasePlanChannelNotFound,
- PurchasePlanNotFound,
+ PurchaseChannelNotFound,
+ PurchaseNotFound,
TelegramChannelNotFound,
UserByUsernameNotFound,
UserNotFound,
@@ -89,15 +87,15 @@ from .login_token import LoginToken
from .placement import Placement, PlacementStatus, PostViewsAvailability
from .post import Post
from .post_views_history import PostViewsHistory
-from .project import InviteLinkType, Project, ProjectStatus
-from .purchase_plan import (
- PurchasePlan,
- PurchasePlanChannel,
- PurchasePlanChannelStatus,
- PurchasePlanStatus,
+from .project import Project, ProjectStatus
+from .purchase import (
+ InviteLinkType,
+ Purchase,
+ PurchaseChannel,
+ PurchaseChannelStatus,
+ PurchaseStatus,
)
from .subscription import Subscription, SubscriptionStatus
-from .telegram_state import TelegramState, TelegramStateEnum
from .user import User
from .workspace import (
PermissionKey,
diff --git a/src/domain/error.py b/src/domain/error.py
index fd8b529..897658a 100644
--- a/src/domain/error.py
+++ b/src/domain/error.py
@@ -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')
-def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException:
- if plan_id is None:
- return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found')
- return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found')
+def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
+ if purchase_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase 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:
- if plan_channel_id is None:
- return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found')
- return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found')
+def PurchaseChannelNotFound(purchase_channel_id: uuid.UUID | None = None) -> HTTPException:
+ if purchase_channel_id is None:
+ return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found')
+ return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
diff --git a/src/domain/placement.py b/src/domain/placement.py
index 73e211a..d388370 100644
--- a/src/domain/placement.py
+++ b/src/domain/placement.py
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from .creative import Creative
from .post import Post
from .project import Project
+ from .purchase import Purchase, PurchaseChannel
from .workspace import Workspace
@@ -27,35 +28,44 @@ class PostViewsAvailability(str, enum.Enum):
class Placement(TimestampedModel):
- placement_date = fields.DatetimeField()
+ wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512)
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(
'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(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
)
- workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
- 'models.Workspace', 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
)
+ 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(
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
)
if TYPE_CHECKING:
- project_id: UUID
- placement_channel_id: UUID
- creative_id: UUID
workspace_id: UUID
+ project_id: UUID
+ creative_id: UUID
+ placement_channel_id: UUID
+ purchase_id: UUID | None
post_id: UUID | None
+ purchase_channel_id: UUID | None
class Meta:
table = 'placement'
diff --git a/src/domain/project.py b/src/domain/project.py
index 38d0b16..b4bb7ea 100644
--- a/src/domain/project.py
+++ b/src/domain/project.py
@@ -4,6 +4,7 @@ from uuid import UUID
from tortoise import fields
+from .purchase import InviteLinkType
from .base import TimestampedModel
if TYPE_CHECKING:
@@ -17,25 +18,21 @@ class ProjectStatus(str, enum.Enum):
ARCHIVED = 'archived'
-class InviteLinkType(str, enum.Enum):
- PUBLIC = 'public' # открытая ссылка
- APPROVAL = 'approval' # с одобрением ботом
-
-
class Project(TimestampedModel):
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(
'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:
- workspace_id: UUID
channel_id: UUID
+ workspace_id: UUID
class Meta:
table = 'project'
diff --git a/src/domain/purchase.py b/src/domain/purchase.py
new file mode 100644
index 0000000..4c77bc0
--- /dev/null
+++ b/src/domain/purchase.py
@@ -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'),)
diff --git a/src/domain/purchase_plan.py b/src/domain/purchase_plan.py
deleted file mode 100644
index 5ed4c64..0000000
--- a/src/domain/purchase_plan.py
+++ /dev/null
@@ -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'),)
diff --git a/src/domain/telegram_state.py b/src/domain/telegram_state.py
deleted file mode 100644
index 92d0497..0000000
--- a/src/domain/telegram_state.py
+++ /dev/null
@@ -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'
diff --git a/src/dto/__init__.py b/src/dto/__init__.py
index ce9bf3a..755a0d7 100644
--- a/src/dto/__init__.py
+++ b/src/dto/__init__.py
@@ -9,18 +9,19 @@ __all__ = (
'ProjectOutput',
'ValidateLoginTokenInput',
'ValidateLoginTokenOutput',
- 'TelegramLoginInput',
'ChannelBotPermissions',
'ChannelOutput',
'GetChannelInput',
'GetChannelsInput',
'GetChannelsOutput',
'AttachChannelToWorkspaceInput',
- 'PurchasePlanChannelOutput',
- 'GetPurchasePlanChannelsInput',
- 'GetPurchasePlanChannelsOutput',
- 'AttachChannelToPurchasePlanInput',
- 'UpdatePurchasePlanChannelInput',
+ 'PurchaseOutput',
+ 'PurchaseChannelOutput',
+ 'CreatePurchaseInput',
+ 'CreatePurchaseChannelInput',
+ 'GetPurchasesInput',
+ 'GetPurchasesOutput',
+ 'GetPurchaseInput',
'CreativeOutput',
'GetCreativesInput',
'GetCreativesOutput',
@@ -32,9 +33,6 @@ __all__ = (
'GetPlacementsInput',
'GetPlacementsOutput',
'GetPlacementInput',
- 'CreatePlacementInput',
- 'UpdatePlacementInput',
- 'DeletePlacementInput',
'PostViewsHistoryOutput',
'GetViewsHistoryInput',
'GetViewsHistoryOutput',
@@ -104,15 +102,7 @@ from .creative import (
GetCreativesOutput,
UpdateCreativeInput,
)
-from .placement import (
- CreatePlacementInput,
- DeletePlacementInput,
- GetPlacementInput,
- GetPlacementsInput,
- GetPlacementsOutput,
- PlacementOutput,
- UpdatePlacementInput,
-)
+from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput
from .project import (
ChannelBotPermissions,
ConnectProjectInput,
@@ -124,14 +114,15 @@ from .project import (
UpdateProjectInviteLinkTypeInput,
UpdateProjectPermissionsInput,
)
-from .purchase_plan import (
- AttachChannelToPurchasePlanInput,
- GetPurchasePlanChannelsInput,
- GetPurchasePlanChannelsOutput,
- PurchasePlanChannelOutput,
- UpdatePurchasePlanChannelInput,
+from .purchase import (
+ CreatePurchaseChannelInput,
+ CreatePurchaseInput,
+ GetPurchaseInput,
+ GetPurchasesInput,
+ GetPurchasesOutput,
+ PurchaseChannelOutput,
+ PurchaseOutput,
)
-from .telegram_login import TelegramLoginInput
from .user import UserOutput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
from .views import (
@@ -162,4 +153,4 @@ from .workspace import (
class CreateLoginTokenRequest(BaseModel):
telegram_id: int
- username: str | None
\ No newline at end of file
+ username: str | None
diff --git a/src/dto/placement.py b/src/dto/placement.py
index 4773657..7ea0250 100644
--- a/src/dto/placement.py
+++ b/src/dto/placement.py
@@ -18,10 +18,12 @@ class PlacementOutput(pydantic.BaseModel):
cost: float | None
comment: str | None
ad_post_url: str | None
- invite_link_type: domain.InviteLinkType
+ invite_link_type: domain.purchase.InviteLinkType
invite_link: str
status: domain.PlacementStatus
subscriptions_count: int
+ purchase_id: uuid.UUID | None = None
+ purchase_channel_id: uuid.UUID | None = None
created_at: datetime.datetime
diff --git a/src/dto/project.py b/src/dto/project.py
index f62b7b5..02d3cea 100644
--- a/src/dto/project.py
+++ b/src/dto/project.py
@@ -32,11 +32,11 @@ class ProjectOutput(pydantic.BaseModel):
title: str
username: str | None
status: ProjectStatus
- invite_link_type: domain.InviteLinkType
+ purchase_invite_type_default: domain.purchase.InviteLinkType
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
- invite_link_type: domain.InviteLinkType
+ purchase_invite_type_default: domain.purchase.InviteLinkType
class GetWorkspaceProjectsInput(pydantic.BaseModel):
diff --git a/src/dto/purchase.py b/src/dto/purchase.py
new file mode 100644
index 0000000..4299c1a
--- /dev/null
+++ b/src/dto/purchase.py
@@ -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
diff --git a/src/dto/purchase_plan.py b/src/dto/purchase_plan.py
deleted file mode 100644
index bcaffd6..0000000
--- a/src/dto/purchase_plan.py
+++ /dev/null
@@ -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
diff --git a/src/dto/telegram_login.py b/src/dto/telegram_login.py
deleted file mode 100644
index bf71060..0000000
--- a/src/dto/telegram_login.py
+++ /dev/null
@@ -1,7 +0,0 @@
-import pydantic
-
-
-class TelegramLoginInput(pydantic.BaseModel):
- telegram_id: int
- username: str | None
- chat_id: int
diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py
index 3489596..3a4bd4b 100644
--- a/src/usecase/__init__.py
+++ b/src/usecase/__init__.py
@@ -3,7 +3,6 @@ import typing
from dataclasses import dataclass
from uuid import UUID
-
if typing.TYPE_CHECKING:
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_placements_analytics import get_placements_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.get_jwt_by_telegram_id import get_jwt_by_telegram_id
from .auth.get_me import get_me
from .auth.validate_login_token import validate_login_token
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_creatives import get_creatives
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.get_placement import get_placement
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.get_project import get_project
from .project.get_workspace_projects import get_workspace_projects
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_permissions import update_project_permissions
-from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan
-from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels
-from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel
-from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_channel
+from .purchase.create_purchase import create_purchase
+from .purchase.get_purchase import get_purchase
+from .purchase.get_purchases import get_purchases
from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription
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
) -> 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(
- self, plan_channel_id: UUID, plan_id: UUID
- ) -> domain.PurchasePlanChannel | None: ...
+ async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ...
- 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,
- plan_id: UUID,
+ purchase_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,
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: ...
@@ -197,10 +198,6 @@ class Database(typing.Protocol):
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(
self,
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_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):
async def send_message(self, text: str, chat_id: int) -> None: ...
@@ -362,14 +351,14 @@ class Usecase:
return workspace
- async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan:
- plan = await self.database.get_active_purchase_plan(project.id)
- if plan:
- return plan
+ async def ensure_active_purchase(self, project: domain.Project, creative: domain.Creative) -> domain.Purchase:
+ purchase = await self.database.get_active_purchase(project.id, creative.id)
+ if purchase:
+ return purchase
- plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id)
- await self.database.create_purchase_plan(plan)
- return plan
+ purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id)
+ await self.database.create_purchase(purchase)
+ return purchase
validate_login_token = validate_login_token
create_telegram_login_token = create_telegram_login_token
@@ -384,10 +373,9 @@ class Usecase:
get_channels = get_channels
get_channel = get_channel
attach_channel_to_workspace = attach_channel_to_workspace
- attach_channel_to_purchase_plan = attach_channel_to_purchase_plan
- get_purchase_plan_channels = get_purchase_plan_channels
- update_purchase_plan_channel = update_purchase_plan_channel
- remove_purchase_plan_channel = remove_purchase_plan_channel
+ create_purchase = create_purchase
+ get_purchases = get_purchases
+ get_purchase = get_purchase
get_creatives = get_creatives
get_creative = get_creative
create_creative = create_creative
@@ -395,9 +383,6 @@ class Usecase:
delete_creative = delete_creative
get_placements = get_placements
get_placement = get_placement
- create_placement = create_placement
- update_placement = update_placement
- delete_placement = delete_placement
fetch_placement_post_cycle = fetch_placement_post_cycle
handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription
diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py
index 37e6350..f85afba 100644
--- a/src/usecase/analytics/get_channel_analytics.py
+++ b/src/usecase/analytics/get_channel_analytics.py
@@ -25,9 +25,8 @@ async def get_channel_analytics(
if not project:
raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
- plan = await self.ensure_active_purchase_plan(project)
- plan_channels = await self.database.get_purchase_plan_channels(plan.id)
- channels = [pc.channel for pc in plan_channels]
+ purchase_channels = await self.database.get_purchase_channels_by_project(project.id)
+ channels = [pc.channel for pc in purchase_channels]
allowed_project_filter = None
else:
allowed_project_filter = allowed_project_ids
diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py
index d40bfa4..97021f6 100644
--- a/src/usecase/analytics/get_placements_analytics.py
+++ b/src/usecase/analytics/get_placements_analytics.py
@@ -61,7 +61,7 @@ async def get_placements_analytics(
placement_channel_title=p.placement_channel.title,
creative_id=p.creative_id,
creative_name=p.creative.name,
- placement_date=p.placement_date,
+ placement_date=p.wanted_placement_date,
cost=p.cost,
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,
diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py
index 5feec20..689fffe 100644
--- a/src/usecase/analytics/get_spending_analytics.py
+++ b/src/usecase/analytics/get_spending_analytics.py
@@ -56,8 +56,8 @@ async def get_spending_analytics(
filtered = [
p
for p in placements
- if (not input.date_from or p.placement_date >= input.date_from)
- and (not input.date_to or p.placement_date <= input.date_to)
+ if (not input.date_from or p.wanted_placement_date >= input.date_from)
+ and (not input.date_to or p.wanted_placement_date <= input.date_to)
]
total_cost = 0.0
@@ -82,7 +82,7 @@ async def get_spending_analytics(
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
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]
if p.cost is not None:
diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py
index 398c3d1..0e67b8a 100644
--- a/src/usecase/channel/attach_channel_to_workspace.py
+++ b/src/usecase/channel/attach_channel_to_workspace.py
@@ -41,12 +41,10 @@ async def attach_channel_to_workspace(
await self.database.create_project(project)
log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
- await self.ensure_active_purchase_plan(project)
-
return dto.ProjectOutput(
id=project.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
status=project.status,
- )
\ No newline at end of file
+ )
diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py
index f2fa7ca..74ef62f 100644
--- a/src/usecase/placement/create_placement.py
+++ b/src/usecase/placement/create_placement.py
@@ -2,6 +2,7 @@ import logging
import uuid
from typing import TYPE_CHECKING
+import domain.purchase
from src import domain, dto
if TYPE_CHECKING:
@@ -35,7 +36,7 @@ async def create_placement(
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:
raise domain.ChannelNotFound(project.channel.id)
@@ -64,11 +65,11 @@ async def create_placement(
placement_channel_title=placement_channel.title,
creative_id=placement.creative_id,
creative_name=creative.name,
- placement_date=placement.placement_date,
+ placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
ad_post_url=None,
- invite_link_type=project.invite_link_type,
+ invite_link_type=project.purchase_invite_type_default,
invite_link=placement.invite_link,
status=placement.status,
subscriptions_count=0,
diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py
index 0e0e02b..0a3a4c8 100644
--- a/src/usecase/placement/fetch_placement_post_cycle.py
+++ b/src/usecase/placement/fetch_placement_post_cycle.py
@@ -12,52 +12,56 @@ log = logging.getLogger(__name__)
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
log.debug('Starting fetch_placement_post_cycle')
- placements_without_post = (
- await domain.Placement.filter(
- post_id__isnull=True,
- )
- .prefetch_related('placement_channel')
+ purchase_channels = (
+ await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE)
+ .prefetch_related('channel', 'purchase')
.all()
)
- if not placements_without_post:
- log.debug('No placements without posts found')
+ if not purchase_channels:
+ log.debug('No active purchase channels found')
return
- log.info(f'Found {len(placements_without_post)} placements without posts')
+ created_count = 0
- updated_count = 0
-
- for placement in placements_without_post:
- 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}')
+ for purchase_channel in purchase_channels:
+ if purchase_channel.channel is None or purchase_channel.purchase is None:
+ log.warning('Purchase channel %s missing channel or purchase, skipping', purchase_channel.id)
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)
diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py
index cf6059b..6a2499d 100644
--- a/src/usecase/placement/get_placement.py
+++ b/src/usecase/placement/get_placement.py
@@ -33,11 +33,11 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
- placement_date=placement.placement_date,
+ placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
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,
status=placement.status,
subscriptions_count=subscriptions_count,
diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py
index ab9c107..7a9c931 100644
--- a/src/usecase/placement/get_placements.py
+++ b/src/usecase/placement/get_placements.py
@@ -42,11 +42,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
- placement_date=placement.placement_date,
+ placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
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,
status=placement.status,
subscriptions_count=subscriptions_counts.get(placement.id, 0),
diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py
index 2b974ad..766bb41 100644
--- a/src/usecase/placement/update_placement.py
+++ b/src/usecase/placement/update_placement.py
@@ -27,7 +27,7 @@ async def update_placement(
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
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:
placement.cost = input.cost
if input.comment is not None:
@@ -49,11 +49,11 @@ async def update_placement(
placement_channel_title=placement.placement_channel.title,
creative_id=placement.creative_id,
creative_name=placement.creative.name,
- placement_date=placement.placement_date,
+ placement_date=placement.wanted_placement_date,
cost=placement.cost,
comment=placement.comment,
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,
status=placement.status,
subscriptions_count=subscriptions_count,
diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py
index 9f207bb..8e5eed6 100644
--- a/src/usecase/project/get_project.py
+++ b/src/usecase/project/get_project.py
@@ -20,5 +20,5 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec
title=project.channel.title,
username=project.channel.username,
status=project.status,
- invite_link_type=project.invite_link_type,
+ purchase_invite_type_default=project.purchase_invite_type_default,
)
diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py
index f3f0be1..43aebd7 100644
--- a/src/usecase/project/get_workspace_projects.py
+++ b/src/usecase/project/get_workspace_projects.py
@@ -6,9 +6,7 @@ if TYPE_CHECKING:
from .. import Usecase
-async def get_workspace_projects(
- self: 'Usecase', input: dto.GetWorkspaceProjectsInput
-) -> list[dto.ProjectOutput]:
+async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]:
context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
)
@@ -24,7 +22,7 @@ async def get_workspace_projects(
title=project.channel.title,
username=project.channel.username,
status=project.status,
- invite_link_type=project.invite_link_type,
+ purchase_invite_type_default=project.purchase_invite_type_default,
)
for project in projects
]
diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py
index 94e4cfa..917bd5d 100644
--- a/src/usecase/project/tg_add_project.py
+++ b/src/usecase/project/tg_add_project.py
@@ -104,8 +104,6 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
)
await self.database.create_project(project)
- await self.ensure_active_purchase_plan(project)
-
log.info(
'Project for channel %s connected/updated successfully in workspace %s by user %s',
input.telegram_id,
diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py
index fd0a718..d2467a4 100644
--- a/src/usecase/project/update_project_invite_link_type.py
+++ b/src/usecase/project/update_project_invite_link_type.py
@@ -2,7 +2,7 @@ import logging
import uuid
from typing import TYPE_CHECKING
-from src import domain
+from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
@@ -14,9 +14,9 @@ async def update_project_invite_link_type(
self: 'Usecase',
workspace_id: uuid.UUID,
project_id: uuid.UUID,
- invite_link_type: domain.InviteLinkType,
+ purchase_invite_type_default: domain.purchase.InviteLinkType,
user_id: uuid.UUID,
-) -> domain.Project:
+) -> dto.ProjectOutput:
await self.ensure_workspace_permission(
workspace_id,
user_id,
@@ -24,19 +24,19 @@ async def update_project_invite_link_type(
for_project_id=project_id,
)
- async with self.database.transaction():
- project = await self.database.get_project(workspace_id, project_id=project_id)
- if not project:
- raise domain.ProjectNotFound(project_id)
+ project = await self.database.get_project(workspace_id, project_id=project_id)
+ if not project:
+ raise domain.ProjectNotFound(project_id)
- project.invite_link_type = invite_link_type
- await project.save()
+ project.purchase_invite_type_default = purchase_invite_type_default
- log.info(
- 'Project %s invite_link_type updated to %s by user %s',
- project_id,
- invite_link_type,
- user_id,
- )
+ await self.database.update_project(project)
- 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,
+ )
diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py
new file mode 100644
index 0000000..0793c50
--- /dev/null
+++ b/src/usecase/purchase/create_purchase.py
@@ -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)
diff --git a/src/usecase/purchase/get_purchase.py b/src/usecase/purchase/get_purchase.py
new file mode 100644
index 0000000..da67ea2
--- /dev/null
+++ b/src/usecase/purchase/get_purchase.py
@@ -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)
diff --git a/src/usecase/purchase/get_purchases.py b/src/usecase/purchase/get_purchases.py
new file mode 100644
index 0000000..24271f2
--- /dev/null
+++ b/src/usecase/purchase/get_purchases.py
@@ -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
diff --git a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py
deleted file mode 100644
index d6be749..0000000
--- a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py
+++ /dev/null
@@ -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,
- ),
- )
diff --git a/src/usecase/purchase_plan/get_purchase_plan_channels.py b/src/usecase/purchase_plan/get_purchase_plan_channels.py
deleted file mode 100644
index 4b7587b..0000000
--- a/src/usecase/purchase_plan/get_purchase_plan_channels.py
+++ /dev/null
@@ -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
- ]
diff --git a/src/usecase/purchase_plan/remove_purchase_plan_channel.py b/src/usecase/purchase_plan/remove_purchase_plan_channel.py
deleted file mode 100644
index f861ea0..0000000
--- a/src/usecase/purchase_plan/remove_purchase_plan_channel.py
+++ /dev/null
@@ -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)
diff --git a/src/usecase/purchase_plan/update_purchase_plan_channel.py b/src/usecase/purchase_plan/update_purchase_plan_channel.py
deleted file mode 100644
index fab9f3f..0000000
--- a/src/usecase/purchase_plan/update_purchase_plan_channel.py
+++ /dev/null
@@ -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,
- ),
- )
diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go
index 790a7aa..2e79bcb 100644
--- a/telegram_bot/adapter/backend/backend_client.go
+++ b/telegram_bot/adapter/backend/backend_client.go
@@ -141,12 +141,12 @@ type Workspace struct {
}
type Project struct {
- ID string `json:"id"`
- TelegramID int64 `json:"telegram_id"`
- Title string `json:"title"`
- Username *string `json:"username"`
- Status string `json:"status"`
- InviteLinkType string `json:"invite_link_type"` // "public" или "approval"
+ ID string `json:"id"`
+ TelegramID int64 `json:"telegram_id"`
+ Title string `json:"title"`
+ Username *string `json:"username"`
+ Status string `json:"status"`
+ PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
}
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)
payload := map[string]string{
- "invite_link_type": inviteLinkType,
+ "purchase_invite_type_default": inviteLinkType,
}
var project Project
@@ -306,13 +306,14 @@ func (c *Client) AcceptWorkspaceInvite(
// ============================================================================
type Creative struct {
- ID string `json:"id"`
- ProjectID string `json:"project_id"`
- Name string `json:"name"`
- Text string `json:"text"`
- IsArchived bool `json:"is_archived"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ ProjectID string `json:"project_id"`
+ ProjectChannelTitle string `json:"project_channel_title"`
+ CreatedAt string `json:"created_at"`
+ Status string `json:"status"`
+ PlacementsCount int `json:"placements_count"`
}
type CreativesPage struct {
@@ -406,9 +407,9 @@ func (c *Client) CreateCreative(
}
type UpdateCreativeInput struct {
- Name *string `json:"name,omitempty"`
- Text *string `json:"text,omitempty"`
- IsArchived *bool `json:"is_archived,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Text *string `json:"text,omitempty"`
+ Status *string `json:"status,omitempty"`
}
func (c *Client) UpdateCreative(
@@ -456,17 +457,24 @@ func (c *Client) DeleteCreative(
// ============================================================================
type Placement struct {
- ID string `json:"id"`
- ProjectID string `json:"project_id"`
- PlacementChannelID string `json:"placement_channel_id"`
- CreativeID *string `json:"creative_id"`
- Name string `json:"name"`
- Price *int `json:"price"`
- Status string `json:"status"`
- InviteLink *string `json:"invite_link"`
- IsArchived bool `json:"is_archived"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
+ ID string `json:"id"`
+ ProjectID string `json:"project_id"`
+ ProjectChannelTitle *string `json:"project_channel_title"`
+ PlacementChannelID string `json:"placement_channel_id"`
+ PlacementChannelTitle *string `json:"placement_channel_title"`
+ CreativeID string `json:"creative_id"`
+ CreativeName string `json:"creative_name"`
+ PlacementDate string `json:"placement_date"`
+ Cost *float64 `json:"cost"`
+ Comment *string `json:"comment"`
+ 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 {
@@ -537,84 +545,10 @@ func (c *Client) GetPlacement(
return &placement, err
}
-type CreatePlacementInput struct {
- 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),
- )
-}
+// Note: Placement creation/updates are handled by worker; API exposes only read endpoints.
// ============================================================================
-// Purchase Plan
+// Purchases (Закупы)
// ============================================================================
type Channel struct {
@@ -624,24 +558,67 @@ type Channel struct {
Username *string `json:"username"`
}
-type PurchasePlanChannel struct {
- ID string `json:"id"`
- Channel Channel `json:"channel"`
- Status string `json:"status"`
- PlannedCost *float64 `json:"planned_cost"`
- Comment *string `json:"comment"`
+type Purchase struct {
+ ID string `json:"id"`
+ Status string `json:"status"`
+ CreativeID string `json:"creative_id"`
+ Channels []PurchaseChannelOut `json:"channels"`
}
-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,
jwt string,
workspaceID string,
projectID string,
-) ([]PurchasePlanChannel, error) {
- path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID)
+ input CreatePurchaseInput,
+) (*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 {
- Items []PurchasePlanChannel `json:"items"`
+ Items []Purchase `json:"items"`
}
err := c.do(
@@ -656,81 +633,26 @@ func (c *Client) GetPurchasePlanChannels(
return resp.Items, err
}
-type AttachChannelToPurchasePlanInput struct {
- Username string `json:"username"`
- Status *string `json:"status,omitempty"`
- PlannedCost *float64 `json:"planned_cost,omitempty"`
- Comment *string `json:"comment,omitempty"`
-}
-
-func (c *Client) AttachChannelToPurchasePlan(
+func (c *Client) GetPurchase(
ctx context.Context,
jwt string,
workspaceID string,
projectID string,
- input AttachChannelToPurchasePlanInput,
-) (*PurchasePlanChannel, error) {
- path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID)
+ purchaseID string,
+) (*Purchase, error) {
+ path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID)
- var planChannel PurchasePlanChannel
+ var purchase Purchase
err := c.do(
ctx,
- http.MethodPost,
- 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,
+ http.MethodGet,
path,
nil,
- nil,
+ &purchase,
withBearer(jwt),
)
+
+ return &purchase, err
}
// ============================================================================
diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go
index 1e320fd..aa87022 100644
--- a/telegram_bot/bot/bot.go
+++ b/telegram_bot/bot/bot.go
@@ -101,6 +101,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
if b.LastMessageID != 0 {
+ log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
b.cleanupKeyboard(b.LastMessageID)
}
@@ -120,6 +121,7 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
if res.Result != nil {
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 {
callbackMessageID := u.CallbackQuery.Message.ID
+ // Если это сообщение последнее, не удаляем клавиатуру
if b.LastMessageID == callbackMessageID {
return
}
diff --git a/telegram_bot/screens/add_placement.go b/telegram_bot/screens/add_placement.go
deleted file mode 100644
index dfc5dcd..0000000
--- a/telegram_bot/screens/add_placement.go
+++ /dev/null
@@ -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 := `➕ Создать закуп
-
-Шаг 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(`⚠️ Сначала выберите проект
-
-Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, 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 }
diff --git a/telegram_bot/screens/add_purchase.go b/telegram_bot/screens/add_purchase.go
new file mode 100644
index 0000000..ba50a4f
--- /dev/null
+++ b/telegram_bot/screens/add_purchase.go
@@ -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 := "➕ Создание закупа\n\n"
+
+ var buttons [][]echotron.InlineKeyboardButton
+
+ if len(page.Items) == 0 {
+ text += `Для создания закупа сначала нужно создать креатив.
+
+Вернитесь назад и создайте креатив в разделе "Креативы"`
+
+ 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(`Шаг 1/2: Выбор креатива%s
+
+Выберите креатив для закупа:
+
+`, 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 }
diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go
index 04fabdd..4ec31e7 100644
--- a/telegram_bot/screens/creative_details.go
+++ b/telegram_bot/screens/creative_details.go
@@ -46,9 +46,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.Name = &creative.Name
if 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 остаются пустыми (не сохраняются в БД)
+ 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.showManagementPanelWithDelete(b)
diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go
index ac8513b..56d1941 100644
--- a/telegram_bot/screens/creative_editor.go
+++ b/telegram_bot/screens/creative_editor.go
@@ -2,6 +2,7 @@ package screens
import (
"fmt"
+ "strings"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
@@ -43,8 +44,24 @@ type CreativeEditorFields struct {
// GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string {
if e.Text != nil && *e.Text != "" {
+ // Проверяем что текст не является "визуально пустым" HTML
+ // Например: "" - пустая ссылка
+ text := strings.TrimSpace(*e.Text)
+
+ // Убираем пустые HTML-теги (простая проверка)
+ if text == "" || text == "" {
+ log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default")
+ return `💭 Я твой креатив
+
+У меня пока нет текста, добавь его ниже.
+
+`
+ }
+
+ log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
return *e.Text
}
+ log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
return `💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
@@ -136,7 +153,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
}
} else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
- // чтобы не обновлять LastMessageID и не затронуть cleanup
+ // чтобы не затронуть LastMessageID и не затронуть cleanup
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
@@ -319,12 +336,15 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
} else {
// Редактируем существующую панель
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
+
+ // Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID
keyboard := bot.Keyboard(buttons...)
b.Edit(text, keyboard)
+ // Восстанавливаем оригинальный LastMessageID
b.LastMessageID = oldLastMessageID
log.Info().Msg("Control panel updated")
}
diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go
index 1aea3a1..98d894e 100644
--- a/telegram_bot/screens/creatives.go
+++ b/telegram_bot/screens/creatives.go
@@ -104,24 +104,20 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
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"))
}
// Кнопка "Добавить креатив" всегда в центре
navRow = append(navRow, bot.Button("+ Добавить", "add_creative"))
- // Правая стрелка (или пустая кнопка)
+ // Правая стрелка (только если есть следующая страница)
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
- } else {
- navRow = append(navRow, bot.Button(" ", "empty"))
}
buttons = append(buttons, navRow)
diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go
index 5980bcf..a3730fe 100644
--- a/telegram_bot/screens/main_menu.go
+++ b/telegram_bot/screens/main_menu.go
@@ -19,11 +19,11 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data {
case "my_projects":
- b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
+ b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage)
case "support":
// b.SetState(&Support{}, bot.NewMessage)
case "placements":
- b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage)
+ b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.NewMessage)
case "dashboard":
// b.SetState(&Dashboard{}, bot.NewMessage)
default:
diff --git a/telegram_bot/screens/my_placements.go b/telegram_bot/screens/my_placements.go
index 6be70a7..d360731 100644
--- a/telegram_bot/screens/my_placements.go
+++ b/telegram_bot/screens/my_placements.go
@@ -66,27 +66,37 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
return
}
- text := "Мои закупы • Управление размещениями\n\n"
+ text := "📍 Мои размещения\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 {
- text += "У вас пока нет закупов"
- } else {
- text += fmt.Sprintf("Выберите закуп для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
+ text += `У вас пока нет размещений.
- // Закупы по 2 в ряду
+Размещения создаются автоматически при создании закупа.`
+ } else {
+ text += fmt.Sprintf("Выберите размещение для деталей (стр. %d из %d):\n\n", s.CurrentPage+1, page.Pages)
+
+ // Размещения по 2 в ряду
for i := 0; i < len(page.Items); i += 2 {
var row []echotron.InlineKeyboardButton
- // Первый закуп в ряду
+ // Первый placement в ряду
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) {
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 {
// Добавляем пустую кнопку для выравнивания
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(
- bot.Button("Статистика", "statistics"),
- bot.Button("Создать закуп", "add_placement"),
+ bot.Button("📊 Статистика", "statistics"),
))
// Кнопка возврата в главное меню
- buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu")))
+ buttons = append(buttons, bot.Row(bot.Button("← Назад", "back")))
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
@@ -167,8 +176,12 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.CurrentPage++
s.Enter(b, bot.EditMessage)
- case data == "menu":
- b.SetState(&MainMenu{}, bot.EditMessage)
+ case data == "back":
+ if s.BackState != nil {
+ b.SetState(s.BackState, bot.EditMessage)
+ } else {
+ b.SetState(&MainMenu{}, bot.EditMessage)
+ }
case data == "page_info", data == "empty":
// Неактивные кнопки - ничего не делаем
@@ -176,16 +189,10 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
case data == "statistics":
// TODO: реализовать экран статистики
- b.Edit("📊 Статистика закупов\n\nЭта функция в разработке", bot.Keyboard(
+ b.Edit("📊 Статистика размещений\n\nЭта функция в разработке", bot.Keyboard(
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":
s.Enter(b, bot.EditMessage)
diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go
index c187f37..22197a2 100644
--- a/telegram_bot/screens/my_projects.go
+++ b/telegram_bot/screens/my_projects.go
@@ -115,24 +115,20 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
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"))
}
// Кнопка "Добавить проект" всегда в центре
navRow = append(navRow, bot.Button("+ Добавить", "add_project"))
- // Правая стрелка (или пустая кнопка)
+ // Правая стрелка (только если есть следующая страница)
if s.CurrentPage+1 < page.Pages {
navRow = append(navRow, bot.Button("→", "next"))
- } else {
- navRow = append(navRow, bot.Button(" ", "empty"))
}
buttons = append(buttons, navRow)
@@ -224,7 +220,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
Project: project,
WorkspaceID: s.WorkspaceID,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
- }, bot.EditMessage)
+ }, bot.NewMessage)
}
case strings.HasPrefix(data, "workspace:"):
diff --git a/telegram_bot/screens/placement_purchase_plan.go b/telegram_bot/screens/placement_purchase_plan.go
deleted file mode 100644
index 3fc5fde..0000000
--- a/telegram_bot/screens/placement_purchase_plan.go
+++ /dev/null
@@ -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 := "📋 План закупа — Каналы\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Добавление канала:\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 }
diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go
index 793025b..15551d4 100644
--- a/telegram_bot/screens/project_details.go
+++ b/telegram_bot/screens/project_details.go
@@ -68,37 +68,32 @@ func (s *ProjectDetails) formatProjectDetails() string {
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
var buttons [][]echotron.InlineKeyboardButton
- // Первый ряд: Креативы и Создать закуп
+ // Первый ряд: Креативы и Закупы
buttons = append(buttons, bot.Row(
- bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
- bot.Button("Создать закуп", fmt.Sprintf("create_placement:%s", s.Project.ID)),
+ bot.Button("✎ Креативы", fmt.Sprintf("creatives:%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
if s.confirmingLinkTypeChange {
// Показываем подтверждение
var newType string
- if s.Project.InviteLinkType == "public" {
- newType = "С одобрением"
+ if s.Project.PurchaseInviteTypeDefault == "public" {
+ newType = "По заявке"
} else {
- newType = "Открытая"
+ newType = "Свободное"
}
- linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType)
+ linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType)
} else {
- // Показываем текущий тип приглашений
+ // Показываем текущий тип вступления
var currentType string
- if s.Project.InviteLinkType == "public" {
- currentType = "Открытая"
+ if s.Project.PurchaseInviteTypeDefault == "public" {
+ currentType = "Свободное"
} else {
- currentType = "С одобрением"
+ currentType = "По заявке"
}
- linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType)
+ linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType)
}
buttons = append(buttons, bot.Row(
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,
}, bot.NewMessage)
- case strings.HasPrefix(data, "create_placement:"):
+ case strings.HasPrefix(data, "purchases:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
- // TODO: реализовать создание закупа
- b.Edit("🚧 Создать закуп\n\nФункция в разработке", bot.Keyboard(
- bot.Row(bot.Button("← Назад", "back_to_project")),
- ))
+ b.SetState(&Purchases{
+ WorkspaceID: s.WorkspaceID,
+ ProjectID: s.Project.ID,
+ BackState: s,
+ }, bot.NewMessage)
case data == "back_to_project":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
@@ -154,7 +150,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
} else {
// Второе нажатие - меняем тип ссылки
var newType string
- if s.Project.InviteLinkType == "public" {
+ if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "approval"
} else {
newType = "public"
@@ -163,7 +159,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
log.Info().
Str("workspace_id", s.WorkspaceID).
Str("project_id", s.Project.ID).
- Str("old_type", s.Project.InviteLinkType).
+ Str("old_type", s.Project.PurchaseInviteTypeDefault).
Str("new_type", newType).
Msg("Updating project invite link type")
diff --git a/telegram_bot/screens/purchase_details.go b/telegram_bot/screens/purchase_details.go
new file mode 100644
index 0000000..1491577
--- /dev/null
+++ b/telegram_bot/screens/purchase_details.go
@@ -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 := "📦 Детали закупа\n\n"
+
+ // Информация о креативе
+ if creative != nil {
+ text += fmt.Sprintf("Креатив: %s\n", creative.Name)
+ } else {
+ text += fmt.Sprintf("Креатив ID: %s\n", purchase.CreativeID)
+ }
+
+ // Статус закупа
+ statusText := "Активный"
+ if purchase.Status == "archived" {
+ statusText = "Архивирован"
+ }
+ text += fmt.Sprintf("Статус: %s\n\n", statusText)
+
+ // Информация о каналах
+ if len(purchase.Channels) == 0 {
+ text += "Каналы не добавлены\n"
+ } else {
+ text += fmt.Sprintf("Каналы (%d):\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 %s\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 }
diff --git a/telegram_bot/screens/purchases.go b/telegram_bot/screens/purchases.go
new file mode 100644
index 0000000..76596c6
--- /dev/null
+++ b/telegram_bot/screens/purchases.go
@@ -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 := "📦 Закупы\n\n"
+
+ var buttons [][]echotron.InlineKeyboardButton
+
+ if len(purchases) == 0 {
+ text += `Список закупов пуст.
+
+Закуп — это план размещения рекламы в каналах:
+▸ Выбор креатива
+▸ Список каналов для размещения
+▸ Планируемая стоимость
+▸ Статус выполнения
+
+Создайте первый закуп 👇`
+ } else {
+ text += "Управление закупами для проекта.\n\n"
+ text += "Выберите закуп для деталей:\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 }
diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go
new file mode 100644
index 0000000..fd8ebae
--- /dev/null
+++ b/telegram_bot/screens/select_channels_for_purchase.go
@@ -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 := "➕ Создание закупа\n\n"
+ text += "Шаг 2/2: Добавление каналов\n\n"
+
+ var buttons [][]echotron.InlineKeyboardButton
+
+ if len(s.Channels) == 0 {
+ text += `Добавьте каналы для размещения рекламы.
+
+Отправьте username канала (без @)
+Например: channel_name
+
+После добавления всех каналов нажмите "Создать закуп"`
+ } else {
+ text += fmt.Sprintf("Добавлено каналов: %d\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Добавьте еще каналы или создайте закуп"
+
+ // Кнопка создания закупа (доступна только если есть каналы)
+ 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(`✅ Закуп создан!
+
+ID: %s
+Креатив: %s
+Каналов: %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)
+ }
+ }
+}
diff --git a/telegram_bot/screens/select_creative_for_placement.go b/telegram_bot/screens/select_creative_for_placement.go
deleted file mode 100644
index 3cee7a0..0000000
--- a/telegram_bot/screens/select_creative_for_placement.go
+++ /dev/null
@@ -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 := "Выбор креатива\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 }
diff --git a/telegram_bot/screens/select_project_for_placement.go b/telegram_bot/screens/select_project_for_placement.go
deleted file mode 100644
index 1dd1d83..0000000
--- a/telegram_bot/screens/select_project_for_placement.go
+++ /dev/null
@@ -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 := "Выбор проекта\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 }