change domen
This commit is contained in:
3
Makefile
3
Makefile
@@ -4,6 +4,9 @@ test:
|
|||||||
env:
|
env:
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
||||||
|
run:
|
||||||
|
uv run uvicorn src.main:app
|
||||||
|
|
||||||
up:
|
up:
|
||||||
GIT_COMMIT=$$(git rev-parse --short HEAD) docker compose up --build -d --force-recreate
|
GIT_COMMIT=$$(git rev-parse --short HEAD) docker compose up --build -d --force-recreate
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
|
|||||||
@@ -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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'
|
|
||||||
)
|
|
||||||
335
migrations/models/0_20260102162851_init.py
Normal file
335
migrations/models/0_20260102162851_init.py
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
CREATE TABLE IF NOT EXISTS "channel"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"telegram_id" BIGINT NOT NULL UNIQUE,
|
||||||
|
"title" VARCHAR(255) NOT NULL,
|
||||||
|
"username" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"access_hash" BIGINT,
|
||||||
|
"pts" INT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username");
|
||||||
|
CREATE TABLE IF NOT EXISTS "post"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"message_id" INT NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"deleted_from_channel_at" TIMESTAMPTZ,
|
||||||
|
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id");
|
||||||
|
CREATE TABLE IF NOT EXISTS "post_views_history"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"views_count" INT NOT NULL,
|
||||||
|
"fetched_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
"post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id");
|
||||||
|
CREATE TABLE IF NOT EXISTS "user"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"telegram_id" BIGINT NOT NULL UNIQUE,
|
||||||
|
"username" VARCHAR(255),
|
||||||
|
"first_name" VARCHAR(255),
|
||||||
|
"last_name" VARCHAR(255)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id");
|
||||||
|
CREATE TABLE IF NOT EXISTS "login_token"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"token" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"expires_at" TIMESTAMPTZ NOT NULL,
|
||||||
|
"used_at" TIMESTAMPTZ,
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id");
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"name" VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS "project"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
|
||||||
|
"purchase_invite_type_default" VARCHAR(10) NOT NULL DEFAULT 'approval',
|
||||||
|
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id");
|
||||||
|
COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived';
|
||||||
|
COMMENT ON COLUMN "project"."purchase_invite_type_default" IS 'PUBLIC: public\nAPPROVAL: approval';
|
||||||
|
CREATE TABLE IF NOT EXISTS "creative"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"name" VARCHAR(255) NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
|
||||||
|
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_creative_workspa_2de546" ON "creative" ("workspace_id");
|
||||||
|
COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived';
|
||||||
|
CREATE TABLE IF NOT EXISTS "purchase"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
|
||||||
|
"creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
|
||||||
|
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_purchase_creativ_6829cd" ON "purchase" ("creative_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_purchase_project_95b1c2" ON "purchase" ("project_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_purchase_workspa_8784a5" ON "purchase" ("workspace_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_purchase_project_dee224" ON "purchase" ("project_id", "status");
|
||||||
|
COMMENT ON COLUMN "purchase"."status" IS 'ACTIVE: active\nARCHIVED: archived';
|
||||||
|
CREATE TABLE IF NOT EXISTS "purchase_channel"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"status" VARCHAR(11) NOT NULL DEFAULT 'planned',
|
||||||
|
"planned_cost" DOUBLE PRECISION,
|
||||||
|
"comment" TEXT,
|
||||||
|
"invite_link" VARCHAR(512) NOT NULL,
|
||||||
|
"invite_link_type" VARCHAR(8) NOT NULL,
|
||||||
|
"channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||||
|
"purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_purchase_ch_purchas_f7c86e" UNIQUE ("purchase_id", "channel_id")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_purchase_ch_channel_2b1264" ON "purchase_channel" ("channel_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_purchase_ch_purchas_1fe84a" ON "purchase_channel" ("purchase_id");
|
||||||
|
COMMENT ON COLUMN "purchase_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed';
|
||||||
|
COMMENT ON COLUMN "purchase_channel"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';
|
||||||
|
CREATE TABLE IF NOT EXISTS "placement"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"wanted_placement_date" TIMESTAMPTZ NOT NULL,
|
||||||
|
"cost" DOUBLE PRECISION,
|
||||||
|
"comment" TEXT,
|
||||||
|
"invite_link" VARCHAR(512) NOT NULL,
|
||||||
|
"status" VARCHAR(8) NOT NULL DEFAULT 'active',
|
||||||
|
"creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
|
||||||
|
"placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||||
|
"post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL,
|
||||||
|
"project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
|
||||||
|
"purchase_id" UUID REFERENCES "purchase" ("id") ON DELETE CASCADE,
|
||||||
|
"purchase_channel_id" UUID REFERENCES "purchase_channel" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_purchas_3e3775" ON "placement" ("purchase_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id");
|
||||||
|
COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived';
|
||||||
|
CREATE TABLE IF NOT EXISTS "subscription"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"invite_link" VARCHAR(512) NOT NULL,
|
||||||
|
"status" VARCHAR(12) NOT NULL DEFAULT 'active',
|
||||||
|
"unsubscribed_at" TIMESTAMPTZ,
|
||||||
|
"placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE,
|
||||||
|
"subscriber_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_subscriptio_placeme_8fcb8f" UNIQUE ("placement_id", "subscriber_id")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id");
|
||||||
|
COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed';
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_invite"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"status" VARCHAR(8) NOT NULL DEFAULT 'pending',
|
||||||
|
"invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id");
|
||||||
|
COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked';
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_user"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"status" VARCHAR(7) NOT NULL DEFAULT 'active',
|
||||||
|
"user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "user_id")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id");
|
||||||
|
COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked';
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_user_permission"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"permission" VARCHAR(24) NOT NULL,
|
||||||
|
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
|
||||||
|
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
|
||||||
|
CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope"
|
||||||
|
(
|
||||||
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"deleted_at" TIMESTAMPTZ,
|
||||||
|
"permission" VARCHAR(24) NOT NULL,
|
||||||
|
"channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE,
|
||||||
|
"creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE,
|
||||||
|
"placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE,
|
||||||
|
"project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE,
|
||||||
|
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_id");
|
||||||
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id");
|
||||||
|
COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
|
||||||
|
CREATE TABLE IF NOT EXISTS "aerich"
|
||||||
|
(
|
||||||
|
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||||
|
"version" VARCHAR(255) NOT NULL,
|
||||||
|
"app" VARCHAR(100) NOT NULL,
|
||||||
|
"content" JSONB NOT NULL
|
||||||
|
);"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
'eJztXWtv2zoS/SuGP/UC2SJxnCa3WCxgO26rW8c2/Ei7ty0ERWYcbWTJV5KTBkX/+5KSaI'
|
||||||
|
'l6WS9HlD1fipTk0OIZkpo5Gg5/NVf6Aqnm296DpGlIbb5v/Gpq0grhP4JVJ42mtF57FaTA'
|
||||||
|
'ku5Uu63sa3RnWoYkW7j4XlJNhIsWyJQNZW0puoZLtY2qkkJdxg0VbekVbTTlnw0SLX2JrA'
|
||||||
|
'dk4IpvP3Cxoi3QT2TS/64fxXsFqQvmYZUF+W27XLRe1nbZfC5cf7Bbkp+7E2Vd3aw0r/X6'
|
||||||
|
'xXrQtW3zzUZZvCUypG6JNGRIFlr4hkGe0h0wLXKeGBdYxgZtH3XhFSzQvbRRCRjNf99vNJ'
|
||||||
|
'lg0LB/ifzT/k8zAzyyrhFoFc0iWPz67YzKG7Nd2iQ/1fvUmbw5f/eHPUrdtJaGXWkj0vxt'
|
||||||
|
'C0qW5IjauHpAygYiwxYlKwzoNa6xlBWKBpWVDIC7cEXf0j/ygEwLPJS9GUZhpvDlw7SJx7'
|
||||||
|
'AYaeqLq8EEjGfCTX8669yMyUhWpvmPakPUmfVJTcsufQmUvnFUouP14aybbSeNL8LsU4P8'
|
||||||
|
't/H3aNgPKm7bbvZ3kzyTtLF0UdOfRWnhm2y0lAKDW3qK3awXORXLSoJiK1Ws+/CeXvFejP'
|
||||||
|
'LplZUsQa/u076iWmuiRjrsxAVqYXUsDWklRr3LuspS0KxoPQYEA4rEUO3nhVZwQS7Jr/zr'
|
||||||
|
'z1br/PyydXr+7uqifXl5cXV6hdvajxSuukxQd1f4KAxnrPZIwW8WY8VSURhdbOMYMdhSgQ'
|
||||||
|
'CqeCicbnQr6aeoIm1pPeD/ti4uEjC77UxsSwG3Csz7oVvVcupYEDcmMuy/M+DolykHyr1P'
|
||||||
|
'0P0DKckyMk3xQTIfsq34gGCuFf/6m3U1S35tmWFwY5F1W9cL0dZZ+7J9df6uvQVyW5KEn4'
|
||||||
|
'MVcanuH32+ACm4k+THZ8lYiEyND1RVktEKaVHYdl3ZD58nSJXsIYURdZ3LMe2Hz830N50e'
|
||||||
|
'tDTKACN2QFEYcBd1RsDQ/4fkwiA4vdQZh40h4z0ZiS4dUhQQtzsfA1NXYJCxUkwTdy2ask'
|
||||||
|
'72oELAfNGNR3ONd445tirG276npGsu9+lYkMheq7f0uN03XLVqrYIlkiYt7acmv01+iRJ3'
|
||||||
|
'hI9RnlAzitSjdSeJrJ6/FdB6QOsB+wO0HigWaD2g9dLTelmZknJZkoMgnCz0M2IVzHBpHC'
|
||||||
|
'H6M2rucwth0nTvf50xM50C9eam8/UPZrYPRsOPtLkP2N5g1A3gaVqStYmwwcmk7GublY2q'
|
||||||
|
'gB9R0mQUQteTfj18m9jydA1RFuNmpzcTbvvvG06D7xpBBxdc4xLsPeEiZ3pknMZXKSbxVe'
|
||||||
|
'wUvgpOYNdBjmT2481ZVqpMszYMecnkaREj1oPtmfp5GYELyh0JdCEmLwLJMIwfdAMpS+0z'
|
||||||
|
'egkt+x3eN8/whVxtXGxIz1sXMzRH8Dgdw8kGujPtda77zahFXAKCpTJdFeHHbk7R6AGTnI'
|
||||||
|
'8/LIk3rDMSQBi+PmE40JeKNtMfkdaMoAx9tSdJpKFK2onWtiHwhsAbAr0EvCEoFnhD4A0z'
|
||||||
|
'hAPSN2jqUDUqAPFVLoTo51rBGsmxFljJeu5xh7QYNma+V5UJ+xlHKjQy8ng+EaDwCBglcE'
|
||||||
|
'9ztxteQdtJPPkmRVbWaZ/Os8c/RfjODDkV7zqvmWbgOIPjXFfb42D8K3CcD1Sx4DgfqqH5'
|
||||||
|
'LGlEG9uXqUiQz6rT2E7quWwPSb+ye1giYAiruhQTDkQFAqq7JxJ1W43Xo3l30G+MJ/2eMB'
|
||||||
|
'VGQ1Y9diUpwgWKYxZP+p1BgBaR9RU1MtNGVPlEcrFLXGG4l5gqRXvCiIuqoj1mIe0CYnWJ'
|
||||||
|
'WGPJu4uzVgryDreKJe/sOghSqzRIjZ72yEjSBMSOhKhhghW2RoJ77itrnF+M/DFCiauzou'
|
||||||
|
'eJFABs9wuKT7wgsDQXbPSgZkbcWLEjnG6BA6558Sttn6spjhDYDIHNENjMJ35pApujzOYS'
|
||||||
|
'4POfyq4tfgF/IMX0C5q/ZSBZZsqEqiZijFuQAlFfAHjRFZ0+lryqd/FuIFmzLT1+JU7I7L'
|
||||||
|
'k8+Icz87SMJm4zQ5kuWQ6/+HkOK4PZtD9rDOeDQbpzM+bmbvuUBU9HTH1d8Uk3wpkRXs+M'
|
||||||
|
'2IsxKuLFXaQJwS60RalxLt+a7K60QqaJn5z87wfEwOyaOxADU+J6PJhQCYiBOVDFQgzMoc'
|
||||||
|
'ZI+F57IUXG5j5lhcpJI/0Kq7GEHKiQbGZ/gRF0Z7g39NXWZcy/wUR0A7tN1RFZub4GHeXH'
|
||||||
|
'7oRPGcB+MjRyCnKJ1/wiHPn+/mX6pKBnU3xQTEs3lMJJRvDcviUdfrL7e+H6Zff6nAgDTQ'
|
||||||
|
'w/EoQvmSsR/ep72Qdz4g++QZb84JjzQJsAbQLeNdAmoFigTQ7dkXFesbK+iTodEcubBKSO'
|
||||||
|
'lTjx2QwZVwEr+Tq72+t5hLVbBZUFYXOltvyO/OvGOfDrwscFOlSdqcENUozyyrz4xQRnzN'
|
||||||
|
'eoZA8sGLvpI0HADQM3DKx1cMNAseCGHboBeshHf4UhLVE0ro8De1HTzhF10qFIR5xTNbv6'
|
||||||
|
'fE2FrbEd9ySpESobz7sDofe+sd7cqYqM1TMeT0a3nQFWj18oo3rOTlPo5+w0VkGkKnBgGz'
|
||||||
|
'67prfz4PAdfLGu/ot15DwsAb9jOrqY5qs/PYRW8CN3uQfyqjkVAferwP0qcFaGi7gQOm+i'
|
||||||
|
'mEffnEqgHv2t9pkf9puf5XQdqh9AOQLlCMwUUI7Hq1igHIFyrB/lyCW9CNkGIeUbsH7cQp'
|
||||||
|
'cU3gLpoiBdVHXpooA2DUJYHm0Kh6WC3zZKogpL/b5xUGQYRSaBE/OBt5sa8+fsKv2AFJtI'
|
||||||
|
'DKLz9m6/AVV24IwKUGUHqligyoAq44Yqw3YtflMvIriy8aAzHBJqzG1Co71stsyO9iJlk/'
|
||||||
|
'5f/d6MlBmIeHukTBiKuOHHSX86JbF8Im5LUDa/a73RzXjQt5vL+mptT948jNvZWZqIsbP4'
|
||||||
|
'iLGzUEyfM0gx8xVKQUG4SgmuUrKHCVcpFX7j7/8qJR8yDh459+6ofiqGeU/BumV/7IBI3W'
|
||||||
|
'aeTx1VX9NSJ+CSGPtK8sHzS5hmTggPgc6QmqtW7CqT0j2CWg2mfI/nVc1gy7I51e0tF850'
|
||||||
|
'cn/vDhnAqwKvCvQb8KqgWOBVj4FX5Zv2KNnWhAukC4V0zofTeXfamwhdwjJvtK3VlI9oTo'
|
||||||
|
'N+Avhh7P1PlOeNExaH7anqrGABQz01GxOQOxI6htmJGJ8mA3YhwSMBL4nL8jv7Rcmscs+0'
|
||||||
|
'Vn5PZFo6y5tWJYBIjnzWGr/QIuMpWZ2NbgSBQ1GPJ242tMU+z4kCHwN8DLjtwMccr2KBjz'
|
||||||
|
'lUh8fC6lga0irSZu8qy9h84AHBcvKB736hFVyQTjbwP1ut8/PL1un5u6uL9uXlxdXpNi14'
|
||||||
|
'uCopP3hX+EhShDPaC+cMJ0aK/XcI4HjCyy9Tkwgqlm5pXVyk4Ftwq1jCxa4LZF9XDNMSs2'
|
||||||
|
'LJSgGaFE1VygEmI3TcWIYc+DTxAKq+VDBi+iMqeqP5gPQ0Ix3xaf2kOoEGN7yHELFJDvt7'
|
||||||
|
'R1kJqwS7txpj4jv8CcBEA0MMhjITnNUMlH3SU94B6giOijldHU9UMQe6ga0CtioMLpAawF'
|
||||||
|
'aBYoGtArYqdoFmdVYL+alVLEGOHFXILg3ZpSOwcDJBFUWizJxY1eAAWbaZiE7wyMEjr8Aj'
|
||||||
|
'd6dLkl/uzagU3rnLL5XvpIevPSTTA07/gP8Obh7476BY8N+PwX+v4WmVNdIWRD0hBTbH/e'
|
||||||
|
'G1MPz4vuE2+a51er3+2E6JJMkyWltOVqXb0WcnqdKT/pjv6ErJiTocG2ch3r1kjNQPCR5J'
|
||||||
|
'pH4wmCcjbD6RIwQMcpKXdSoEkkKf5EoKHb33lQBi7U+FhDb03QDSExfHDp1vU+fpKA3LGC'
|
||||||
|
'XxIrsP17BUFnAiwIkAJ8KVzwWcyNEoFjgR4ES44URSZ/AQhreCzYe4huZ3rTsY9Ww65E7V'
|
||||||
|
'5Zx0SNKJHEqHXMbSIZehNB7g0YNHDx49H25Vdo8eHNLCDmnUlen7uSydTysxXcQPV7fJ84'
|
||||||
|
'TSq/EZvnm0i9lgp1xajkNcs2J7ozv8BoT3k0B7AO0B3jHQHqBYoD0OnfZg37R5qA+2h6qv'
|
||||||
|
'aOlc3whD8cN8QC5lWawUTbzH7b5r48mIXKs1FSf9DrmBy42fF8kK9dV+mQizvq/62VAs9F'
|
||||||
|
'3rYSlCqVDx7aEMV96rdzvwGrg9jAedXv+mP/SeYHuagT6D14I+hdfE7aUz7Az+OxN6tBNs'
|
||||||
|
'rqkvliLTPrx6MnNG85nYGwi9z1N/y2cFa2hjibKqyI/kBrFP5FaywXZo7l20dGS0lg6MVt'
|
||||||
|
'tPlIc2arXTnKZpxx+maQeZo0h7LjsbcnxsUhpKRCzJsd9DDH3l3EgdPj4Hfdr0HtvWB87h'
|
||||||
|
'tjm+efnOG3hme9gGwDM7eAMePLMDVSx4ZuCZgWcGnhmfnlnFd5Hu3oC4ccUY1NwpmhU2Vu'
|
||||||
|
'z4cOPg1pC6IufsqllxY6SODzXgnYB34pN3iljbJUCZPl1MVQt69/01zJaV4jJmX9qkguhl'
|
||||||
|
'yMDELXwBEyPF5KvoBiVuEcx+gVIV94FzC1+x68D3Sbp3kKHID80Iet2tOUki0iWvDTd8ee'
|
||||||
|
'ydE2mvmnAVWIwnL8jzOTdNtM7al+2r83ft7QUT25KkKHZ6h0Q8Pf6EjHiGJho9n8iR50P0'
|
||||||
|
'73JkaWQA0W1eTwDPTk9TAIhbxV+CSuoCpIGuWZHv2b+mo2EMYeCJBICca3iA3xaKbJ00VM'
|
||||||
|
'W0fvAJawKKZNQM00vBe3PT+RrEtTcYdYOuCumgmy1pZ/mvl9//Bz1kVX4='
|
||||||
|
)
|
||||||
@@ -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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'
|
|
||||||
)
|
|
||||||
99
migrations/models/1_20260102184542_add_check_constraint.py
Normal file
99
migrations/models/1_20260102184542_add_check_constraint.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
-- Add CHECK constraint: exactly one FK must be set
|
||||||
|
ALTER TABLE "workspace_user_permission_scope"
|
||||||
|
ADD CONSTRAINT "check_exactly_one_scope"
|
||||||
|
CHECK (
|
||||||
|
(("project_id" IS NOT NULL)::int +
|
||||||
|
("creative_id" IS NOT NULL)::int +
|
||||||
|
("placement_id" IS NOT NULL)::int +
|
||||||
|
("channel_id" IS NOT NULL)::int) = 1
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE "workspace_user_permission_scope"
|
||||||
|
IS 'Permission scoped to specific entity (project/creative/placement/channel)'; \
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||||
|
return """
|
||||||
|
ALTER TABLE "workspace_user_permission_scope"
|
||||||
|
DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; \
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
MODELS_STATE = (
|
||||||
|
'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'
|
||||||
|
)
|
||||||
@@ -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"
|
|
||||||
)
|
|
||||||
@@ -257,37 +257,60 @@ class Postgres(DatabaseBase, Database):
|
|||||||
|
|
||||||
return await query.order_by('created_at').all()
|
return await query.order_by('created_at').all()
|
||||||
|
|
||||||
async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None:
|
async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None:
|
||||||
return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE)
|
return await domain.Purchase.get_or_none(
|
||||||
|
project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE
|
||||||
|
)
|
||||||
|
|
||||||
async def get_purchase_plan(self, workspace_id: uuid.UUID, plan_id: uuid.UUID) -> domain.PurchasePlan | None:
|
async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
|
||||||
return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id)
|
return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id)
|
||||||
|
|
||||||
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None:
|
async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]:
|
||||||
await plan.save()
|
|
||||||
|
|
||||||
async def get_purchase_plan_channels(self, plan_id: uuid.UUID) -> list[domain.PurchasePlanChannel]:
|
|
||||||
return await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id).prefetch_related('channel').all()
|
|
||||||
|
|
||||||
async def get_purchase_plan_channel(
|
|
||||||
self, plan_channel_id: uuid.UUID, plan_id: uuid.UUID
|
|
||||||
) -> domain.PurchasePlanChannel | None:
|
|
||||||
return (
|
return (
|
||||||
await domain.PurchasePlanChannel.filter(id=plan_channel_id, purchase_plan_id=plan_id)
|
await domain.Purchase.filter(workspace_id=workspace_id, project_id=project_id)
|
||||||
|
.prefetch_related('channels', 'channels__channel')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_purchase(self, purchase: domain.Purchase) -> None:
|
||||||
|
await purchase.save()
|
||||||
|
|
||||||
|
async def get_purchase_channels(self, purchase_id: uuid.UUID) -> list[domain.PurchaseChannel]:
|
||||||
|
return await domain.PurchaseChannel.filter(purchase_id=purchase_id).prefetch_related('channel').all()
|
||||||
|
|
||||||
|
async def get_purchase_channel(
|
||||||
|
self, purchase_channel_id: uuid.UUID, purchase_id: uuid.UUID
|
||||||
|
) -> domain.PurchaseChannel | None:
|
||||||
|
return (
|
||||||
|
await domain.PurchaseChannel.filter(id=purchase_channel_id, purchase_id=purchase_id)
|
||||||
.prefetch_related('channel')
|
.prefetch_related('channel')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_channel_to_purchase_plan(
|
async def get_purchase_channels_by_project(self, project_id: uuid.UUID) -> list[domain.PurchaseChannel]:
|
||||||
|
return (
|
||||||
|
await domain.PurchaseChannel.filter(
|
||||||
|
purchase__project_id=project_id, purchase__status=domain.PurchaseStatus.ACTIVE
|
||||||
|
)
|
||||||
|
.prefetch_related('channel')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_channel_to_purchase(
|
||||||
self,
|
self,
|
||||||
plan_id: uuid.UUID,
|
purchase_id: uuid.UUID,
|
||||||
channel_id: uuid.UUID,
|
channel_id: uuid.UUID,
|
||||||
|
invite_link: str,
|
||||||
|
invite_link_type: domain.purchase.InviteLinkType,
|
||||||
*,
|
*,
|
||||||
status: domain.PurchasePlanChannelStatus | None = None,
|
status: domain.PurchaseChannelStatus | None = None,
|
||||||
planned_cost: float | None = None,
|
planned_cost: float | None = None,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
) -> domain.PurchasePlanChannel:
|
) -> domain.PurchaseChannel:
|
||||||
defaults: dict[str, object | None] = {}
|
defaults: dict[str, object | None] = {
|
||||||
|
'invite_link': invite_link,
|
||||||
|
'invite_link_type': invite_link_type,
|
||||||
|
}
|
||||||
if status is not None:
|
if status is not None:
|
||||||
defaults['status'] = status
|
defaults['status'] = status
|
||||||
if planned_cost is not None:
|
if planned_cost is not None:
|
||||||
@@ -295,24 +318,24 @@ class Postgres(DatabaseBase, Database):
|
|||||||
if comment is not None:
|
if comment is not None:
|
||||||
defaults['comment'] = comment
|
defaults['comment'] = comment
|
||||||
|
|
||||||
plan_channel, created = await domain.PurchasePlanChannel.get_or_create(
|
purchase_channel, created = await domain.PurchaseChannel.get_or_create(
|
||||||
purchase_plan_id=plan_id,
|
purchase_id=purchase_id,
|
||||||
channel_id=channel_id,
|
channel_id=channel_id,
|
||||||
defaults=defaults,
|
defaults=defaults,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not created and defaults:
|
if not created and defaults:
|
||||||
for field, value in defaults.items():
|
for field, value in defaults.items():
|
||||||
setattr(plan_channel, field, value)
|
setattr(purchase_channel, field, value)
|
||||||
await plan_channel.save()
|
await purchase_channel.save()
|
||||||
|
|
||||||
return plan_channel
|
return purchase_channel
|
||||||
|
|
||||||
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None:
|
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None:
|
||||||
await plan_channel.save()
|
await purchase_channel.save()
|
||||||
|
|
||||||
async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None:
|
async def remove_channel_from_purchase(self, purchase_id: uuid.UUID, channel_id: uuid.UUID) -> None:
|
||||||
await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id, channel_id=channel_id).delete()
|
await domain.PurchaseChannel.filter(purchase_id=purchase_id, channel_id=channel_id).delete()
|
||||||
|
|
||||||
async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
|
async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
|
||||||
return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related(
|
return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related(
|
||||||
@@ -485,27 +508,6 @@ class Postgres(DatabaseBase, Database):
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None:
|
|
||||||
return await domain.TelegramState.get_or_none(telegram_id=telegram_id)
|
|
||||||
|
|
||||||
async def set_telegram_state(
|
|
||||||
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
|
|
||||||
) -> domain.TelegramState:
|
|
||||||
existing = await self.get_telegram_state(telegram_id)
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
existing.state = state
|
|
||||||
existing.context = context
|
|
||||||
existing.updated_at = timezone.now()
|
|
||||||
await existing.save()
|
|
||||||
return existing
|
|
||||||
|
|
||||||
new_state = await domain.TelegramState.create(telegram_id=telegram_id, state=state, context=context)
|
|
||||||
return new_state
|
|
||||||
|
|
||||||
async def clear_telegram_state(self, telegram_id: int) -> None:
|
|
||||||
await domain.TelegramState.filter(telegram_id=telegram_id).delete()
|
|
||||||
|
|
||||||
# Count methods
|
# Count methods
|
||||||
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int:
|
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int:
|
||||||
return await domain.Placement.filter(creative_id=creative_id).count()
|
return await domain.Placement.filter(creative_id=creative_id).count()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from src.controller.http_v1.creatives import creatives_router
|
|||||||
from src.controller.http_v1.internal import internal_router
|
from src.controller.http_v1.internal import internal_router
|
||||||
from src.controller.http_v1.placements import placements_router
|
from src.controller.http_v1.placements import placements_router
|
||||||
from src.controller.http_v1.projects import projects_router
|
from src.controller.http_v1.projects import projects_router
|
||||||
from src.controller.http_v1.purchase_plans import purchase_plan_channels_router
|
from src.controller.http_v1.purchases import purchase_router
|
||||||
from src.controller.http_v1.views import views_router
|
from src.controller.http_v1.views import views_router
|
||||||
from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router
|
from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router
|
||||||
from src.controller.http_v1.workspace_members import workspace_members_router
|
from src.controller.http_v1.workspace_members import workspace_members_router
|
||||||
@@ -21,7 +21,7 @@ api_v1_router.include_router(auth_router)
|
|||||||
api_v1_router.include_router(internal_router)
|
api_v1_router.include_router(internal_router)
|
||||||
api_v1_router.include_router(channels_router)
|
api_v1_router.include_router(channels_router)
|
||||||
api_v1_router.include_router(projects_router)
|
api_v1_router.include_router(projects_router)
|
||||||
api_v1_router.include_router(purchase_plan_channels_router)
|
api_v1_router.include_router(purchase_router)
|
||||||
api_v1_router.include_router(creatives_router)
|
api_v1_router.include_router(creatives_router)
|
||||||
api_v1_router.include_router(placements_router)
|
api_v1_router.include_router(placements_router)
|
||||||
api_v1_router.include_router(views_router)
|
api_v1_router.include_router(views_router)
|
||||||
|
|||||||
@@ -48,40 +48,4 @@ async def get_placement(
|
|||||||
return await deps.get_usecase().get_placement(input=input)
|
return await deps.get_usecase().get_placement(input=input)
|
||||||
|
|
||||||
|
|
||||||
@placements_router.post('')
|
# Placement creation/updates are handled by worker; API exposes only read endpoints.
|
||||||
async def create_placement(
|
|
||||||
request: dto.CreatePlacementInput,
|
|
||||||
workspace_id: uuid.UUID,
|
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
) -> dto.PlacementOutput:
|
|
||||||
return await deps.get_usecase().create_placement(request, current_user.user_id, workspace_id)
|
|
||||||
|
|
||||||
|
|
||||||
@placements_router.patch('/{placement_id}')
|
|
||||||
async def update_placement(
|
|
||||||
placement_id: uuid.UUID,
|
|
||||||
request: dto.UpdatePlacementInput,
|
|
||||||
workspace_id: uuid.UUID,
|
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
) -> dto.PlacementOutput:
|
|
||||||
return await deps.get_usecase().update_placement(
|
|
||||||
placement_id=placement_id,
|
|
||||||
input=request,
|
|
||||||
user_id=current_user.user_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@placements_router.delete('/{placement_id}')
|
|
||||||
async def delete_placement(
|
|
||||||
placement_id: uuid.UUID,
|
|
||||||
workspace_id: uuid.UUID,
|
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
) -> None:
|
|
||||||
input = dto.DeletePlacementInput(
|
|
||||||
placement_id=placement_id,
|
|
||||||
user_id=current_user.user_id,
|
|
||||||
workspace_id=workspace_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
await deps.get_usecase().delete_placement(input)
|
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[
|
|||||||
|
|
||||||
|
|
||||||
@projects_router.get('')
|
@projects_router.get('')
|
||||||
async def get_workspace_projects(
|
async def get_projects(
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)]
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
|
||||||
) -> Page[dto.ProjectOutput]:
|
) -> Page[dto.ProjectOutput]:
|
||||||
input = dto.GetWorkspaceProjectsInput(
|
input = dto.GetWorkspaceProjectsInput(
|
||||||
user_id=current_user.user_id,
|
user_id=current_user.user_id,
|
||||||
@@ -32,18 +31,9 @@ async def update_project_invite_link_type(
|
|||||||
input: dto.UpdateProjectInviteLinkTypeInput,
|
input: dto.UpdateProjectInviteLinkTypeInput,
|
||||||
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
) -> dto.ProjectOutput:
|
) -> dto.ProjectOutput:
|
||||||
project = await deps.get_usecase().update_project_invite_link_type(
|
return await deps.get_usecase().update_project_invite_link_type(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
invite_link_type=input.invite_link_type,
|
purchase_invite_type_default=input.purchase_invite_type_default,
|
||||||
user_id=current_user.user_id,
|
user_id=current_user.user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return dto.ProjectOutput(
|
|
||||||
id=project.id,
|
|
||||||
telegram_id=project.channel.telegram_id,
|
|
||||||
title=project.channel.title,
|
|
||||||
username=project.channel.username,
|
|
||||||
status=project.status,
|
|
||||||
invite_link_type=project.invite_link_type,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
60
src/controller/http_v1/purchases.py
Normal file
60
src/controller/http_v1/purchases.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.routing import APIRouter
|
||||||
|
from fastapi_pagination import Page, paginate
|
||||||
|
|
||||||
|
from src import deps, dto
|
||||||
|
from src.adapter.jwt import JWTPayload
|
||||||
|
|
||||||
|
purchase_router = APIRouter(
|
||||||
|
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase',
|
||||||
|
tags=['purchase'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@purchase_router.post('')
|
||||||
|
async def create_purchase(
|
||||||
|
request: dto.CreatePurchaseInput,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> dto.PurchaseOutput:
|
||||||
|
return await deps.get_usecase().create_purchase(
|
||||||
|
project_id=project_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
input=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@purchase_router.get('')
|
||||||
|
async def get_purchases(
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> Page[dto.PurchaseOutput]:
|
||||||
|
input = dto.GetPurchasesInput(
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
result = await deps.get_usecase().get_purchases(input=input)
|
||||||
|
return paginate(result) # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
@purchase_router.get('/{purchase_id}')
|
||||||
|
async def get_purchase(
|
||||||
|
purchase_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
|
||||||
|
) -> dto.PurchaseOutput:
|
||||||
|
input = dto.GetPurchaseInput(
|
||||||
|
user_id=current_user.user_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
project_id=project_id,
|
||||||
|
purchase_id=purchase_id,
|
||||||
|
)
|
||||||
|
return await deps.get_usecase().get_purchase(input=input)
|
||||||
@@ -15,18 +15,16 @@ __all__ = (
|
|||||||
'Channel',
|
'Channel',
|
||||||
'Project',
|
'Project',
|
||||||
'ProjectStatus',
|
'ProjectStatus',
|
||||||
'PurchasePlan',
|
'Purchase',
|
||||||
'PurchasePlanChannel',
|
'PurchaseChannel',
|
||||||
'PurchasePlanStatus',
|
'PurchaseStatus',
|
||||||
'PurchasePlanChannelStatus',
|
'PurchaseChannelStatus',
|
||||||
'Creative',
|
'Creative',
|
||||||
'replace_invite_link_with_tag',
|
'replace_invite_link_with_tag',
|
||||||
'Placement',
|
'Placement',
|
||||||
'Post',
|
'Post',
|
||||||
'Subscription',
|
'Subscription',
|
||||||
'PostViewsHistory',
|
'PostViewsHistory',
|
||||||
'TelegramState',
|
|
||||||
'TelegramStateEnum',
|
|
||||||
'ChannelNotFound',
|
'ChannelNotFound',
|
||||||
'ProjectNotFound',
|
'ProjectNotFound',
|
||||||
'CreativeStatus',
|
'CreativeStatus',
|
||||||
@@ -45,8 +43,8 @@ __all__ = (
|
|||||||
'LoginTokenExpired',
|
'LoginTokenExpired',
|
||||||
'LoginTokenAlreadyUsed',
|
'LoginTokenAlreadyUsed',
|
||||||
'ProjectNotFound',
|
'ProjectNotFound',
|
||||||
'PurchasePlanNotFound',
|
'PurchaseNotFound',
|
||||||
'PurchasePlanChannelNotFound',
|
'PurchaseChannelNotFound',
|
||||||
'ChannelNotFound',
|
'ChannelNotFound',
|
||||||
'ChannelAlreadyExists',
|
'ChannelAlreadyExists',
|
||||||
'ChannelNoAdminRights',
|
'ChannelNoAdminRights',
|
||||||
@@ -74,8 +72,8 @@ from .error import (
|
|||||||
LoginTokenNotFound,
|
LoginTokenNotFound,
|
||||||
PlacementNotFound,
|
PlacementNotFound,
|
||||||
ProjectNotFound,
|
ProjectNotFound,
|
||||||
PurchasePlanChannelNotFound,
|
PurchaseChannelNotFound,
|
||||||
PurchasePlanNotFound,
|
PurchaseNotFound,
|
||||||
TelegramChannelNotFound,
|
TelegramChannelNotFound,
|
||||||
UserByUsernameNotFound,
|
UserByUsernameNotFound,
|
||||||
UserNotFound,
|
UserNotFound,
|
||||||
@@ -89,15 +87,15 @@ from .login_token import LoginToken
|
|||||||
from .placement import Placement, PlacementStatus, PostViewsAvailability
|
from .placement import Placement, PlacementStatus, PostViewsAvailability
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .post_views_history import PostViewsHistory
|
from .post_views_history import PostViewsHistory
|
||||||
from .project import InviteLinkType, Project, ProjectStatus
|
from .project import Project, ProjectStatus
|
||||||
from .purchase_plan import (
|
from .purchase import (
|
||||||
PurchasePlan,
|
InviteLinkType,
|
||||||
PurchasePlanChannel,
|
Purchase,
|
||||||
PurchasePlanChannelStatus,
|
PurchaseChannel,
|
||||||
PurchasePlanStatus,
|
PurchaseChannelStatus,
|
||||||
|
PurchaseStatus,
|
||||||
)
|
)
|
||||||
from .subscription import Subscription, SubscriptionStatus
|
from .subscription import Subscription, SubscriptionStatus
|
||||||
from .telegram_state import TelegramState, TelegramStateEnum
|
|
||||||
from .user import User
|
from .user import User
|
||||||
from .workspace import (
|
from .workspace import (
|
||||||
PermissionKey,
|
PermissionKey,
|
||||||
|
|||||||
@@ -43,16 +43,16 @@ def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException:
|
|||||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found')
|
||||||
|
|
||||||
|
|
||||||
def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException:
|
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException:
|
||||||
if plan_id is None:
|
if purchase_id is None:
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found')
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found')
|
||||||
|
|
||||||
|
|
||||||
def PurchasePlanChannelNotFound(plan_channel_id: uuid.UUID | None = None) -> HTTPException:
|
def PurchaseChannelNotFound(purchase_channel_id: uuid.UUID | None = None) -> HTTPException:
|
||||||
if plan_channel_id is None:
|
if purchase_channel_id is None:
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found')
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found')
|
||||||
|
|
||||||
|
|
||||||
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
|
def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
|||||||
from .creative import Creative
|
from .creative import Creative
|
||||||
from .post import Post
|
from .post import Post
|
||||||
from .project import Project
|
from .project import Project
|
||||||
|
from .purchase import Purchase, PurchaseChannel
|
||||||
from .workspace import Workspace
|
from .workspace import Workspace
|
||||||
|
|
||||||
|
|
||||||
@@ -27,35 +28,44 @@ class PostViewsAvailability(str, enum.Enum):
|
|||||||
|
|
||||||
|
|
||||||
class Placement(TimestampedModel):
|
class Placement(TimestampedModel):
|
||||||
placement_date = fields.DatetimeField()
|
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
|
||||||
cost = fields.FloatField(null=True)
|
cost = fields.FloatField(null=True)
|
||||||
comment = fields.TextField(null=True)
|
comment = fields.TextField(null=True)
|
||||||
invite_link = fields.CharField(max_length=512)
|
invite_link = fields.CharField(max_length=512)
|
||||||
|
|
||||||
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
|
||||||
|
|
||||||
|
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||||
|
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
|
||||||
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
|
|
||||||
)
|
|
||||||
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||||
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||||
'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
purchase: fields.ForeignKeyRelation['Purchase'] | None = fields.ForeignKeyField(
|
||||||
|
'models.Purchase', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
|
||||||
|
)
|
||||||
|
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] | None = fields.ForeignKeyField(
|
||||||
|
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
|
||||||
|
)
|
||||||
|
|
||||||
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
|
||||||
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
|
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
project_id: UUID
|
|
||||||
placement_channel_id: UUID
|
|
||||||
creative_id: UUID
|
|
||||||
workspace_id: UUID
|
workspace_id: UUID
|
||||||
|
project_id: UUID
|
||||||
|
creative_id: UUID
|
||||||
|
placement_channel_id: UUID
|
||||||
|
purchase_id: UUID | None
|
||||||
post_id: UUID | None
|
post_id: UUID | None
|
||||||
|
purchase_channel_id: UUID | None
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'placement'
|
table = 'placement'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
|
from .purchase import InviteLinkType
|
||||||
from .base import TimestampedModel
|
from .base import TimestampedModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -17,25 +18,21 @@ class ProjectStatus(str, enum.Enum):
|
|||||||
ARCHIVED = 'archived'
|
ARCHIVED = 'archived'
|
||||||
|
|
||||||
|
|
||||||
class InviteLinkType(str, enum.Enum):
|
|
||||||
PUBLIC = 'public' # открытая ссылка
|
|
||||||
APPROVAL = 'approval' # с одобрением ботом
|
|
||||||
|
|
||||||
|
|
||||||
class Project(TimestampedModel):
|
class Project(TimestampedModel):
|
||||||
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
|
status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE)
|
||||||
invite_link_type = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
|
purchase_invite_type_default = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10)
|
||||||
|
|
||||||
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
|
||||||
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
|
|
||||||
)
|
|
||||||
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||||
'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True
|
'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||||
|
'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
workspace_id: UUID
|
|
||||||
channel_id: UUID
|
channel_id: UUID
|
||||||
|
workspace_id: UUID
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'project'
|
table = 'project'
|
||||||
|
|||||||
74
src/domain/purchase.py
Normal file
74
src/domain/purchase.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
from .base import TimestampedModel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .channel import Channel
|
||||||
|
from .creative import Creative
|
||||||
|
from .project import Project
|
||||||
|
from .workspace import Workspace
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseStatus(str, enum.Enum):
|
||||||
|
ACTIVE = 'active'
|
||||||
|
ARCHIVED = 'archived'
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseChannelStatus(str, enum.Enum):
|
||||||
|
PLANNED = 'planned'
|
||||||
|
APPROVED = 'approved'
|
||||||
|
REJECTED = 'rejected'
|
||||||
|
IN_PROGRESS = 'in_progress'
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
|
||||||
|
|
||||||
|
class Purchase(TimestampedModel):
|
||||||
|
status = fields.CharEnumField(PurchaseStatus, default=PurchaseStatus.ACTIVE)
|
||||||
|
|
||||||
|
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||||
|
'models.Project', related_name='purchases', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
|
||||||
|
'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
|
||||||
|
'models.Workspace', related_name='purchases', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = 'purchase'
|
||||||
|
indexes = (('project', 'status'),)
|
||||||
|
|
||||||
|
|
||||||
|
class InviteLinkType(str, enum.Enum):
|
||||||
|
PUBLIC = 'public' # открытая ссылка
|
||||||
|
APPROVAL = 'approval' # с одобрением ботом
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseChannel(TimestampedModel):
|
||||||
|
status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED)
|
||||||
|
planned_cost = fields.FloatField(null=True)
|
||||||
|
comment = fields.TextField(null=True)
|
||||||
|
|
||||||
|
invite_link = fields.CharField(max_length=512)
|
||||||
|
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||||
|
|
||||||
|
purchase: fields.ForeignKeyRelation['Purchase'] = fields.ForeignKeyField(
|
||||||
|
'models.Purchase', related_name='channels', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
|
||||||
|
'models.Channel', related_name='purchase_channels', on_delete=fields.CASCADE, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
purchase_id: uuid.UUID
|
||||||
|
channel_id: uuid.UUID
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
table = 'purchase_channel'
|
||||||
|
unique_together = (('purchase_id', 'channel_id'),)
|
||||||
@@ -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'),)
|
|
||||||
@@ -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'
|
|
||||||
@@ -9,18 +9,19 @@ __all__ = (
|
|||||||
'ProjectOutput',
|
'ProjectOutput',
|
||||||
'ValidateLoginTokenInput',
|
'ValidateLoginTokenInput',
|
||||||
'ValidateLoginTokenOutput',
|
'ValidateLoginTokenOutput',
|
||||||
'TelegramLoginInput',
|
|
||||||
'ChannelBotPermissions',
|
'ChannelBotPermissions',
|
||||||
'ChannelOutput',
|
'ChannelOutput',
|
||||||
'GetChannelInput',
|
'GetChannelInput',
|
||||||
'GetChannelsInput',
|
'GetChannelsInput',
|
||||||
'GetChannelsOutput',
|
'GetChannelsOutput',
|
||||||
'AttachChannelToWorkspaceInput',
|
'AttachChannelToWorkspaceInput',
|
||||||
'PurchasePlanChannelOutput',
|
'PurchaseOutput',
|
||||||
'GetPurchasePlanChannelsInput',
|
'PurchaseChannelOutput',
|
||||||
'GetPurchasePlanChannelsOutput',
|
'CreatePurchaseInput',
|
||||||
'AttachChannelToPurchasePlanInput',
|
'CreatePurchaseChannelInput',
|
||||||
'UpdatePurchasePlanChannelInput',
|
'GetPurchasesInput',
|
||||||
|
'GetPurchasesOutput',
|
||||||
|
'GetPurchaseInput',
|
||||||
'CreativeOutput',
|
'CreativeOutput',
|
||||||
'GetCreativesInput',
|
'GetCreativesInput',
|
||||||
'GetCreativesOutput',
|
'GetCreativesOutput',
|
||||||
@@ -32,9 +33,6 @@ __all__ = (
|
|||||||
'GetPlacementsInput',
|
'GetPlacementsInput',
|
||||||
'GetPlacementsOutput',
|
'GetPlacementsOutput',
|
||||||
'GetPlacementInput',
|
'GetPlacementInput',
|
||||||
'CreatePlacementInput',
|
|
||||||
'UpdatePlacementInput',
|
|
||||||
'DeletePlacementInput',
|
|
||||||
'PostViewsHistoryOutput',
|
'PostViewsHistoryOutput',
|
||||||
'GetViewsHistoryInput',
|
'GetViewsHistoryInput',
|
||||||
'GetViewsHistoryOutput',
|
'GetViewsHistoryOutput',
|
||||||
@@ -104,15 +102,7 @@ from .creative import (
|
|||||||
GetCreativesOutput,
|
GetCreativesOutput,
|
||||||
UpdateCreativeInput,
|
UpdateCreativeInput,
|
||||||
)
|
)
|
||||||
from .placement import (
|
from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput
|
||||||
CreatePlacementInput,
|
|
||||||
DeletePlacementInput,
|
|
||||||
GetPlacementInput,
|
|
||||||
GetPlacementsInput,
|
|
||||||
GetPlacementsOutput,
|
|
||||||
PlacementOutput,
|
|
||||||
UpdatePlacementInput,
|
|
||||||
)
|
|
||||||
from .project import (
|
from .project import (
|
||||||
ChannelBotPermissions,
|
ChannelBotPermissions,
|
||||||
ConnectProjectInput,
|
ConnectProjectInput,
|
||||||
@@ -124,14 +114,15 @@ from .project import (
|
|||||||
UpdateProjectInviteLinkTypeInput,
|
UpdateProjectInviteLinkTypeInput,
|
||||||
UpdateProjectPermissionsInput,
|
UpdateProjectPermissionsInput,
|
||||||
)
|
)
|
||||||
from .purchase_plan import (
|
from .purchase import (
|
||||||
AttachChannelToPurchasePlanInput,
|
CreatePurchaseChannelInput,
|
||||||
GetPurchasePlanChannelsInput,
|
CreatePurchaseInput,
|
||||||
GetPurchasePlanChannelsOutput,
|
GetPurchaseInput,
|
||||||
PurchasePlanChannelOutput,
|
GetPurchasesInput,
|
||||||
UpdatePurchasePlanChannelInput,
|
GetPurchasesOutput,
|
||||||
|
PurchaseChannelOutput,
|
||||||
|
PurchaseOutput,
|
||||||
)
|
)
|
||||||
from .telegram_login import TelegramLoginInput
|
|
||||||
from .user import UserOutput
|
from .user import UserOutput
|
||||||
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput
|
||||||
from .views import (
|
from .views import (
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ class PlacementOutput(pydantic.BaseModel):
|
|||||||
cost: float | None
|
cost: float | None
|
||||||
comment: str | None
|
comment: str | None
|
||||||
ad_post_url: str | None
|
ad_post_url: str | None
|
||||||
invite_link_type: domain.InviteLinkType
|
invite_link_type: domain.purchase.InviteLinkType
|
||||||
invite_link: str
|
invite_link: str
|
||||||
status: domain.PlacementStatus
|
status: domain.PlacementStatus
|
||||||
subscriptions_count: int
|
subscriptions_count: int
|
||||||
|
purchase_id: uuid.UUID | None = None
|
||||||
|
purchase_channel_id: uuid.UUID | None = None
|
||||||
created_at: datetime.datetime
|
created_at: datetime.datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ class ProjectOutput(pydantic.BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
username: str | None
|
username: str | None
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
invite_link_type: domain.InviteLinkType
|
purchase_invite_type_default: domain.purchase.InviteLinkType
|
||||||
|
|
||||||
|
|
||||||
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
|
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
|
||||||
invite_link_type: domain.InviteLinkType
|
purchase_invite_type_default: domain.purchase.InviteLinkType
|
||||||
|
|
||||||
|
|
||||||
class GetWorkspaceProjectsInput(pydantic.BaseModel):
|
class GetWorkspaceProjectsInput(pydantic.BaseModel):
|
||||||
|
|||||||
53
src/dto/purchase.py
Normal file
53
src/dto/purchase.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
|
||||||
|
from src.domain.purchase import InviteLinkType, PurchaseChannelStatus, PurchaseStatus
|
||||||
|
|
||||||
|
from .channel import ChannelOutput
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseChannelOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: PurchaseChannelStatus
|
||||||
|
planned_cost: float | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
invite_link: str
|
||||||
|
invite_link_type: InviteLinkType
|
||||||
|
channel: ChannelOutput
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: PurchaseStatus
|
||||||
|
creative_id: uuid.UUID
|
||||||
|
channels: list[PurchaseChannelOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePurchaseChannelInput(pydantic.BaseModel):
|
||||||
|
username: str = pydantic.Field(pattern=r'^[^@]')
|
||||||
|
status: PurchaseChannelStatus | None = None
|
||||||
|
planned_cost: float | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePurchaseInput(pydantic.BaseModel):
|
||||||
|
creative_id: uuid.UUID
|
||||||
|
channels: list[CreatePurchaseChannelInput]
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchasesInput(pydantic.BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
workspace_id: uuid.UUID
|
||||||
|
project_id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchasesOutput(pydantic.BaseModel):
|
||||||
|
purchases: list[PurchaseOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseInput(pydantic.BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
workspace_id: uuid.UUID
|
||||||
|
project_id: uuid.UUID
|
||||||
|
purchase_id: uuid.UUID
|
||||||
@@ -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
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import pydantic
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramLoginInput(pydantic.BaseModel):
|
|
||||||
telegram_id: int
|
|
||||||
username: str | None
|
|
||||||
chat_id: int
|
|
||||||
@@ -3,7 +3,6 @@ import typing
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
from aiogram.types import InlineKeyboardButton
|
from aiogram.types import InlineKeyboardButton
|
||||||
|
|
||||||
@@ -13,8 +12,8 @@ from .analytics.get_channel_analytics import get_channel_analytics
|
|||||||
from .analytics.get_creatives_analytics import get_creatives_analytics
|
from .analytics.get_creatives_analytics import get_creatives_analytics
|
||||||
from .analytics.get_placements_analytics import get_placements_analytics
|
from .analytics.get_placements_analytics import get_placements_analytics
|
||||||
from .analytics.get_spending_analytics import get_spending_analytics
|
from .analytics.get_spending_analytics import get_spending_analytics
|
||||||
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
|
|
||||||
from .auth.create_telegram_login_token import create_telegram_login_token
|
from .auth.create_telegram_login_token import create_telegram_login_token
|
||||||
|
from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
|
||||||
from .auth.get_me import get_me
|
from .auth.get_me import get_me
|
||||||
from .auth.validate_login_token import validate_login_token
|
from .auth.validate_login_token import validate_login_token
|
||||||
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
|
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
|
||||||
@@ -25,22 +24,18 @@ from .creative.delete_creative import delete_creative
|
|||||||
from .creative.get_creative import get_creative
|
from .creative.get_creative import get_creative
|
||||||
from .creative.get_creatives import get_creatives
|
from .creative.get_creatives import get_creatives
|
||||||
from .creative.update_creative import update_creative
|
from .creative.update_creative import update_creative
|
||||||
from .placement.create_placement import create_placement
|
|
||||||
from .placement.delete_placement import delete_placement
|
|
||||||
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
|
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle
|
||||||
from .placement.get_placement import get_placement
|
from .placement.get_placement import get_placement
|
||||||
from .placement.get_placements import get_placements
|
from .placement.get_placements import get_placements
|
||||||
from .placement.update_placement import update_placement
|
|
||||||
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
|
||||||
from .project.get_project import get_project
|
from .project.get_project import get_project
|
||||||
from .project.get_workspace_projects import get_workspace_projects
|
from .project.get_workspace_projects import get_workspace_projects
|
||||||
from .project.tg_add_project import tg_add_project
|
from .project.tg_add_project import tg_add_project
|
||||||
from .project.update_project_invite_link_type import update_project_invite_link_type
|
from .project.update_project_invite_link_type import update_project_invite_link_type
|
||||||
from .project.update_project_permissions import update_project_permissions
|
from .project.update_project_permissions import update_project_permissions
|
||||||
from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan
|
from .purchase.create_purchase import create_purchase
|
||||||
from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels
|
from .purchase.get_purchase import get_purchase
|
||||||
from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel
|
from .purchase.get_purchases import get_purchases
|
||||||
from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_channel
|
|
||||||
from .subscription.handle_subscription import handle_subscription
|
from .subscription.handle_subscription import handle_subscription
|
||||||
from .subscription.handle_unsubscription import handle_unsubscription
|
from .subscription.handle_unsubscription import handle_unsubscription
|
||||||
from .views.get_views_history import get_views_history
|
from .views.get_views_history import get_views_history
|
||||||
@@ -155,31 +150,37 @@ class Database(typing.Protocol):
|
|||||||
self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None
|
self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None
|
||||||
) -> list[domain.Project]: ...
|
) -> list[domain.Project]: ...
|
||||||
|
|
||||||
async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ...
|
async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ...
|
||||||
|
|
||||||
async def get_purchase_plan(self, workspace_id: UUID, plan_id: UUID) -> domain.PurchasePlan | None: ...
|
async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ...
|
||||||
|
|
||||||
async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: ...
|
async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ...
|
||||||
|
|
||||||
async def get_purchase_plan_channels(self, plan_id: UUID) -> list[domain.PurchasePlanChannel]: ...
|
async def create_purchase(self, purchase: domain.Purchase) -> None: ...
|
||||||
|
|
||||||
async def get_purchase_plan_channel(
|
async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ...
|
||||||
self, plan_channel_id: UUID, plan_id: UUID
|
|
||||||
) -> domain.PurchasePlanChannel | None: ...
|
|
||||||
|
|
||||||
async def add_channel_to_purchase_plan(
|
async def get_purchase_channel(
|
||||||
|
self, purchase_channel_id: UUID, purchase_id: UUID
|
||||||
|
) -> domain.PurchaseChannel | None: ...
|
||||||
|
|
||||||
|
async def get_purchase_channels_by_project(self, project_id: UUID) -> list[domain.PurchaseChannel]: ...
|
||||||
|
|
||||||
|
async def add_channel_to_purchase(
|
||||||
self,
|
self,
|
||||||
plan_id: UUID,
|
purchase_id: UUID,
|
||||||
channel_id: UUID,
|
channel_id: UUID,
|
||||||
|
invite_link: str,
|
||||||
|
invite_link_type: domain.purchase.InviteLinkType,
|
||||||
*,
|
*,
|
||||||
status: domain.PurchasePlanChannelStatus | None = None,
|
status: domain.PurchaseChannelStatus | None = None,
|
||||||
planned_cost: float | None = None,
|
planned_cost: float | None = None,
|
||||||
comment: str | None = None,
|
comment: str | None = None,
|
||||||
) -> domain.PurchasePlanChannel: ...
|
) -> domain.PurchaseChannel: ...
|
||||||
|
|
||||||
async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: ...
|
async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ...
|
||||||
|
|
||||||
async def remove_channel_from_purchase_plan(self, plan_id: UUID, channel_id: UUID) -> None: ...
|
async def remove_channel_from_purchase(self, purchase_id: UUID, channel_id: UUID) -> None: ...
|
||||||
|
|
||||||
async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
|
async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
|
||||||
|
|
||||||
@@ -197,10 +198,6 @@ class Database(typing.Protocol):
|
|||||||
|
|
||||||
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
|
async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ...
|
||||||
|
|
||||||
async def create_placement(self, placement: domain.Placement) -> None: ...
|
|
||||||
|
|
||||||
async def update_placement(self, placement: domain.Placement) -> None: ...
|
|
||||||
|
|
||||||
async def get_workspace_placements(
|
async def get_workspace_placements(
|
||||||
self,
|
self,
|
||||||
workspace_id: UUID,
|
workspace_id: UUID,
|
||||||
@@ -264,14 +261,6 @@ class Database(typing.Protocol):
|
|||||||
|
|
||||||
async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ...
|
async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ...
|
||||||
|
|
||||||
async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ...
|
|
||||||
|
|
||||||
async def set_telegram_state(
|
|
||||||
self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any]
|
|
||||||
) -> domain.TelegramState: ...
|
|
||||||
|
|
||||||
async def clear_telegram_state(self, telegram_id: int) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramBotWriter(typing.Protocol):
|
class TelegramBotWriter(typing.Protocol):
|
||||||
async def send_message(self, text: str, chat_id: int) -> None: ...
|
async def send_message(self, text: str, chat_id: int) -> None: ...
|
||||||
@@ -362,14 +351,14 @@ class Usecase:
|
|||||||
|
|
||||||
return workspace
|
return workspace
|
||||||
|
|
||||||
async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan:
|
async def ensure_active_purchase(self, project: domain.Project, creative: domain.Creative) -> domain.Purchase:
|
||||||
plan = await self.database.get_active_purchase_plan(project.id)
|
purchase = await self.database.get_active_purchase(project.id, creative.id)
|
||||||
if plan:
|
if purchase:
|
||||||
return plan
|
return purchase
|
||||||
|
|
||||||
plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id)
|
purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id)
|
||||||
await self.database.create_purchase_plan(plan)
|
await self.database.create_purchase(purchase)
|
||||||
return plan
|
return purchase
|
||||||
|
|
||||||
validate_login_token = validate_login_token
|
validate_login_token = validate_login_token
|
||||||
create_telegram_login_token = create_telegram_login_token
|
create_telegram_login_token = create_telegram_login_token
|
||||||
@@ -384,10 +373,9 @@ class Usecase:
|
|||||||
get_channels = get_channels
|
get_channels = get_channels
|
||||||
get_channel = get_channel
|
get_channel = get_channel
|
||||||
attach_channel_to_workspace = attach_channel_to_workspace
|
attach_channel_to_workspace = attach_channel_to_workspace
|
||||||
attach_channel_to_purchase_plan = attach_channel_to_purchase_plan
|
create_purchase = create_purchase
|
||||||
get_purchase_plan_channels = get_purchase_plan_channels
|
get_purchases = get_purchases
|
||||||
update_purchase_plan_channel = update_purchase_plan_channel
|
get_purchase = get_purchase
|
||||||
remove_purchase_plan_channel = remove_purchase_plan_channel
|
|
||||||
get_creatives = get_creatives
|
get_creatives = get_creatives
|
||||||
get_creative = get_creative
|
get_creative = get_creative
|
||||||
create_creative = create_creative
|
create_creative = create_creative
|
||||||
@@ -395,9 +383,6 @@ class Usecase:
|
|||||||
delete_creative = delete_creative
|
delete_creative = delete_creative
|
||||||
get_placements = get_placements
|
get_placements = get_placements
|
||||||
get_placement = get_placement
|
get_placement = get_placement
|
||||||
create_placement = create_placement
|
|
||||||
update_placement = update_placement
|
|
||||||
delete_placement = delete_placement
|
|
||||||
fetch_placement_post_cycle = fetch_placement_post_cycle
|
fetch_placement_post_cycle = fetch_placement_post_cycle
|
||||||
handle_subscription = handle_subscription
|
handle_subscription = handle_subscription
|
||||||
handle_unsubscription = handle_unsubscription
|
handle_unsubscription = handle_unsubscription
|
||||||
|
|||||||
@@ -25,9 +25,8 @@ async def get_channel_analytics(
|
|||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||||
plan = await self.ensure_active_purchase_plan(project)
|
purchase_channels = await self.database.get_purchase_channels_by_project(project.id)
|
||||||
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
|
channels = [pc.channel for pc in purchase_channels]
|
||||||
channels = [pc.channel for pc in plan_channels]
|
|
||||||
allowed_project_filter = None
|
allowed_project_filter = None
|
||||||
else:
|
else:
|
||||||
allowed_project_filter = allowed_project_ids
|
allowed_project_filter = allowed_project_ids
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ async def get_placements_analytics(
|
|||||||
placement_channel_title=p.placement_channel.title,
|
placement_channel_title=p.placement_channel.title,
|
||||||
creative_id=p.creative_id,
|
creative_id=p.creative_id,
|
||||||
creative_name=p.creative.name,
|
creative_name=p.creative.name,
|
||||||
placement_date=p.placement_date,
|
placement_date=p.wanted_placement_date,
|
||||||
cost=p.cost,
|
cost=p.cost,
|
||||||
subscriptions_count=subscriptions_counts.get(p.id, 0),
|
subscriptions_count=subscriptions_counts.get(p.id, 0),
|
||||||
views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None,
|
views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None,
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ async def get_spending_analytics(
|
|||||||
filtered = [
|
filtered = [
|
||||||
p
|
p
|
||||||
for p in placements
|
for p in placements
|
||||||
if (not input.date_from or p.placement_date >= input.date_from)
|
if (not input.date_from or p.wanted_placement_date >= input.date_from)
|
||||||
and (not input.date_to or p.placement_date <= input.date_to)
|
and (not input.date_to or p.wanted_placement_date <= input.date_to)
|
||||||
]
|
]
|
||||||
|
|
||||||
total_cost = 0.0
|
total_cost = 0.0
|
||||||
@@ -82,7 +82,7 @@ async def get_spending_analytics(
|
|||||||
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
period_data: dict[str, PeriodData] = defaultdict(PeriodData)
|
||||||
|
|
||||||
for p in filtered:
|
for p in filtered:
|
||||||
period = _format_period(p.placement_date, input.grouping)
|
period = _format_period(p.wanted_placement_date, input.grouping)
|
||||||
pd = period_data[period]
|
pd = period_data[period]
|
||||||
|
|
||||||
if p.cost is not None:
|
if p.cost is not None:
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ async def attach_channel_to_workspace(
|
|||||||
await self.database.create_project(project)
|
await self.database.create_project(project)
|
||||||
log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
|
log.info('Project created for channel %s in workspace %s', channel.id, workspace.id)
|
||||||
|
|
||||||
await self.ensure_active_purchase_plan(project)
|
|
||||||
|
|
||||||
return dto.ProjectOutput(
|
return dto.ProjectOutput(
|
||||||
id=project.id,
|
id=project.id,
|
||||||
telegram_id=channel.telegram_id,
|
telegram_id=channel.telegram_id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import domain.purchase
|
||||||
from src import domain, dto
|
from src import domain, dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -35,7 +36,7 @@ async def create_placement(
|
|||||||
raise domain.CreativeNotFound(input.creative_id)
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
|
||||||
# Берем тип ссылки из настроек проекта
|
# Берем тип ссылки из настроек проекта
|
||||||
requires_approval = project.invite_link_type == domain.InviteLinkType.APPROVAL
|
requires_approval = project.purchase_invite_type_default == domain.purchase.InviteLinkType.APPROVAL
|
||||||
|
|
||||||
if project.channel.telegram_id is None:
|
if project.channel.telegram_id is None:
|
||||||
raise domain.ChannelNotFound(project.channel.id)
|
raise domain.ChannelNotFound(project.channel.id)
|
||||||
@@ -64,11 +65,11 @@ async def create_placement(
|
|||||||
placement_channel_title=placement_channel.title,
|
placement_channel_title=placement_channel.title,
|
||||||
creative_id=placement.creative_id,
|
creative_id=placement.creative_id,
|
||||||
creative_name=creative.name,
|
creative_name=creative.name,
|
||||||
placement_date=placement.placement_date,
|
placement_date=placement.wanted_placement_date,
|
||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=None,
|
ad_post_url=None,
|
||||||
invite_link_type=project.invite_link_type,
|
invite_link_type=project.purchase_invite_type_default,
|
||||||
invite_link=placement.invite_link,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=0,
|
subscriptions_count=0,
|
||||||
|
|||||||
@@ -12,52 +12,56 @@ log = logging.getLogger(__name__)
|
|||||||
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
|
async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None:
|
||||||
log.debug('Starting fetch_placement_post_cycle')
|
log.debug('Starting fetch_placement_post_cycle')
|
||||||
|
|
||||||
placements_without_post = (
|
purchase_channels = (
|
||||||
await domain.Placement.filter(
|
await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE)
|
||||||
post_id__isnull=True,
|
.prefetch_related('channel', 'purchase')
|
||||||
)
|
|
||||||
.prefetch_related('placement_channel')
|
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
if not placements_without_post:
|
if not purchase_channels:
|
||||||
log.debug('No placements without posts found')
|
log.debug('No active purchase channels found')
|
||||||
return
|
return
|
||||||
|
|
||||||
log.info(f'Found {len(placements_without_post)} placements without posts')
|
created_count = 0
|
||||||
|
|
||||||
updated_count = 0
|
for purchase_channel in purchase_channels:
|
||||||
|
if purchase_channel.channel is None or purchase_channel.purchase is None:
|
||||||
for placement in placements_without_post:
|
log.warning('Purchase channel %s missing channel or purchase, skipping', purchase_channel.id)
|
||||||
try:
|
|
||||||
matching_post = await domain.Post.filter(
|
|
||||||
channel_id=placement.placement_channel_id,
|
|
||||||
text__contains=placement.invite_link,
|
|
||||||
deleted_from_channel_at__isnull=True,
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if matching_post:
|
|
||||||
# Validate channel consistency
|
|
||||||
if matching_post.channel_id != placement.placement_channel_id:
|
|
||||||
log.error(
|
|
||||||
'Post %s channel_id mismatch: expected %s, got %s. Skipping placement %s',
|
|
||||||
matching_post.id,
|
|
||||||
placement.placement_channel_id,
|
|
||||||
matching_post.channel_id,
|
|
||||||
placement.id,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
placement.post = matching_post
|
|
||||||
await placement.save()
|
|
||||||
updated_count += 1
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
f'Linked post {matching_post.id} (message_id={matching_post.message_id}) '
|
|
||||||
f'to placement {placement.id}'
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(f'Error processing placement {placement.id}: {e}')
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
log.info(f'Fetch placement post cycle completed. Updated {updated_count} placements')
|
posts = await domain.Post.filter(
|
||||||
|
channel_id=purchase_channel.channel_id,
|
||||||
|
text__contains=purchase_channel.invite_link,
|
||||||
|
deleted_from_channel_at__isnull=True,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
existing = await domain.Placement.filter(post_id=post.id).first()
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
placement = domain.Placement(
|
||||||
|
wanted_placement_date=post.created_at,
|
||||||
|
cost=None,
|
||||||
|
comment=None,
|
||||||
|
invite_link=purchase_channel.invite_link,
|
||||||
|
workspace_id=purchase_channel.purchase.workspace_id,
|
||||||
|
project_id=purchase_channel.purchase.project_id,
|
||||||
|
creative_id=purchase_channel.purchase.creative_id,
|
||||||
|
placement_channel_id=purchase_channel.channel_id,
|
||||||
|
purchase_id=purchase_channel.purchase_id,
|
||||||
|
purchase_channel_id=purchase_channel.id,
|
||||||
|
post=post,
|
||||||
|
status=domain.PlacementStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.database.create_placement(placement)
|
||||||
|
created_count += 1
|
||||||
|
log.info(
|
||||||
|
'Created placement %s for purchase_channel %s from post %s',
|
||||||
|
placement.id,
|
||||||
|
purchase_channel.id,
|
||||||
|
post.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('Fetch placement post cycle completed. Created %s placements', created_count)
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
|
|||||||
placement_channel_title=placement.placement_channel.title,
|
placement_channel_title=placement.placement_channel.title,
|
||||||
creative_id=placement.creative_id,
|
creative_id=placement.creative_id,
|
||||||
creative_name=placement.creative.name,
|
creative_name=placement.creative.name,
|
||||||
placement_date=placement.placement_date,
|
placement_date=placement.wanted_placement_date,
|
||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=ad_post_url,
|
ad_post_url=ad_post_url,
|
||||||
invite_link_type=placement.project.invite_link_type,
|
invite_link_type=placement.project.purchase_invite_type_default,
|
||||||
invite_link=placement.invite_link,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=subscriptions_count,
|
subscriptions_count=subscriptions_count,
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
|
|||||||
placement_channel_title=placement.placement_channel.title,
|
placement_channel_title=placement.placement_channel.title,
|
||||||
creative_id=placement.creative_id,
|
creative_id=placement.creative_id,
|
||||||
creative_name=placement.creative.name,
|
creative_name=placement.creative.name,
|
||||||
placement_date=placement.placement_date,
|
placement_date=placement.wanted_placement_date,
|
||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=ad_post_url,
|
ad_post_url=ad_post_url,
|
||||||
invite_link_type=placement.project.invite_link_type,
|
invite_link_type=placement.project.purchase_invite_type_default,
|
||||||
invite_link=placement.invite_link,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=subscriptions_counts.get(placement.id, 0),
|
subscriptions_count=subscriptions_counts.get(placement.id, 0),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ async def update_placement(
|
|||||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
|
||||||
|
|
||||||
if input.placement_date is not None:
|
if input.placement_date is not None:
|
||||||
placement.placement_date = input.placement_date
|
placement.wanted_placement_date = input.placement_date
|
||||||
if input.cost is not None:
|
if input.cost is not None:
|
||||||
placement.cost = input.cost
|
placement.cost = input.cost
|
||||||
if input.comment is not None:
|
if input.comment is not None:
|
||||||
@@ -49,11 +49,11 @@ async def update_placement(
|
|||||||
placement_channel_title=placement.placement_channel.title,
|
placement_channel_title=placement.placement_channel.title,
|
||||||
creative_id=placement.creative_id,
|
creative_id=placement.creative_id,
|
||||||
creative_name=placement.creative.name,
|
creative_name=placement.creative.name,
|
||||||
placement_date=placement.placement_date,
|
placement_date=placement.wanted_placement_date,
|
||||||
cost=placement.cost,
|
cost=placement.cost,
|
||||||
comment=placement.comment,
|
comment=placement.comment,
|
||||||
ad_post_url=ad_post_url,
|
ad_post_url=ad_post_url,
|
||||||
invite_link_type=placement.project.invite_link_type,
|
invite_link_type=placement.project.purchase_invite_type_default,
|
||||||
invite_link=placement.invite_link,
|
invite_link=placement.invite_link,
|
||||||
status=placement.status,
|
status=placement.status,
|
||||||
subscriptions_count=subscriptions_count,
|
subscriptions_count=subscriptions_count,
|
||||||
|
|||||||
@@ -20,5 +20,5 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec
|
|||||||
title=project.channel.title,
|
title=project.channel.title,
|
||||||
username=project.channel.username,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
invite_link_type=project.invite_link_type,
|
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ if TYPE_CHECKING:
|
|||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
|
|
||||||
|
|
||||||
async def get_workspace_projects(
|
async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]:
|
||||||
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
|
|
||||||
) -> list[dto.ProjectOutput]:
|
|
||||||
context = await self.ensure_workspace_permission(
|
context = await self.ensure_workspace_permission(
|
||||||
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
||||||
)
|
)
|
||||||
@@ -24,7 +22,7 @@ async def get_workspace_projects(
|
|||||||
title=project.channel.title,
|
title=project.channel.title,
|
||||||
username=project.channel.username,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
invite_link_type=project.invite_link_type,
|
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||||
)
|
)
|
||||||
for project in projects
|
for project in projects
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -104,8 +104,6 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
|
|||||||
)
|
)
|
||||||
await self.database.create_project(project)
|
await self.database.create_project(project)
|
||||||
|
|
||||||
await self.ensure_active_purchase_plan(project)
|
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
'Project for channel %s connected/updated successfully in workspace %s by user %s',
|
'Project for channel %s connected/updated successfully in workspace %s by user %s',
|
||||||
input.telegram_id,
|
input.telegram_id,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import domain
|
from src import domain, dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
@@ -14,9 +14,9 @@ async def update_project_invite_link_type(
|
|||||||
self: 'Usecase',
|
self: 'Usecase',
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
project_id: uuid.UUID,
|
project_id: uuid.UUID,
|
||||||
invite_link_type: domain.InviteLinkType,
|
purchase_invite_type_default: domain.purchase.InviteLinkType,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
) -> domain.Project:
|
) -> dto.ProjectOutput:
|
||||||
await self.ensure_workspace_permission(
|
await self.ensure_workspace_permission(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -24,19 +24,19 @@ async def update_project_invite_link_type(
|
|||||||
for_project_id=project_id,
|
for_project_id=project_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self.database.transaction():
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
if not project:
|
||||||
if not project:
|
raise domain.ProjectNotFound(project_id)
|
||||||
raise domain.ProjectNotFound(project_id)
|
|
||||||
|
|
||||||
project.invite_link_type = invite_link_type
|
project.purchase_invite_type_default = purchase_invite_type_default
|
||||||
await project.save()
|
|
||||||
|
|
||||||
log.info(
|
await self.database.update_project(project)
|
||||||
'Project %s invite_link_type updated to %s by user %s',
|
|
||||||
project_id,
|
|
||||||
invite_link_type,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return project
|
return dto.ProjectOutput(
|
||||||
|
id=project.id,
|
||||||
|
telegram_id=project.channel.telegram_id,
|
||||||
|
title=project.channel.title,
|
||||||
|
username=project.channel.username,
|
||||||
|
status=project.status,
|
||||||
|
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||||
|
)
|
||||||
|
|||||||
117
src/usecase/purchase/create_purchase.py
Normal file
117
src/usecase/purchase/create_purchase.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain, dto
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_purchase_output(
|
||||||
|
purchase: domain.Purchase, purchase_channels: list[domain.PurchaseChannel]
|
||||||
|
) -> dto.PurchaseOutput:
|
||||||
|
channels_output: list[dto.PurchaseChannelOutput] = []
|
||||||
|
for purchase_channel in purchase_channels:
|
||||||
|
channel = purchase_channel.channel
|
||||||
|
if channel is None:
|
||||||
|
log.warning('Purchase channel %s has no channel prefetched', purchase_channel.id)
|
||||||
|
continue
|
||||||
|
channels_output.append(
|
||||||
|
dto.PurchaseChannelOutput(
|
||||||
|
id=purchase_channel.id,
|
||||||
|
status=purchase_channel.status,
|
||||||
|
planned_cost=purchase_channel.planned_cost,
|
||||||
|
comment=purchase_channel.comment,
|
||||||
|
invite_link=purchase_channel.invite_link,
|
||||||
|
invite_link_type=purchase_channel.invite_link_type,
|
||||||
|
channel=dto.ChannelOutput(
|
||||||
|
id=channel.id,
|
||||||
|
telegram_id=channel.telegram_id,
|
||||||
|
title=channel.title,
|
||||||
|
username=channel.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return dto.PurchaseOutput(
|
||||||
|
id=purchase.id,
|
||||||
|
status=purchase.status,
|
||||||
|
creative_id=purchase.creative_id,
|
||||||
|
channels=channels_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_purchase(
|
||||||
|
self: 'Usecase',
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
input: dto.CreatePurchaseInput,
|
||||||
|
) -> dto.PurchaseOutput:
|
||||||
|
context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
|
||||||
|
|
||||||
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
|
if not project:
|
||||||
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
|
creative = await self.database.get_creative(workspace_id, input.creative_id)
|
||||||
|
if not creative or creative.project_id != project.id:
|
||||||
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
|
||||||
|
existing_purchase = await self.database.get_active_purchase(project.id, creative.id)
|
||||||
|
if existing_purchase:
|
||||||
|
channels = await self.database.get_purchase_channels(existing_purchase.id)
|
||||||
|
return _build_purchase_output(existing_purchase, channels)
|
||||||
|
|
||||||
|
purchase = domain.Purchase(workspace_id=workspace_id, project_id=project.id, creative_id=creative.id)
|
||||||
|
await self.database.create_purchase(purchase)
|
||||||
|
|
||||||
|
if project.channel.telegram_id is None:
|
||||||
|
raise domain.ChannelNotFound(project.channel.id)
|
||||||
|
|
||||||
|
invite_link_type = project.purchase_invite_type_default
|
||||||
|
requires_approval = invite_link_type == domain.purchase.InviteLinkType.APPROVAL
|
||||||
|
|
||||||
|
purchase_channels: list[domain.PurchaseChannel] = []
|
||||||
|
|
||||||
|
for channel_input in input.channels:
|
||||||
|
channel = await self.database.get_channel(username=channel_input.username)
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
channel = domain.Channel(
|
||||||
|
username=channel_input.username,
|
||||||
|
telegram_id=random.randint(1, 10000),
|
||||||
|
title='parser_response.title',
|
||||||
|
)
|
||||||
|
await self.database.create_channel(channel)
|
||||||
|
log.info('Created channel @%s with telegram_id=%s', channel_input.username, channel.telegram_id)
|
||||||
|
|
||||||
|
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
||||||
|
|
||||||
|
purchase_channel = await self.database.add_channel_to_purchase(
|
||||||
|
purchase.id,
|
||||||
|
channel.id,
|
||||||
|
invite_link=invite_link,
|
||||||
|
invite_link_type=invite_link_type,
|
||||||
|
status=channel_input.status,
|
||||||
|
planned_cost=channel_input.planned_cost,
|
||||||
|
comment=channel_input.comment,
|
||||||
|
)
|
||||||
|
purchase_channel.channel = channel
|
||||||
|
purchase_channels.append(purchase_channel)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'Purchase %s created for project %s (creative %s) with %s channels',
|
||||||
|
purchase.id,
|
||||||
|
project.id,
|
||||||
|
creative.id,
|
||||||
|
len(purchase_channels),
|
||||||
|
)
|
||||||
|
|
||||||
|
return _build_purchase_output(purchase, purchase_channels)
|
||||||
30
src/usecase/purchase/get_purchase.py
Normal file
30
src/usecase/purchase/get_purchase.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain, dto
|
||||||
|
from .create_purchase import _build_purchase_output
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput:
|
||||||
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
||||||
|
if not project:
|
||||||
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
|
||||||
|
|
||||||
|
purchase = await self.database.get_purchase(input.workspace_id, input.purchase_id)
|
||||||
|
if not purchase or purchase.project_id != project.id:
|
||||||
|
raise domain.PurchaseNotFound(input.purchase_id)
|
||||||
|
|
||||||
|
channels = await self.database.get_purchase_channels(purchase.id)
|
||||||
|
|
||||||
|
return _build_purchase_output(purchase, channels)
|
||||||
32
src/usecase/purchase/get_purchases.py
Normal file
32
src/usecase/purchase/get_purchases.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain, dto
|
||||||
|
from .create_purchase import _build_purchase_output
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .. import Usecase
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> list[dto.PurchaseOutput]:
|
||||||
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
||||||
|
if not project:
|
||||||
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
|
||||||
|
|
||||||
|
purchases = await self.database.get_project_purchases(input.workspace_id, project.id)
|
||||||
|
log.debug('Fetched %s purchases for project %s', len(purchases), project.id)
|
||||||
|
|
||||||
|
output: list[dto.PurchaseOutput] = []
|
||||||
|
for purchase in purchases:
|
||||||
|
channels = await self.database.get_purchase_channels(purchase.id)
|
||||||
|
output.append(_build_purchase_output(purchase, channels))
|
||||||
|
|
||||||
|
return output
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
@@ -141,12 +141,12 @@ type Workspace struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TelegramID int64 `json:"telegram_id"`
|
TelegramID int64 `json:"telegram_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
InviteLinkType string `json:"invite_link_type"` // "public" или "approval"
|
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Page struct {
|
type Page struct {
|
||||||
@@ -231,7 +231,7 @@ func (c *Client) UpdateProjectInviteLinkType(
|
|||||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID)
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID)
|
||||||
|
|
||||||
payload := map[string]string{
|
payload := map[string]string{
|
||||||
"invite_link_type": inviteLinkType,
|
"purchase_invite_type_default": inviteLinkType,
|
||||||
}
|
}
|
||||||
|
|
||||||
var project Project
|
var project Project
|
||||||
@@ -306,13 +306,14 @@ func (c *Client) AcceptWorkspaceInvite(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type Creative struct {
|
type Creative struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id"`
|
Name string `json:"name"`
|
||||||
Name string `json:"name"`
|
Text string `json:"text"`
|
||||||
Text string `json:"text"`
|
ProjectID string `json:"project_id"`
|
||||||
IsArchived bool `json:"is_archived"`
|
ProjectChannelTitle string `json:"project_channel_title"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
Status string `json:"status"`
|
||||||
|
PlacementsCount int `json:"placements_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreativesPage struct {
|
type CreativesPage struct {
|
||||||
@@ -406,9 +407,9 @@ func (c *Client) CreateCreative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateCreativeInput struct {
|
type UpdateCreativeInput struct {
|
||||||
Name *string `json:"name,omitempty"`
|
Name *string `json:"name,omitempty"`
|
||||||
Text *string `json:"text,omitempty"`
|
Text *string `json:"text,omitempty"`
|
||||||
IsArchived *bool `json:"is_archived,omitempty"`
|
Status *string `json:"status,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) UpdateCreative(
|
func (c *Client) UpdateCreative(
|
||||||
@@ -456,17 +457,24 @@ func (c *Client) DeleteCreative(
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type Placement struct {
|
type Placement struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
PlacementChannelID string `json:"placement_channel_id"`
|
ProjectChannelTitle *string `json:"project_channel_title"`
|
||||||
CreativeID *string `json:"creative_id"`
|
PlacementChannelID string `json:"placement_channel_id"`
|
||||||
Name string `json:"name"`
|
PlacementChannelTitle *string `json:"placement_channel_title"`
|
||||||
Price *int `json:"price"`
|
CreativeID string `json:"creative_id"`
|
||||||
Status string `json:"status"`
|
CreativeName string `json:"creative_name"`
|
||||||
InviteLink *string `json:"invite_link"`
|
PlacementDate string `json:"placement_date"`
|
||||||
IsArchived bool `json:"is_archived"`
|
Cost *float64 `json:"cost"`
|
||||||
CreatedAt string `json:"created_at"`
|
Comment *string `json:"comment"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
AdPostURL *string `json:"ad_post_url"`
|
||||||
|
InviteLinkType string `json:"invite_link_type"`
|
||||||
|
InviteLink string `json:"invite_link"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SubscriptionsCount int `json:"subscriptions_count"`
|
||||||
|
PurchaseID *string `json:"purchase_id"`
|
||||||
|
PurchaseChannelID *string `json:"purchase_channel_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlacementsPage struct {
|
type PlacementsPage struct {
|
||||||
@@ -537,84 +545,10 @@ func (c *Client) GetPlacement(
|
|||||||
return &placement, err
|
return &placement, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreatePlacementInput struct {
|
// Note: Placement creation/updates are handled by worker; API exposes only read endpoints.
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
PlacementChannelID string `json:"placement_channel_id"`
|
|
||||||
CreativeID *string `json:"creative_id,omitempty"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Price *int `json:"price,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) CreatePlacement(
|
|
||||||
ctx context.Context,
|
|
||||||
jwt string,
|
|
||||||
workspaceID string,
|
|
||||||
input CreatePlacementInput,
|
|
||||||
) (*Placement, error) {
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements", workspaceID)
|
|
||||||
|
|
||||||
var placement Placement
|
|
||||||
err := c.do(
|
|
||||||
ctx,
|
|
||||||
http.MethodPost,
|
|
||||||
path,
|
|
||||||
input,
|
|
||||||
&placement,
|
|
||||||
withBearer(jwt),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &placement, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdatePlacementInput struct {
|
|
||||||
Name *string `json:"name,omitempty"`
|
|
||||||
Price *int `json:"price,omitempty"`
|
|
||||||
Status *string `json:"status,omitempty"`
|
|
||||||
IsArchived *bool `json:"is_archived,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) UpdatePlacement(
|
|
||||||
ctx context.Context,
|
|
||||||
jwt string,
|
|
||||||
workspaceID string,
|
|
||||||
placementID string,
|
|
||||||
input UpdatePlacementInput,
|
|
||||||
) (*Placement, error) {
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
|
|
||||||
|
|
||||||
var placement Placement
|
|
||||||
err := c.do(
|
|
||||||
ctx,
|
|
||||||
http.MethodPatch,
|
|
||||||
path,
|
|
||||||
input,
|
|
||||||
&placement,
|
|
||||||
withBearer(jwt),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &placement, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) DeletePlacement(
|
|
||||||
ctx context.Context,
|
|
||||||
jwt string,
|
|
||||||
workspaceID string,
|
|
||||||
placementID string,
|
|
||||||
) error {
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID)
|
|
||||||
|
|
||||||
return c.do(
|
|
||||||
ctx,
|
|
||||||
http.MethodDelete,
|
|
||||||
path,
|
|
||||||
nil,
|
|
||||||
nil,
|
|
||||||
withBearer(jwt),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Purchase Plan
|
// Purchases (Закупы)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
type Channel struct {
|
type Channel struct {
|
||||||
@@ -624,24 +558,67 @@ type Channel struct {
|
|||||||
Username *string `json:"username"`
|
Username *string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PurchasePlanChannel struct {
|
type Purchase struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Channel Channel `json:"channel"`
|
Status string `json:"status"`
|
||||||
Status string `json:"status"`
|
CreativeID string `json:"creative_id"`
|
||||||
PlannedCost *float64 `json:"planned_cost"`
|
Channels []PurchaseChannelOut `json:"channels"`
|
||||||
Comment *string `json:"comment"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetPurchasePlanChannels(
|
type PurchaseChannelOut struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PlannedCost *float64 `json:"planned_cost"`
|
||||||
|
Comment *string `json:"comment"`
|
||||||
|
InviteLink string `json:"invite_link"`
|
||||||
|
InviteLinkType string `json:"invite_link_type"`
|
||||||
|
Channel Channel `json:"channel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePurchaseChannelInput struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
PlannedCost *float64 `json:"planned_cost,omitempty"`
|
||||||
|
Comment *string `json:"comment,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePurchaseInput struct {
|
||||||
|
CreativeID string `json:"creative_id"`
|
||||||
|
Channels []CreatePurchaseChannelInput `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreatePurchase(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
workspaceID string,
|
workspaceID string,
|
||||||
projectID string,
|
projectID string,
|
||||||
) ([]PurchasePlanChannel, error) {
|
input CreatePurchaseInput,
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID)
|
) (*Purchase, error) {
|
||||||
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
|
||||||
|
|
||||||
|
var purchase Purchase
|
||||||
|
err := c.do(
|
||||||
|
ctx,
|
||||||
|
http.MethodPost,
|
||||||
|
path,
|
||||||
|
input,
|
||||||
|
&purchase,
|
||||||
|
withBearer(jwt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &purchase, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPurchases(
|
||||||
|
ctx context.Context,
|
||||||
|
jwt string,
|
||||||
|
workspaceID string,
|
||||||
|
projectID string,
|
||||||
|
) ([]Purchase, error) {
|
||||||
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID)
|
||||||
|
|
||||||
var resp struct {
|
var resp struct {
|
||||||
Items []PurchasePlanChannel `json:"items"`
|
Items []Purchase `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := c.do(
|
err := c.do(
|
||||||
@@ -656,81 +633,26 @@ func (c *Client) GetPurchasePlanChannels(
|
|||||||
return resp.Items, err
|
return resp.Items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttachChannelToPurchasePlanInput struct {
|
func (c *Client) GetPurchase(
|
||||||
Username string `json:"username"`
|
|
||||||
Status *string `json:"status,omitempty"`
|
|
||||||
PlannedCost *float64 `json:"planned_cost,omitempty"`
|
|
||||||
Comment *string `json:"comment,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) AttachChannelToPurchasePlan(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
jwt string,
|
jwt string,
|
||||||
workspaceID string,
|
workspaceID string,
|
||||||
projectID string,
|
projectID string,
|
||||||
input AttachChannelToPurchasePlanInput,
|
purchaseID string,
|
||||||
) (*PurchasePlanChannel, error) {
|
) (*Purchase, error) {
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID)
|
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID)
|
||||||
|
|
||||||
var planChannel PurchasePlanChannel
|
var purchase Purchase
|
||||||
err := c.do(
|
err := c.do(
|
||||||
ctx,
|
ctx,
|
||||||
http.MethodPost,
|
http.MethodGet,
|
||||||
path,
|
|
||||||
input,
|
|
||||||
&planChannel,
|
|
||||||
withBearer(jwt),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &planChannel, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type UpdatePurchasePlanChannelInput struct {
|
|
||||||
Status *string `json:"status,omitempty"`
|
|
||||||
PlannedCost *float64 `json:"planned_cost,omitempty"`
|
|
||||||
Comment *string `json:"comment,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) UpdatePurchasePlanChannel(
|
|
||||||
ctx context.Context,
|
|
||||||
jwt string,
|
|
||||||
workspaceID string,
|
|
||||||
projectID string,
|
|
||||||
channelID string,
|
|
||||||
input UpdatePurchasePlanChannelInput,
|
|
||||||
) (*PurchasePlanChannel, error) {
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID)
|
|
||||||
|
|
||||||
var planChannel PurchasePlanChannel
|
|
||||||
err := c.do(
|
|
||||||
ctx,
|
|
||||||
http.MethodPatch,
|
|
||||||
path,
|
|
||||||
input,
|
|
||||||
&planChannel,
|
|
||||||
withBearer(jwt),
|
|
||||||
)
|
|
||||||
|
|
||||||
return &planChannel, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) RemoveChannelFromPurchasePlan(
|
|
||||||
ctx context.Context,
|
|
||||||
jwt string,
|
|
||||||
workspaceID string,
|
|
||||||
projectID string,
|
|
||||||
channelID string,
|
|
||||||
) error {
|
|
||||||
path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID)
|
|
||||||
|
|
||||||
return c.do(
|
|
||||||
ctx,
|
|
||||||
http.MethodDelete,
|
|
||||||
path,
|
path,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
&purchase,
|
||||||
withBearer(jwt),
|
withBearer(jwt),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return &purchase, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
|
|||||||
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
||||||
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
|
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
|
||||||
if b.LastMessageID != 0 {
|
if b.LastMessageID != 0 {
|
||||||
|
log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew")
|
||||||
b.cleanupKeyboard(b.LastMessageID)
|
b.cleanupKeyboard(b.LastMessageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +121,7 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
|
|||||||
|
|
||||||
if res.Result != nil {
|
if res.Result != nil {
|
||||||
b.LastMessageID = res.Result.ID
|
b.LastMessageID = res.Result.ID
|
||||||
|
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +280,7 @@ func (b *Bot) cleanupCallbackUI(u *echotron.Update) {
|
|||||||
if u.CallbackQuery.Message != nil {
|
if u.CallbackQuery.Message != nil {
|
||||||
callbackMessageID := u.CallbackQuery.Message.ID
|
callbackMessageID := u.CallbackQuery.Message.ID
|
||||||
|
|
||||||
|
// Если это сообщение последнее, не удаляем клавиатуру
|
||||||
if b.LastMessageID == callbackMessageID {
|
if b.LastMessageID == callbackMessageID {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
package screens
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"example.com/m/adapter/backend"
|
|
||||||
"example.com/m/bot"
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AddPlacement struct {
|
|
||||||
WorkspaceID string
|
|
||||||
BackState bot.State
|
|
||||||
|
|
||||||
// Выбранные данные
|
|
||||||
SelectedProject *backend.Project
|
|
||||||
SelectedCreative *backend.Creative
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AddPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|
||||||
text := `<b>➕ Создать закуп</b>
|
|
||||||
|
|
||||||
Шаг 1: Выберите проект и креатив
|
|
||||||
|
|
||||||
Нажмите на кнопки ниже для выбора:`
|
|
||||||
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
// Кнопка выбора проекта
|
|
||||||
projectButtonText := "Проект: Не выбран"
|
|
||||||
if s.SelectedProject != nil {
|
|
||||||
projectButtonText = fmt.Sprintf("Проект: %s", s.SelectedProject.Title)
|
|
||||||
}
|
|
||||||
buttons = append(buttons, bot.Row(
|
|
||||||
bot.Button(projectButtonText, "select_project"),
|
|
||||||
))
|
|
||||||
|
|
||||||
// Кнопка выбора креатива
|
|
||||||
creativeButtonText := "Креатив: Не выбран"
|
|
||||||
if s.SelectedCreative != nil {
|
|
||||||
creativeButtonText = fmt.Sprintf("Креатив: %s", s.SelectedCreative.Name)
|
|
||||||
}
|
|
||||||
buttons = append(buttons, bot.Row(
|
|
||||||
bot.Button(creativeButtonText, "select_creative"),
|
|
||||||
))
|
|
||||||
|
|
||||||
// Кнопки навигации
|
|
||||||
var navRow []echotron.InlineKeyboardButton
|
|
||||||
navRow = append(navRow, bot.Button("« Назад", "cancel"))
|
|
||||||
|
|
||||||
// Кнопка "Далее" активна только если выбран проект
|
|
||||||
if s.SelectedProject != nil {
|
|
||||||
navRow = append(navRow, bot.Button("Далее →", "next"))
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
|
||||||
|
|
||||||
keyboard := bot.Keyboard(buttons...)
|
|
||||||
b.Render(text, keyboard, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AddPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|
||||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := u.CallbackQuery.Data
|
|
||||||
|
|
||||||
switch data {
|
|
||||||
case "cancel":
|
|
||||||
if s.BackState != nil {
|
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "select_project":
|
|
||||||
// Переходим к выбору проекта
|
|
||||||
b.SetState(&SelectProjectForPlacement{
|
|
||||||
WorkspaceID: s.WorkspaceID,
|
|
||||||
AddPlacementState: s,
|
|
||||||
}, bot.NewMessage)
|
|
||||||
|
|
||||||
case "select_creative":
|
|
||||||
if s.SelectedProject == nil {
|
|
||||||
// Если проект не выбран, показываем предупреждение
|
|
||||||
b.Edit(`<b>⚠️ Сначала выберите проект</b>
|
|
||||||
|
|
||||||
Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, bot.Keyboard(
|
|
||||||
bot.Row(bot.Button("« Назад", "back_to_add_placement")),
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переходим к выбору креатива
|
|
||||||
b.SetState(&SelectCreativeForPlacement{
|
|
||||||
WorkspaceID: s.WorkspaceID,
|
|
||||||
ProjectID: s.SelectedProject.ID,
|
|
||||||
AddPlacementState: s,
|
|
||||||
}, bot.NewMessage)
|
|
||||||
|
|
||||||
case "back_to_add_placement":
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
|
|
||||||
case "next":
|
|
||||||
if s.SelectedProject == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Переходим к управлению планом закупа (добавление/удаление каналов)
|
|
||||||
b.SetState(&PlacementPurchasePlan{
|
|
||||||
WorkspaceID: s.WorkspaceID,
|
|
||||||
ProjectID: s.SelectedProject.ID,
|
|
||||||
AddPlacementState: s,
|
|
||||||
}, bot.EditMessage)
|
|
||||||
|
|
||||||
default:
|
|
||||||
s.Enter(b, bot.NewMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AddPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
|
|
||||||
func (s *AddPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
160
telegram_bot/screens/add_purchase.go
Normal file
160
telegram_bot/screens/add_purchase.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"example.com/m/bot"
|
||||||
|
"github.com/NicoNex/echotron/v3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addPurchaseCreativesPerPage = 5
|
||||||
|
|
||||||
|
type AddPurchase struct {
|
||||||
|
WorkspaceID string
|
||||||
|
ProjectID string
|
||||||
|
CurrentPage int
|
||||||
|
BackState bot.State
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
jwt, err := s.ensureJWT(b)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get JWT")
|
||||||
|
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.renderCreativeSelection(b, mode, jwt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||||
|
// Получаем список активных креативов
|
||||||
|
page, err := b.Backend.GetCreatives(
|
||||||
|
context.Background(),
|
||||||
|
jwt,
|
||||||
|
s.WorkspaceID,
|
||||||
|
&s.ProjectID,
|
||||||
|
false, // только активные
|
||||||
|
s.CurrentPage+1,
|
||||||
|
addPurchaseCreativesPerPage,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get creatives")
|
||||||
|
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text := "<b>➕ Создание закупа</b>\n\n"
|
||||||
|
|
||||||
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
|
if len(page.Items) == 0 {
|
||||||
|
text += `Для создания закупа сначала нужно создать креатив.
|
||||||
|
|
||||||
|
<i>Вернитесь назад и создайте креатив в разделе "Креативы"</i>`
|
||||||
|
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("← Назад", "back"),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
pageInfo := ""
|
||||||
|
if page.Pages > 1 {
|
||||||
|
pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
text += fmt.Sprintf(`<b>Шаг 1/2: Выбор креатива</b>%s
|
||||||
|
|
||||||
|
<i>Выберите креатив для закупа:</i>
|
||||||
|
|
||||||
|
`, pageInfo)
|
||||||
|
|
||||||
|
// Кнопки с креативами
|
||||||
|
for _, creative := range page.Items {
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
var navRow []echotron.InlineKeyboardButton
|
||||||
|
if s.CurrentPage > 0 {
|
||||||
|
navRow = append(navRow, bot.Button("←", "prev"))
|
||||||
|
}
|
||||||
|
if s.CurrentPage+1 < page.Pages {
|
||||||
|
navRow = append(navRow, bot.Button("→", "next"))
|
||||||
|
}
|
||||||
|
if len(navRow) > 0 {
|
||||||
|
buttons = append(buttons, navRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка назад
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("← Отмена", "back"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboard := bot.Keyboard(buttons...)
|
||||||
|
b.Render(text, keyboard, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AddPurchase) ensureJWT(b *bot.Bot) (string, error) {
|
||||||
|
if b.Session.JWT != "" {
|
||||||
|
return b.Session.JWT, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Session.JWT = jwt
|
||||||
|
return jwt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
|
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := u.CallbackQuery.Data
|
||||||
|
|
||||||
|
switch data {
|
||||||
|
case "back":
|
||||||
|
if s.BackState != nil {
|
||||||
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "prev":
|
||||||
|
if s.CurrentPage > 0 {
|
||||||
|
s.CurrentPage--
|
||||||
|
}
|
||||||
|
s.Enter(b, bot.EditMessage)
|
||||||
|
|
||||||
|
case "next":
|
||||||
|
s.CurrentPage++
|
||||||
|
s.Enter(b, bot.EditMessage)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Проверяем, не выбрали ли креатив
|
||||||
|
if len(data) > 9 && data[:9] == "creative:" {
|
||||||
|
creativeID := data[9:]
|
||||||
|
|
||||||
|
// Переходим к выбору каналов
|
||||||
|
b.SetState(&SelectChannelsForPurchase{
|
||||||
|
WorkspaceID: s.WorkspaceID,
|
||||||
|
ProjectID: s.ProjectID,
|
||||||
|
CreativeID: creativeID,
|
||||||
|
Channels: []PurchaseChannelInput{},
|
||||||
|
BackState: s.BackState,
|
||||||
|
}, bot.EditMessage)
|
||||||
|
} else {
|
||||||
|
s.Enter(b, bot.NewMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
|
|
||||||
|
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
@@ -46,9 +46,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
s.Name = &creative.Name
|
s.Name = &creative.Name
|
||||||
if creative.Text != "" {
|
if creative.Text != "" {
|
||||||
s.Text = &creative.Text
|
s.Text = &creative.Text
|
||||||
|
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
|
||||||
|
} else {
|
||||||
|
log.Info().Msg("Creative text is empty from backend")
|
||||||
}
|
}
|
||||||
// Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД)
|
// Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД)
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Str("creative_id", s.CreativeID).
|
||||||
|
Str("name", creative.Name).
|
||||||
|
Bool("has_text", creative.Text != "").
|
||||||
|
Bool("s_text_is_nil", s.Text == nil).
|
||||||
|
Msg("Sending creative preview and panel")
|
||||||
|
|
||||||
// Отправляем превью и панель управления
|
// Отправляем превью и панель управления
|
||||||
s.SendCreativePreview(b)
|
s.SendCreativePreview(b)
|
||||||
s.showManagementPanelWithDelete(b)
|
s.showManagementPanelWithDelete(b)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package screens
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"example.com/m/bot"
|
"example.com/m/bot"
|
||||||
"github.com/NicoNex/echotron/v3"
|
"github.com/NicoNex/echotron/v3"
|
||||||
@@ -43,8 +44,24 @@ type CreativeEditorFields struct {
|
|||||||
// GetCreativeText возвращает текст для превью креатива
|
// GetCreativeText возвращает текст для превью креатива
|
||||||
func (e *CreativeEditorFields) GetCreativeText() string {
|
func (e *CreativeEditorFields) GetCreativeText() string {
|
||||||
if e.Text != nil && *e.Text != "" {
|
if e.Text != nil && *e.Text != "" {
|
||||||
|
// Проверяем что текст не является "визуально пустым" HTML
|
||||||
|
// Например: "<a class=\"tg-link\"></a>" - пустая ссылка
|
||||||
|
text := strings.TrimSpace(*e.Text)
|
||||||
|
|
||||||
|
// Убираем пустые HTML-теги (простая проверка)
|
||||||
|
if text == "<a class=\"tg-link\"></a>" || text == "" {
|
||||||
|
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default")
|
||||||
|
return `<i>💭 Я твой креатив
|
||||||
|
|
||||||
|
У меня пока нет текста, добавь его ниже.
|
||||||
|
|
||||||
|
</i>`
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
|
||||||
return *e.Text
|
return *e.Text
|
||||||
}
|
}
|
||||||
|
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
|
||||||
return `<i>💭 Я твой креатив
|
return `<i>💭 Я твой креатив
|
||||||
|
|
||||||
У меня пока нет текста, добавь его ниже.
|
У меня пока нет текста, добавь его ниже.
|
||||||
@@ -136,7 +153,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
|
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
|
||||||
// чтобы не обновлять LastMessageID и не затронуть cleanup
|
// чтобы не затронуть LastMessageID и не затронуть cleanup
|
||||||
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
||||||
ReplyMarkup: keyboard,
|
ReplyMarkup: keyboard,
|
||||||
ParseMode: echotron.HTML,
|
ParseMode: echotron.HTML,
|
||||||
@@ -319,12 +336,15 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
|
|||||||
} else {
|
} else {
|
||||||
// Редактируем существующую панель
|
// Редактируем существующую панель
|
||||||
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
|
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
|
||||||
|
|
||||||
|
// Временно заменяем LastMessageID на ID панели управления
|
||||||
oldLastMessageID := b.LastMessageID
|
oldLastMessageID := b.LastMessageID
|
||||||
b.LastMessageID = *e.ControlPanelMessageID
|
b.LastMessageID = *e.ControlPanelMessageID
|
||||||
|
|
||||||
keyboard := bot.Keyboard(buttons...)
|
keyboard := bot.Keyboard(buttons...)
|
||||||
b.Edit(text, keyboard)
|
b.Edit(text, keyboard)
|
||||||
|
|
||||||
|
// Восстанавливаем оригинальный LastMessageID
|
||||||
b.LastMessageID = oldLastMessageID
|
b.LastMessageID = oldLastMessageID
|
||||||
log.Info().Msg("Control panel updated")
|
log.Info().Msg("Control panel updated")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,24 +104,20 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
|
|||||||
buttons = append(buttons, row)
|
buttons = append(buttons, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ряд: Пагинация + Добавить
|
// Ряд: Пагинация + Добавить (динамическая ширина)
|
||||||
var navRow []echotron.InlineKeyboardButton
|
var navRow []echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Левая стрелка (или пустая кнопка)
|
// Левая стрелка (только если есть предыдущая страница)
|
||||||
if s.CurrentPage > 0 {
|
if s.CurrentPage > 0 {
|
||||||
navRow = append(navRow, bot.Button("←", "prev"))
|
navRow = append(navRow, bot.Button("←", "prev"))
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button(" ", "empty"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка "Добавить креатив" всегда в центре
|
// Кнопка "Добавить креатив" всегда в центре
|
||||||
navRow = append(navRow, bot.Button("+ Добавить", "add_creative"))
|
navRow = append(navRow, bot.Button("+ Добавить", "add_creative"))
|
||||||
|
|
||||||
// Правая стрелка (или пустая кнопка)
|
// Правая стрелка (только если есть следующая страница)
|
||||||
if s.CurrentPage+1 < page.Pages {
|
if s.CurrentPage+1 < page.Pages {
|
||||||
navRow = append(navRow, bot.Button("→", "next"))
|
navRow = append(navRow, bot.Button("→", "next"))
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button(" ", "empty"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
buttons = append(buttons, navRow)
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
switch u.CallbackQuery.Data {
|
switch u.CallbackQuery.Data {
|
||||||
case "my_projects":
|
case "my_projects":
|
||||||
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
|
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage)
|
||||||
case "support":
|
case "support":
|
||||||
// b.SetState(&Support{}, bot.NewMessage)
|
// b.SetState(&Support{}, bot.NewMessage)
|
||||||
case "placements":
|
case "placements":
|
||||||
b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage)
|
b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.NewMessage)
|
||||||
case "dashboard":
|
case "dashboard":
|
||||||
// b.SetState(&Dashboard{}, bot.NewMessage)
|
// b.SetState(&Dashboard{}, bot.NewMessage)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -66,27 +66,37 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
text := "Мои закупы • Управление размещениями\n\n"
|
text := "<b>📍 Мои размещения</b>\n\n"
|
||||||
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
if len(page.Items) == 0 {
|
if len(page.Items) == 0 {
|
||||||
text += "У вас пока нет закупов"
|
text += `У вас пока нет размещений.
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("Выберите закуп для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
|
||||||
|
|
||||||
// Закупы по 2 в ряду
|
<i>Размещения создаются автоматически при создании закупа.</i>`
|
||||||
|
} else {
|
||||||
|
text += fmt.Sprintf("Выберите размещение для деталей (стр. %d из %d):\n\n", s.CurrentPage+1, page.Pages)
|
||||||
|
|
||||||
|
// Размещения по 2 в ряду
|
||||||
for i := 0; i < len(page.Items); i += 2 {
|
for i := 0; i < len(page.Items); i += 2 {
|
||||||
var row []echotron.InlineKeyboardButton
|
var row []echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Первый закуп в ряду
|
// Первый placement в ряду
|
||||||
placement := page.Items[i]
|
placement := page.Items[i]
|
||||||
row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID)))
|
placementName := placement.CreativeName
|
||||||
|
if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" {
|
||||||
|
placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle)
|
||||||
|
}
|
||||||
|
row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
|
||||||
|
|
||||||
// Второй закуп в ряду (если есть)
|
// Второй placement в ряду (если есть)
|
||||||
if i+1 < len(page.Items) {
|
if i+1 < len(page.Items) {
|
||||||
placement = page.Items[i+1]
|
placement = page.Items[i+1]
|
||||||
row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID)))
|
placementName = placement.CreativeName
|
||||||
|
if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" {
|
||||||
|
placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle)
|
||||||
|
}
|
||||||
|
row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID)))
|
||||||
} else {
|
} else {
|
||||||
// Добавляем пустую кнопку для выравнивания
|
// Добавляем пустую кнопку для выравнивания
|
||||||
row = append(row, bot.Button(" ", "empty"))
|
row = append(row, bot.Button(" ", "empty"))
|
||||||
@@ -121,12 +131,11 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str
|
|||||||
|
|
||||||
// Кнопки управления
|
// Кнопки управления
|
||||||
buttons = append(buttons, bot.Row(
|
buttons = append(buttons, bot.Row(
|
||||||
bot.Button("Статистика", "statistics"),
|
bot.Button("📊 Статистика", "statistics"),
|
||||||
bot.Button("Создать закуп", "add_placement"),
|
|
||||||
))
|
))
|
||||||
|
|
||||||
// Кнопка возврата в главное меню
|
// Кнопка возврата в главное меню
|
||||||
buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu")))
|
buttons = append(buttons, bot.Row(bot.Button("← Назад", "back")))
|
||||||
|
|
||||||
keyboard := bot.Keyboard(buttons...)
|
keyboard := bot.Keyboard(buttons...)
|
||||||
b.Render(text, keyboard, mode)
|
b.Render(text, keyboard, mode)
|
||||||
@@ -167,8 +176,12 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
s.CurrentPage++
|
s.CurrentPage++
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
|
|
||||||
case data == "menu":
|
case data == "back":
|
||||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
if s.BackState != nil {
|
||||||
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
|
} else {
|
||||||
|
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||||
|
}
|
||||||
|
|
||||||
case data == "page_info", data == "empty":
|
case data == "page_info", data == "empty":
|
||||||
// Неактивные кнопки - ничего не делаем
|
// Неактивные кнопки - ничего не делаем
|
||||||
@@ -176,16 +189,10 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
|
|
||||||
case data == "statistics":
|
case data == "statistics":
|
||||||
// TODO: реализовать экран статистики
|
// TODO: реализовать экран статистики
|
||||||
b.Edit("📊 Статистика закупов\n\nЭта функция в разработке", bot.Keyboard(
|
b.Edit("📊 Статистика размещений\n\nЭта функция в разработке", bot.Keyboard(
|
||||||
bot.Row(bot.Button("« Назад", "back_to_placements")),
|
bot.Row(bot.Button("« Назад", "back_to_placements")),
|
||||||
))
|
))
|
||||||
|
|
||||||
case data == "add_placement":
|
|
||||||
b.SetState(&AddPlacement{
|
|
||||||
WorkspaceID: s.WorkspaceID,
|
|
||||||
BackState: s,
|
|
||||||
}, bot.EditMessage)
|
|
||||||
|
|
||||||
case data == "back_to_placements":
|
case data == "back_to_placements":
|
||||||
s.Enter(b, bot.EditMessage)
|
s.Enter(b, bot.EditMessage)
|
||||||
|
|
||||||
|
|||||||
@@ -115,24 +115,20 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
|
|||||||
buttons = append(buttons, row)
|
buttons = append(buttons, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ряд: Пагинация + Добавить
|
// Ряд: Пагинация + Добавить (динамическая ширина)
|
||||||
var navRow []echotron.InlineKeyboardButton
|
var navRow []echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Левая стрелка (или пустая кнопка)
|
// Левая стрелка (только если есть предыдущая страница)
|
||||||
if s.CurrentPage > 0 {
|
if s.CurrentPage > 0 {
|
||||||
navRow = append(navRow, bot.Button("←", "prev"))
|
navRow = append(navRow, bot.Button("←", "prev"))
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button(" ", "empty"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка "Добавить проект" всегда в центре
|
// Кнопка "Добавить проект" всегда в центре
|
||||||
navRow = append(navRow, bot.Button("+ Добавить", "add_project"))
|
navRow = append(navRow, bot.Button("+ Добавить", "add_project"))
|
||||||
|
|
||||||
// Правая стрелка (или пустая кнопка)
|
// Правая стрелка (только если есть следующая страница)
|
||||||
if s.CurrentPage+1 < page.Pages {
|
if s.CurrentPage+1 < page.Pages {
|
||||||
navRow = append(navRow, bot.Button("→", "next"))
|
navRow = append(navRow, bot.Button("→", "next"))
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button(" ", "empty"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
buttons = append(buttons, navRow)
|
||||||
@@ -224,7 +220,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
Project: project,
|
Project: project,
|
||||||
WorkspaceID: s.WorkspaceID,
|
WorkspaceID: s.WorkspaceID,
|
||||||
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
|
||||||
}, bot.EditMessage)
|
}, bot.NewMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
case strings.HasPrefix(data, "workspace:"):
|
case strings.HasPrefix(data, "workspace:"):
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
package screens
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"example.com/m/adapter/backend"
|
|
||||||
"example.com/m/bot"
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PlacementPurchasePlan struct {
|
|
||||||
WorkspaceID string
|
|
||||||
ProjectID string
|
|
||||||
AddPlacementState *AddPlacement
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PlacementPurchasePlan) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|
||||||
jwt, err := s.ensureJWT(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get JWT")
|
|
||||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.renderChannels(b, mode, jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PlacementPurchasePlan) renderChannels(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
|
||||||
channels, err := b.Backend.GetPurchasePlanChannels(
|
|
||||||
context.Background(),
|
|
||||||
jwt,
|
|
||||||
s.WorkspaceID,
|
|
||||||
s.ProjectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get purchase plan channels")
|
|
||||||
b.SendNew("❌ Не удалось загрузить каналы", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text := "<b>📋 План закупа — Каналы</b>\n\n"
|
|
||||||
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
if len(channels) == 0 {
|
|
||||||
text += "Список каналов пуст.\n\n"
|
|
||||||
} else {
|
|
||||||
text += "Нажмите на канал для удаления:\n\n"
|
|
||||||
|
|
||||||
for _, ch := range channels {
|
|
||||||
channelName := "Без названия"
|
|
||||||
if ch.Channel.Title != nil {
|
|
||||||
channelName = *ch.Channel.Title
|
|
||||||
} else if ch.Channel.Username != nil {
|
|
||||||
channelName = "@" + *ch.Channel.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
// Показываем статус и стоимость
|
|
||||||
statusEmoji := "📌"
|
|
||||||
switch ch.Status {
|
|
||||||
case "planned":
|
|
||||||
statusEmoji = "📝"
|
|
||||||
case "approved":
|
|
||||||
statusEmoji = "✅"
|
|
||||||
case "rejected":
|
|
||||||
statusEmoji = "❌"
|
|
||||||
case "in_progress":
|
|
||||||
statusEmoji = "🔄"
|
|
||||||
case "completed":
|
|
||||||
statusEmoji = "✔️"
|
|
||||||
}
|
|
||||||
|
|
||||||
buttonText := fmt.Sprintf("%s %s", statusEmoji, channelName)
|
|
||||||
if ch.PlannedCost != nil {
|
|
||||||
buttonText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons = append(buttons, bot.Row(
|
|
||||||
bot.Button(buttonText, fmt.Sprintf("remove:%s", ch.ID)),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Инструкция по добавлению канала
|
|
||||||
text += "\n<b>Добавление канала:</b>\nОтправьте username канала (без @)"
|
|
||||||
|
|
||||||
// Навигация
|
|
||||||
var navRow []echotron.InlineKeyboardButton
|
|
||||||
navRow = append(navRow, bot.Button("« Назад", "back"))
|
|
||||||
navRow = append(navRow, bot.Button("Далее →", "next"))
|
|
||||||
buttons = append(buttons, navRow)
|
|
||||||
|
|
||||||
keyboard := bot.Keyboard(buttons...)
|
|
||||||
b.Render(text, keyboard, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PlacementPurchasePlan) ensureJWT(b *bot.Bot) (string, error) {
|
|
||||||
if b.Session.JWT != "" {
|
|
||||||
return b.Session.JWT, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
b.Session.JWT = jwt
|
|
||||||
return jwt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PlacementPurchasePlan) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|
||||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := u.CallbackQuery.Data
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case data == "back":
|
|
||||||
if s.AddPlacementState != nil {
|
|
||||||
b.SetState(s.AddPlacementState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
case data == "next":
|
|
||||||
// TODO: переход к следующему шагу (детали размещения)
|
|
||||||
b.Edit("🚧 Следующий шаг в разработке", bot.Keyboard(
|
|
||||||
bot.Row(bot.Button("« Назад", "back_to_purchase_plan")),
|
|
||||||
))
|
|
||||||
|
|
||||||
case data == "back_to_purchase_plan":
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
|
|
||||||
case strings.HasPrefix(data, "remove:"):
|
|
||||||
parts := strings.Split(data, ":")
|
|
||||||
if len(parts) == 2 {
|
|
||||||
purchasePlanChannelID := parts[1] // ID записи PurchasePlanChannel
|
|
||||||
|
|
||||||
jwt, err := s.ensureJWT(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get JWT")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Удаляем канал из плана закупа
|
|
||||||
err = b.Backend.RemoveChannelFromPurchasePlan(
|
|
||||||
context.Background(),
|
|
||||||
jwt,
|
|
||||||
s.WorkspaceID,
|
|
||||||
s.ProjectID,
|
|
||||||
purchasePlanChannelID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("purchase_plan_channel_id", purchasePlanChannelID).Msg("Failed to remove channel")
|
|
||||||
b.Edit("❌ Не удалось удалить канал", bot.Keyboard(
|
|
||||||
bot.Row(bot.Button("« Назад", "back_to_purchase_plan")),
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем экран
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
s.Enter(b, bot.NewMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PlacementPurchasePlan) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
|
||||||
if u.Message == nil || u.Message.Text == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := strings.TrimSpace(u.Message.Text)
|
|
||||||
|
|
||||||
// Убираем @ если пользователь его добавил
|
|
||||||
username = strings.TrimPrefix(username, "@")
|
|
||||||
|
|
||||||
if username == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jwt, err := s.ensureJWT(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get JWT")
|
|
||||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Отправляем запрос на добавление канала
|
|
||||||
input := backend.AttachChannelToPurchasePlanInput{
|
|
||||||
Username: username,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = b.Backend.AttachChannelToPurchasePlan(
|
|
||||||
context.Background(),
|
|
||||||
jwt,
|
|
||||||
s.WorkspaceID,
|
|
||||||
s.ProjectID,
|
|
||||||
input,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("username", username).Msg("Failed to attach channel")
|
|
||||||
|
|
||||||
// Проверяем, содержит ли ошибка информацию о том, что канал не найден
|
|
||||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") {
|
|
||||||
b.SendNew(fmt.Sprintf("❌ Канал @%s не найден в Telegram.\n\nПроверьте правильность username и попробуйте еще раз.", username), bot.Keyboard())
|
|
||||||
} else {
|
|
||||||
b.SendNew("❌ Не удалось добавить канал. Попробуйте позже.", bot.Keyboard())
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновляем экран со списком каналов
|
|
||||||
s.Enter(b, bot.NewMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *PlacementPurchasePlan) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
@@ -68,37 +68,32 @@ func (s *ProjectDetails) formatProjectDetails() string {
|
|||||||
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
|
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Первый ряд: Креативы и Создать закуп
|
// Первый ряд: Креативы и Закупы
|
||||||
buttons = append(buttons, bot.Row(
|
buttons = append(buttons, bot.Row(
|
||||||
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
|
bot.Button("✎ Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
|
||||||
bot.Button("Создать закуп", fmt.Sprintf("create_placement:%s", s.Project.ID)),
|
bot.Button("☑ Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Второй ряд: План закупов
|
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
|
||||||
buttons = append(buttons, bot.Row(
|
|
||||||
bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)),
|
|
||||||
))
|
|
||||||
|
|
||||||
// Третий ряд: Тип приглашений (с подтверждением)
|
|
||||||
var linkTypeButtonText string
|
var linkTypeButtonText string
|
||||||
if s.confirmingLinkTypeChange {
|
if s.confirmingLinkTypeChange {
|
||||||
// Показываем подтверждение
|
// Показываем подтверждение
|
||||||
var newType string
|
var newType string
|
||||||
if s.Project.InviteLinkType == "public" {
|
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||||
newType = "С одобрением"
|
newType = "По заявке"
|
||||||
} else {
|
} else {
|
||||||
newType = "Открытая"
|
newType = "Свободное"
|
||||||
}
|
}
|
||||||
linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType)
|
linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType)
|
||||||
} else {
|
} else {
|
||||||
// Показываем текущий тип приглашений
|
// Показываем текущий тип вступления
|
||||||
var currentType string
|
var currentType string
|
||||||
if s.Project.InviteLinkType == "public" {
|
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||||
currentType = "Открытая"
|
currentType = "Свободное"
|
||||||
} else {
|
} else {
|
||||||
currentType = "С одобрением"
|
currentType = "По заявке"
|
||||||
}
|
}
|
||||||
linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType)
|
linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType)
|
||||||
}
|
}
|
||||||
buttons = append(buttons, bot.Row(
|
buttons = append(buttons, bot.Row(
|
||||||
bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
|
bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
|
||||||
@@ -135,12 +130,13 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.NewMessage)
|
}, bot.NewMessage)
|
||||||
|
|
||||||
case strings.HasPrefix(data, "create_placement:"):
|
case strings.HasPrefix(data, "purchases:"):
|
||||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||||
// TODO: реализовать создание закупа
|
b.SetState(&Purchases{
|
||||||
b.Edit("🚧 Создать закуп\n\nФункция в разработке", bot.Keyboard(
|
WorkspaceID: s.WorkspaceID,
|
||||||
bot.Row(bot.Button("← Назад", "back_to_project")),
|
ProjectID: s.Project.ID,
|
||||||
))
|
BackState: s,
|
||||||
|
}, bot.NewMessage)
|
||||||
|
|
||||||
case data == "back_to_project":
|
case data == "back_to_project":
|
||||||
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
|
||||||
@@ -154,7 +150,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
} else {
|
} else {
|
||||||
// Второе нажатие - меняем тип ссылки
|
// Второе нажатие - меняем тип ссылки
|
||||||
var newType string
|
var newType string
|
||||||
if s.Project.InviteLinkType == "public" {
|
if s.Project.PurchaseInviteTypeDefault == "public" {
|
||||||
newType = "approval"
|
newType = "approval"
|
||||||
} else {
|
} else {
|
||||||
newType = "public"
|
newType = "public"
|
||||||
@@ -163,7 +159,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
log.Info().
|
log.Info().
|
||||||
Str("workspace_id", s.WorkspaceID).
|
Str("workspace_id", s.WorkspaceID).
|
||||||
Str("project_id", s.Project.ID).
|
Str("project_id", s.Project.ID).
|
||||||
Str("old_type", s.Project.InviteLinkType).
|
Str("old_type", s.Project.PurchaseInviteTypeDefault).
|
||||||
Str("new_type", newType).
|
Str("new_type", newType).
|
||||||
Msg("Updating project invite link type")
|
Msg("Updating project invite link type")
|
||||||
|
|
||||||
|
|||||||
170
telegram_bot/screens/purchase_details.go
Normal file
170
telegram_bot/screens/purchase_details.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"example.com/m/bot"
|
||||||
|
"github.com/NicoNex/echotron/v3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PurchaseDetails struct {
|
||||||
|
WorkspaceID string
|
||||||
|
ProjectID string
|
||||||
|
PurchaseID string
|
||||||
|
BackState bot.State
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
jwt, err := s.ensureJWT(b)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get JWT")
|
||||||
|
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.renderDetails(b, mode, jwt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||||
|
purchase, err := b.Backend.GetPurchase(
|
||||||
|
context.Background(),
|
||||||
|
jwt,
|
||||||
|
s.WorkspaceID,
|
||||||
|
s.ProjectID,
|
||||||
|
s.PurchaseID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get purchase")
|
||||||
|
b.SendNew("❌ Не удалось загрузить закуп", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о креативе
|
||||||
|
creative, err := b.Backend.GetCreative(
|
||||||
|
context.Background(),
|
||||||
|
jwt,
|
||||||
|
s.WorkspaceID,
|
||||||
|
purchase.CreativeID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get creative")
|
||||||
|
creative = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
text := "<b>📦 Детали закупа</b>\n\n"
|
||||||
|
|
||||||
|
// Информация о креативе
|
||||||
|
if creative != nil {
|
||||||
|
text += fmt.Sprintf("<b>Креатив:</b> %s\n", creative.Name)
|
||||||
|
} else {
|
||||||
|
text += fmt.Sprintf("<b>Креатив ID:</b> %s\n", purchase.CreativeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус закупа
|
||||||
|
statusText := "Активный"
|
||||||
|
if purchase.Status == "archived" {
|
||||||
|
statusText = "Архивирован"
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf("<b>Статус:</b> %s\n\n", statusText)
|
||||||
|
|
||||||
|
// Информация о каналах
|
||||||
|
if len(purchase.Channels) == 0 {
|
||||||
|
text += "<i>Каналы не добавлены</i>\n"
|
||||||
|
} else {
|
||||||
|
text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels))
|
||||||
|
|
||||||
|
for i, ch := range purchase.Channels {
|
||||||
|
// Определяем статус emoji
|
||||||
|
statusEmoji := "📌"
|
||||||
|
switch ch.Status {
|
||||||
|
case "planned":
|
||||||
|
statusEmoji = "📝"
|
||||||
|
case "approved":
|
||||||
|
statusEmoji = "✅"
|
||||||
|
case "rejected":
|
||||||
|
statusEmoji = "❌"
|
||||||
|
case "in_progress":
|
||||||
|
statusEmoji = "🔄"
|
||||||
|
case "completed":
|
||||||
|
statusEmoji = "✔️"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Название канала
|
||||||
|
channelName := "Без названия"
|
||||||
|
if ch.Channel.Title != nil {
|
||||||
|
channelName = *ch.Channel.Title
|
||||||
|
} else if ch.Channel.Username != nil {
|
||||||
|
channelName = "@" + *ch.Channel.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
text += fmt.Sprintf("%d. %s <b>%s</b>\n", i+1, statusEmoji, channelName)
|
||||||
|
|
||||||
|
// Стоимость
|
||||||
|
if ch.PlannedCost != nil {
|
||||||
|
text += fmt.Sprintf(" 💰 Стоимость: %.0f₽\n", *ch.PlannedCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Комментарий
|
||||||
|
if ch.Comment != nil && *ch.Comment != "" {
|
||||||
|
text += fmt.Sprintf(" 💬 %s\n", *ch.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тип invite link
|
||||||
|
inviteLinkTypeText := "публичная"
|
||||||
|
if ch.InviteLinkType == "approval" {
|
||||||
|
inviteLinkTypeText = "с одобрением"
|
||||||
|
}
|
||||||
|
text += fmt.Sprintf(" 🔗 Ссылка: %s\n", inviteLinkTypeText)
|
||||||
|
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
|
// Кнопка назад
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("← Назад", "back"),
|
||||||
|
))
|
||||||
|
|
||||||
|
keyboard := bot.Keyboard(buttons...)
|
||||||
|
b.Render(text, keyboard, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseDetails) ensureJWT(b *bot.Bot) (string, error) {
|
||||||
|
if b.Session.JWT != "" {
|
||||||
|
return b.Session.JWT, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Session.JWT = jwt
|
||||||
|
return jwt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
|
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := u.CallbackQuery.Data
|
||||||
|
|
||||||
|
switch data {
|
||||||
|
case "back":
|
||||||
|
if s.BackState != nil {
|
||||||
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.Enter(b, bot.NewMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
|
|
||||||
|
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
158
telegram_bot/screens/purchases.go
Normal file
158
telegram_bot/screens/purchases.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"example.com/m/bot"
|
||||||
|
"github.com/NicoNex/echotron/v3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Purchases struct {
|
||||||
|
WorkspaceID string
|
||||||
|
ProjectID string
|
||||||
|
BackState bot.State
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
jwt, err := s.ensureJWT(b)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get JWT")
|
||||||
|
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.renderPurchases(b, mode, jwt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||||
|
purchases, err := b.Backend.GetPurchases(
|
||||||
|
context.Background(),
|
||||||
|
jwt,
|
||||||
|
s.WorkspaceID,
|
||||||
|
s.ProjectID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get purchases")
|
||||||
|
b.SendNew("❌ Не удалось загрузить закупы", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
text := "<b>📦 Закупы</b>\n\n"
|
||||||
|
|
||||||
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
|
if len(purchases) == 0 {
|
||||||
|
text += `Список закупов пуст.
|
||||||
|
|
||||||
|
<i>Закуп — это план размещения рекламы в каналах:</i>
|
||||||
|
▸ Выбор креатива
|
||||||
|
▸ Список каналов для размещения
|
||||||
|
▸ Планируемая стоимость
|
||||||
|
▸ Статус выполнения
|
||||||
|
|
||||||
|
<i>Создайте первый закуп 👇</i>`
|
||||||
|
} else {
|
||||||
|
text += "Управление закупами для проекта.\n\n"
|
||||||
|
text += "<i>Выберите закуп для деталей:</i>\n\n"
|
||||||
|
|
||||||
|
for i, purchase := range purchases {
|
||||||
|
// Определяем статус emoji
|
||||||
|
statusEmoji := "📌"
|
||||||
|
switch purchase.Status {
|
||||||
|
case "active":
|
||||||
|
statusEmoji = "🟢"
|
||||||
|
case "archived":
|
||||||
|
statusEmoji = "📦"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Считаем каналы по статусам
|
||||||
|
totalChannels := len(purchase.Channels)
|
||||||
|
completedChannels := 0
|
||||||
|
for _, ch := range purchase.Channels {
|
||||||
|
if ch.Status == "completed" {
|
||||||
|
completedChannels++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
|
||||||
|
statusEmoji,
|
||||||
|
i+1,
|
||||||
|
completedChannels,
|
||||||
|
totalChannels,
|
||||||
|
)
|
||||||
|
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопка создания нового закупа
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("+ Создать закуп", "add_purchase"),
|
||||||
|
))
|
||||||
|
|
||||||
|
// Кнопка назад
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("← Назад", "back"),
|
||||||
|
))
|
||||||
|
|
||||||
|
keyboard := bot.Keyboard(buttons...)
|
||||||
|
b.Render(text, keyboard, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Purchases) ensureJWT(b *bot.Bot) (string, error) {
|
||||||
|
if b.Session.JWT != "" {
|
||||||
|
return b.Session.JWT, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Session.JWT = jwt
|
||||||
|
return jwt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
|
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := u.CallbackQuery.Data
|
||||||
|
|
||||||
|
switch data {
|
||||||
|
case "back":
|
||||||
|
if s.BackState != nil {
|
||||||
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "add_purchase":
|
||||||
|
b.SetState(&AddPurchase{
|
||||||
|
WorkspaceID: s.WorkspaceID,
|
||||||
|
ProjectID: s.ProjectID,
|
||||||
|
BackState: s,
|
||||||
|
}, bot.EditMessage)
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Проверяем, не нажали ли на конкретный закуп
|
||||||
|
if len(data) > 9 && data[:9] == "purchase:" {
|
||||||
|
purchaseID := data[9:]
|
||||||
|
b.SetState(&PurchaseDetails{
|
||||||
|
WorkspaceID: s.WorkspaceID,
|
||||||
|
ProjectID: s.ProjectID,
|
||||||
|
PurchaseID: purchaseID,
|
||||||
|
BackState: s,
|
||||||
|
}, bot.NewMessage)
|
||||||
|
} else {
|
||||||
|
s.Enter(b, bot.NewMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
|
|
||||||
|
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||||
214
telegram_bot/screens/select_channels_for_purchase.go
Normal file
214
telegram_bot/screens/select_channels_for_purchase.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package screens
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"example.com/m/adapter/backend"
|
||||||
|
"example.com/m/bot"
|
||||||
|
"github.com/NicoNex/echotron/v3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PurchaseChannelInput struct {
|
||||||
|
Username string
|
||||||
|
PlannedCost *float64
|
||||||
|
Comment *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectChannelsForPurchase struct {
|
||||||
|
WorkspaceID string
|
||||||
|
ProjectID string
|
||||||
|
CreativeID string
|
||||||
|
Channels []PurchaseChannelInput
|
||||||
|
BackState bot.State
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
s.renderChannelSelection(b, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
|
||||||
|
text := "<b>➕ Создание закупа</b>\n\n"
|
||||||
|
text += "<b>Шаг 2/2: Добавление каналов</b>\n\n"
|
||||||
|
|
||||||
|
var buttons [][]echotron.InlineKeyboardButton
|
||||||
|
|
||||||
|
if len(s.Channels) == 0 {
|
||||||
|
text += `Добавьте каналы для размещения рекламы.
|
||||||
|
|
||||||
|
<i>Отправьте username канала (без @)</i>
|
||||||
|
Например: <code>channel_name</code>
|
||||||
|
|
||||||
|
После добавления всех каналов нажмите "Создать закуп"`
|
||||||
|
} else {
|
||||||
|
text += fmt.Sprintf("<b>Добавлено каналов: %d</b>\n\n", len(s.Channels))
|
||||||
|
|
||||||
|
for i, ch := range s.Channels {
|
||||||
|
channelText := fmt.Sprintf("%d. @%s", i+1, ch.Username)
|
||||||
|
if ch.PlannedCost != nil {
|
||||||
|
channelText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost)
|
||||||
|
}
|
||||||
|
text += channelText + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
text += "\n<i>Добавьте еще каналы или создайте закуп</i>"
|
||||||
|
|
||||||
|
// Кнопка создания закупа (доступна только если есть каналы)
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("✅ Создать закуп", "create"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кнопки навигации
|
||||||
|
buttons = append(buttons, bot.Row(
|
||||||
|
bot.Button("← Назад", "back"),
|
||||||
|
))
|
||||||
|
|
||||||
|
keyboard := bot.Keyboard(buttons...)
|
||||||
|
b.Render(text, keyboard, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) ensureJWT(b *bot.Bot) (string, error) {
|
||||||
|
if b.Session.JWT != "" {
|
||||||
|
return b.Session.JWT, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
b.Session.JWT = jwt
|
||||||
|
return jwt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
|
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := u.CallbackQuery.Data
|
||||||
|
|
||||||
|
switch data {
|
||||||
|
case "back":
|
||||||
|
// Возвращаемся к выбору креатива
|
||||||
|
b.SetState(&AddPurchase{
|
||||||
|
WorkspaceID: s.WorkspaceID,
|
||||||
|
ProjectID: s.ProjectID,
|
||||||
|
BackState: s.BackState,
|
||||||
|
}, bot.EditMessage)
|
||||||
|
|
||||||
|
case "create":
|
||||||
|
if len(s.Channels) == 0 {
|
||||||
|
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
|
||||||
|
bot.Row(bot.Button("← Назад", "back_to_select")),
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jwt, err := s.ensureJWT(b)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to get JWT")
|
||||||
|
b.Edit("❌ Ошибка авторизации", bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем закуп
|
||||||
|
s.createPurchase(b, jwt)
|
||||||
|
|
||||||
|
case "back_to_select":
|
||||||
|
s.Enter(b, bot.EditMessage)
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.Enter(b, bot.NewMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
||||||
|
// Преобразуем каналы в формат API
|
||||||
|
var apiChannels []backend.CreatePurchaseChannelInput
|
||||||
|
for _, ch := range s.Channels {
|
||||||
|
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
|
||||||
|
Username: ch.Username,
|
||||||
|
PlannedCost: ch.PlannedCost,
|
||||||
|
Comment: ch.Comment,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
input := backend.CreatePurchaseInput{
|
||||||
|
CreativeID: s.CreativeID,
|
||||||
|
Channels: apiChannels,
|
||||||
|
}
|
||||||
|
|
||||||
|
purchase, err := b.Backend.CreatePurchase(
|
||||||
|
context.Background(),
|
||||||
|
jwt,
|
||||||
|
s.WorkspaceID,
|
||||||
|
s.ProjectID,
|
||||||
|
input,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to create purchase")
|
||||||
|
b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", bot.Keyboard(
|
||||||
|
bot.Row(bot.Button("← Назад", "back_to_select")),
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем успешное создание
|
||||||
|
successText := fmt.Sprintf(`✅ <b>Закуп создан!</b>
|
||||||
|
|
||||||
|
<b>ID:</b> %s
|
||||||
|
<b>Креатив:</b> %s
|
||||||
|
<b>Каналов:</b> %d
|
||||||
|
|
||||||
|
Закуп создан и готов к работе.`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
|
||||||
|
|
||||||
|
b.Edit(successText, bot.Keyboard(
|
||||||
|
bot.Row(bot.Button("✅ Готово", "done")),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||||
|
if u.Message == nil || u.Message.Text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(u.Message.Text)
|
||||||
|
|
||||||
|
// Убираем @ если пользователь его добавил
|
||||||
|
username = strings.TrimPrefix(username, "@")
|
||||||
|
|
||||||
|
if username == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, не добавлен ли уже этот канал
|
||||||
|
for _, ch := range s.Channels {
|
||||||
|
if ch.Username == username {
|
||||||
|
b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем канал в список
|
||||||
|
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||||||
|
Username: username,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обновляем экран
|
||||||
|
s.Enter(b, bot.NewMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
||||||
|
// Обрабатываем callback после успешного создания
|
||||||
|
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
|
||||||
|
// Возвращаемся к списку закупов
|
||||||
|
if s.BackState != nil {
|
||||||
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
package screens
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"example.com/m/bot"
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const creativesForPlacementPerPage = 6
|
|
||||||
|
|
||||||
type SelectCreativeForPlacement struct {
|
|
||||||
WorkspaceID string
|
|
||||||
ProjectID string
|
|
||||||
CurrentPage int
|
|
||||||
AddPlacementState *AddPlacement
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectCreativeForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|
||||||
jwt, err := s.ensureJWT(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get JWT")
|
|
||||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.renderCreatives(b, mode, jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectCreativeForPlacement) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
|
||||||
page, err := b.Backend.GetCreatives(
|
|
||||||
context.Background(),
|
|
||||||
jwt,
|
|
||||||
s.WorkspaceID,
|
|
||||||
&s.ProjectID,
|
|
||||||
false, // includeArchived
|
|
||||||
s.CurrentPage+1,
|
|
||||||
creativesForPlacementPerPage,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get creatives")
|
|
||||||
b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text := "<b>Выбор креатива</b>\n\n"
|
|
||||||
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
if len(page.Items) == 0 {
|
|
||||||
text += "У выбранного проекта пока нет креативов.\n\nСоздайте креатив через меню проекта."
|
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("Выберите креатив (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
|
||||||
|
|
||||||
// Креативы по 2 в ряду
|
|
||||||
for i := 0; i < len(page.Items); i += 2 {
|
|
||||||
var row []echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
// Первый креатив в ряду
|
|
||||||
creative := page.Items[i]
|
|
||||||
row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID)))
|
|
||||||
|
|
||||||
// Второй креатив в ряду (если есть)
|
|
||||||
if i+1 < len(page.Items) {
|
|
||||||
creative = page.Items[i+1]
|
|
||||||
row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID)))
|
|
||||||
} else {
|
|
||||||
row = append(row, bot.Button(" ", "empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons = append(buttons, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки пагинации
|
|
||||||
var navRow []echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
if s.CurrentPage > 0 {
|
|
||||||
navRow = append(navRow, bot.Button("←", "prev"))
|
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
|
|
||||||
navRow = append(navRow, pageButton)
|
|
||||||
|
|
||||||
if s.CurrentPage+1 < page.Pages {
|
|
||||||
navRow = append(navRow, bot.Button("→", "next"))
|
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Опция пропустить выбор креатива (креатив опционален)
|
|
||||||
buttons = append(buttons, bot.Row(bot.Button("Без креатива", "skip")))
|
|
||||||
|
|
||||||
// Кнопка возврата
|
|
||||||
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
|
|
||||||
|
|
||||||
keyboard := bot.Keyboard(buttons...)
|
|
||||||
b.Render(text, keyboard, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectCreativeForPlacement) ensureJWT(b *bot.Bot) (string, error) {
|
|
||||||
if b.Session.JWT != "" {
|
|
||||||
return b.Session.JWT, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
b.Session.JWT = jwt
|
|
||||||
return jwt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectCreativeForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|
||||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := u.CallbackQuery.Data
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case data == "prev":
|
|
||||||
if s.CurrentPage > 0 {
|
|
||||||
s.CurrentPage--
|
|
||||||
}
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
|
|
||||||
case data == "next":
|
|
||||||
s.CurrentPage++
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
|
|
||||||
case data == "page_info", data == "empty":
|
|
||||||
return
|
|
||||||
|
|
||||||
case data == "skip":
|
|
||||||
// Пропускаем выбор креатива
|
|
||||||
if s.AddPlacementState != nil {
|
|
||||||
s.AddPlacementState.SelectedCreative = nil
|
|
||||||
b.SetState(s.AddPlacementState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
case data == "back":
|
|
||||||
if s.AddPlacementState != nil {
|
|
||||||
b.SetState(s.AddPlacementState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
case strings.HasPrefix(data, "select:"):
|
|
||||||
parts := strings.Split(data, ":")
|
|
||||||
if len(parts) == 2 {
|
|
||||||
creativeID := parts[1]
|
|
||||||
|
|
||||||
// Получаем JWT
|
|
||||||
jwt, err := s.ensureJWT(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get JWT")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загружаем полные данные креатива
|
|
||||||
creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, creativeID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("creative_id", creativeID).Msg("Failed to get creative")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем выбранный креатив в состояние AddPlacement
|
|
||||||
if s.AddPlacementState != nil {
|
|
||||||
s.AddPlacementState.SelectedCreative = creative
|
|
||||||
b.SetState(s.AddPlacementState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
s.Enter(b, bot.NewMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectCreativeForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
|
|
||||||
func (s *SelectCreativeForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
package screens
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"example.com/m/bot"
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const projectsForPlacementPerPage = 6
|
|
||||||
|
|
||||||
type SelectProjectForPlacement struct {
|
|
||||||
WorkspaceID string
|
|
||||||
CurrentPage int
|
|
||||||
AddPlacementState *AddPlacement
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectProjectForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|
||||||
jwt, err := s.ensureJWT(b)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get JWT")
|
|
||||||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.renderProjects(b, mode, jwt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectProjectForPlacement) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
|
||||||
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsForPlacementPerPage)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Msg("Failed to get projects")
|
|
||||||
b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
text := "<b>Выбор проекта</b>\n\n"
|
|
||||||
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
if len(page.Items) == 0 {
|
|
||||||
text += "У вас пока нет проектов"
|
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("Выберите проект (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
|
||||||
|
|
||||||
// Проекты по 2 в ряду
|
|
||||||
for i := 0; i < len(page.Items); i += 2 {
|
|
||||||
var row []echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
// Первый проект в ряду
|
|
||||||
project := page.Items[i]
|
|
||||||
row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID)))
|
|
||||||
|
|
||||||
// Второй проект в ряду (если есть)
|
|
||||||
if i+1 < len(page.Items) {
|
|
||||||
project = page.Items[i+1]
|
|
||||||
row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID)))
|
|
||||||
} else {
|
|
||||||
row = append(row, bot.Button(" ", "empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons = append(buttons, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки пагинации
|
|
||||||
var navRow []echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
if s.CurrentPage > 0 {
|
|
||||||
navRow = append(navRow, bot.Button("←", "prev"))
|
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
|
|
||||||
navRow = append(navRow, pageButton)
|
|
||||||
|
|
||||||
if s.CurrentPage+1 < page.Pages {
|
|
||||||
navRow = append(navRow, bot.Button("→", "next"))
|
|
||||||
} else {
|
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопка возврата
|
|
||||||
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
|
|
||||||
|
|
||||||
keyboard := bot.Keyboard(buttons...)
|
|
||||||
b.Render(text, keyboard, mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectProjectForPlacement) ensureJWT(b *bot.Bot) (string, error) {
|
|
||||||
if b.Session.JWT != "" {
|
|
||||||
return b.Session.JWT, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
b.Session.JWT = jwt
|
|
||||||
return jwt, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectProjectForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|
||||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
data := u.CallbackQuery.Data
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case data == "prev":
|
|
||||||
if s.CurrentPage > 0 {
|
|
||||||
s.CurrentPage--
|
|
||||||
}
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
|
|
||||||
case data == "next":
|
|
||||||
s.CurrentPage++
|
|
||||||
s.Enter(b, bot.EditMessage)
|
|
||||||
|
|
||||||
case data == "page_info", data == "empty":
|
|
||||||
return
|
|
||||||
|
|
||||||
case data == "back":
|
|
||||||
if s.AddPlacementState != nil {
|
|
||||||
b.SetState(s.AddPlacementState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
case strings.HasPrefix(data, "select:"):
|
|
||||||
parts := strings.Split(data, ":")
|
|
||||||
if len(parts) == 2 {
|
|
||||||
projectID := parts[1]
|
|
||||||
|
|
||||||
// Загружаем полные данные проекта
|
|
||||||
project, err := b.Backend.GetProject(context.Background(), s.WorkspaceID, projectID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сохраняем выбранный проект в состояние AddPlacement
|
|
||||||
if s.AddPlacementState != nil {
|
|
||||||
s.AddPlacementState.SelectedProject = project
|
|
||||||
// Сбрасываем креатив при смене проекта
|
|
||||||
s.AddPlacementState.SelectedCreative = nil
|
|
||||||
b.SetState(s.AddPlacementState, bot.EditMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
s.Enter(b, bot.NewMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SelectProjectForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
|
|
||||||
func (s *SelectProjectForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
|
||||||
Reference in New Issue
Block a user