diff --git a/Makefile b/Makefile index d3626b8..5662db2 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ test: env: cp .env.example .env +run: + uv run uvicorn src.main:app + up: GIT_COMMIT=$$(git rev-parse --short HEAD) docker compose up --build -d --force-recreate docker compose logs -f diff --git a/migrations/models/0_20251223102745_init.py b/migrations/models/0_20251223102745_init.py deleted file mode 100644 index e1e50b3..0000000 --- a/migrations/models/0_20251223102745_init.py +++ /dev/null @@ -1,324 +0,0 @@ -# ruff: noqa -# mypy: ignore-errors -from tortoise import BaseDBAsyncClient - -RUN_IN_TRANSACTION = True - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE TABLE IF NOT EXISTS "channel" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "telegram_id" BIGINT NOT NULL UNIQUE, - "title" VARCHAR(255) NOT NULL, - "username" VARCHAR(255) NOT NULL UNIQUE, - "access_hash" BIGINT, - "pts" INT -); -CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id"); -CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username"); -CREATE TABLE IF NOT EXISTS "post" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "message_id" INT NOT NULL, - "text" TEXT NOT NULL, - "deleted_from_channel_at" TIMESTAMPTZ, - "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id") -); -CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id"); -CREATE TABLE IF NOT EXISTS "post_views_history" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "views_count" INT NOT NULL, - "fetched_at" TIMESTAMPTZ NOT NULL, - "post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at") -); -CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at"); -CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id"); -CREATE TABLE IF NOT EXISTS "telegram_state" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "telegram_id" BIGINT NOT NULL UNIQUE, - "state" VARCHAR(26) NOT NULL, - "context" JSONB NOT NULL -); -CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id"); -COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_WORKSPACE: creative_waiting_workspace\nCREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text\nPROJECT_WAITING_WORKSPACE: project_waiting_workspace'; -CREATE TABLE IF NOT EXISTS "user" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "telegram_id" BIGINT NOT NULL UNIQUE, - "username" VARCHAR(255), - "first_name" VARCHAR(255), - "last_name" VARCHAR(255) -); -CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id"); -CREATE TABLE IF NOT EXISTS "login_token" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "token" VARCHAR(255) NOT NULL UNIQUE, - "expires_at" TIMESTAMPTZ NOT NULL, - "used_at" TIMESTAMPTZ, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token"); -CREATE INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id"); -CREATE TABLE IF NOT EXISTS "workspace" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "name" VARCHAR(255) NOT NULL -); -CREATE TABLE IF NOT EXISTS "project" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id") -); -CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_id"); -CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id"); -COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived'; -CREATE TABLE IF NOT EXISTS "creative" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "name" VARCHAR(255) NOT NULL, - "text" TEXT NOT NULL, - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project_id"); -CREATE INDEX IF NOT EXISTS "idx_creative_workspa_2de546" ON "creative" ("workspace_id"); -COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; -CREATE TABLE IF NOT EXISTS "placement" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "placement_date" TIMESTAMPTZ NOT NULL, - "cost" DOUBLE PRECISION, - "comment" TEXT, - "invite_link_type" VARCHAR(8) NOT NULL, - "invite_link" VARCHAR(512) NOT NULL, - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, - "placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, - "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL, - "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id"); -COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; -COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived'; -CREATE TABLE IF NOT EXISTS "purchase_plan" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_purchase_pl_project_ceb049" ON "purchase_plan" ("project_id"); -CREATE INDEX IF NOT EXISTS "idx_purchase_pl_workspa_e2459d" ON "purchase_plan" ("workspace_id"); -CREATE INDEX IF NOT EXISTS "idx_purchase_pl_project_a3c246" ON "purchase_plan" ("project_id", "status"); -COMMENT ON COLUMN "purchase_plan"."status" IS 'ACTIVE: active\nARCHIVED: archived'; -CREATE TABLE IF NOT EXISTS "purchase_plan_channel" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "status" VARCHAR(11) NOT NULL DEFAULT 'planned', - "planned_cost" DOUBLE PRECISION, - "comment" TEXT, - "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, - "purchase_plan_id" UUID NOT NULL REFERENCES "purchase_plan" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_purchase_pl_purchas_e3a717" UNIQUE ("purchase_plan_id", "channel_id") -); -CREATE INDEX IF NOT EXISTS "idx_purchase_pl_channel_4cfc1c" ON "purchase_plan_channel" ("channel_id"); -CREATE INDEX IF NOT EXISTS "idx_purchase_pl_purchas_d20693" ON "purchase_plan_channel" ("purchase_plan_id"); -COMMENT ON COLUMN "purchase_plan_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed'; -CREATE TABLE IF NOT EXISTS "subscription" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "invite_link" VARCHAR(512) NOT NULL, - "status" VARCHAR(12) NOT NULL DEFAULT 'active', - "unsubscribed_at" TIMESTAMPTZ, - "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE, - "subscriber_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_subscriptio_placeme_8fcb8f" UNIQUE ("placement_id", "subscriber_id") -); -CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link"); -CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id"); -CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id"); -COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; -CREATE TABLE IF NOT EXISTS "workspace_invite" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "status" VARCHAR(8) NOT NULL DEFAULT 'pending', - "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id") -); -CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id"); -COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked'; -CREATE TABLE IF NOT EXISTS "workspace_user" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "status" VARCHAR(7) NOT NULL DEFAULT 'active', - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, - "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "user_id") -); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id"); -COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked'; -CREATE TABLE IF NOT EXISTS "workspace_user_permission" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "permission" VARCHAR(24) NOT NULL, - "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, - CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") -); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id"); -COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; -CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" ( - "id" UUID NOT NULL PRIMARY KEY, - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "permission" VARCHAR(24) NOT NULL, - "channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE, - "creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE, - "placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE, - "project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE, - "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_id"); -CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id"); -COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; -CREATE TABLE IF NOT EXISTS "aerich" ( - "id" SERIAL NOT NULL PRIMARY KEY, - "version" VARCHAR(255) NOT NULL, - "app" VARCHAR(100) NOT NULL, - "content" JSONB NOT NULL -);""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - """ - - -MODELS_STATE = ( - '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' -) diff --git a/migrations/models/0_20260102162851_init.py b/migrations/models/0_20260102162851_init.py new file mode 100644 index 0000000..0cc8634 --- /dev/null +++ b/migrations/models/0_20260102162851_init.py @@ -0,0 +1,335 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "channel" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "telegram_id" BIGINT NOT NULL UNIQUE, + "title" VARCHAR(255) NOT NULL, + "username" VARCHAR(255) NOT NULL UNIQUE, + "access_hash" BIGINT, + "pts" INT + ); + CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id"); + CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username"); + CREATE TABLE IF NOT EXISTS "post" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "message_id" INT NOT NULL, + "text" TEXT NOT NULL, + "deleted_from_channel_at" TIMESTAMPTZ, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id") + ); + CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id"); + CREATE TABLE IF NOT EXISTS "post_views_history" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "views_count" INT NOT NULL, + "fetched_at" TIMESTAMPTZ NOT NULL, + "post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at") + ); + CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at"); + CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id"); + CREATE TABLE IF NOT EXISTS "user" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "telegram_id" BIGINT NOT NULL UNIQUE, + "username" VARCHAR(255), + "first_name" VARCHAR(255), + "last_name" VARCHAR(255) + ); + CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id"); + CREATE TABLE IF NOT EXISTS "login_token" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "token" VARCHAR(255) NOT NULL UNIQUE, + "expires_at" TIMESTAMPTZ NOT NULL, + "used_at" TIMESTAMPTZ, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token"); + CREATE INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id"); + CREATE TABLE IF NOT EXISTS "workspace" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "name" VARCHAR(255) NOT NULL + ); + CREATE TABLE IF NOT EXISTS "project" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "purchase_invite_type_default" VARCHAR(10) NOT NULL DEFAULT 'approval', + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id") + ); + CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_id"); + CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id"); + COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived'; + COMMENT ON COLUMN "project"."purchase_invite_type_default" IS 'PUBLIC: public\nAPPROVAL: approval'; + CREATE TABLE IF NOT EXISTS "creative" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "name" VARCHAR(255) NOT NULL, + "text" TEXT NOT NULL, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project_id"); + CREATE INDEX IF NOT EXISTS "idx_creative_workspa_2de546" ON "creative" ("workspace_id"); + COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; + CREATE TABLE IF NOT EXISTS "purchase" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_purchase_creativ_6829cd" ON "purchase" ("creative_id"); + CREATE INDEX IF NOT EXISTS "idx_purchase_project_95b1c2" ON "purchase" ("project_id"); + CREATE INDEX IF NOT EXISTS "idx_purchase_workspa_8784a5" ON "purchase" ("workspace_id"); + CREATE INDEX IF NOT EXISTS "idx_purchase_project_dee224" ON "purchase" ("project_id", "status"); + COMMENT ON COLUMN "purchase"."status" IS 'ACTIVE: active\nARCHIVED: archived'; + CREATE TABLE IF NOT EXISTS "purchase_channel" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(11) NOT NULL DEFAULT 'planned', + "planned_cost" DOUBLE PRECISION, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "invite_link_type" VARCHAR(8) NOT NULL, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_purchase_ch_purchas_f7c86e" UNIQUE ("purchase_id", "channel_id") + ); + CREATE INDEX IF NOT EXISTS "idx_purchase_ch_channel_2b1264" ON "purchase_channel" ("channel_id"); + CREATE INDEX IF NOT EXISTS "idx_purchase_ch_purchas_1fe84a" ON "purchase_channel" ("purchase_id"); + COMMENT ON COLUMN "purchase_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed'; + COMMENT ON COLUMN "purchase_channel"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; + CREATE TABLE IF NOT EXISTS "placement" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "wanted_placement_date" TIMESTAMPTZ NOT NULL, + "cost" DOUBLE PRECISION, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "purchase_id" UUID REFERENCES "purchase" ("id") ON DELETE CASCADE, + "purchase_channel_id" UUID REFERENCES "purchase_channel" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_purchas_3e3775" ON "placement" ("purchase_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id"); + CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id"); + COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived'; + CREATE TABLE IF NOT EXISTS "subscription" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "invite_link" VARCHAR(512) NOT NULL, + "status" VARCHAR(12) NOT NULL DEFAULT 'active', + "unsubscribed_at" TIMESTAMPTZ, + "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE, + "subscriber_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_subscriptio_placeme_8fcb8f" UNIQUE ("placement_id", "subscriber_id") + ); + CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link"); + CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id"); + CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id"); + COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; + CREATE TABLE IF NOT EXISTS "workspace_invite" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(8) NOT NULL DEFAULT 'pending', + "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id") + ); + CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id"); + COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked'; + CREATE TABLE IF NOT EXISTS "workspace_user" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "status" VARCHAR(7) NOT NULL DEFAULT 'active', + "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE, + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "user_id") + ); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id"); + COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked'; + CREATE TABLE IF NOT EXISTS "workspace_user_permission" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "permission" VARCHAR(24) NOT NULL, + "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") + ); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id"); + COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; + CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" + ( + "id" UUID NOT NULL PRIMARY KEY, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "permission" VARCHAR(24) NOT NULL, + "channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE, + "creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE, + "placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE, + "project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE, + "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_id"); + CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id"); + COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write'; + CREATE TABLE IF NOT EXISTS "aerich" + ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL + );""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ + + +MODELS_STATE = ( + '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' +) diff --git a/migrations/models/1_20251223103601_add_check_constraint.py b/migrations/models/1_20251223103601_add_check_constraint.py deleted file mode 100644 index d143a3c..0000000 --- a/migrations/models/1_20251223103601_add_check_constraint.py +++ /dev/null @@ -1,103 +0,0 @@ -# ruff: noqa - -from tortoise import BaseDBAsyncClient - -RUN_IN_TRANSACTION = True - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - -- Add CHECK constraint: exactly one FK must be set - ALTER TABLE "workspace_user_permission_scope" - ADD CONSTRAINT "check_exactly_one_scope" - CHECK ( - (("project_id" IS NOT NULL)::int + - ("creative_id" IS NOT NULL)::int + - ("placement_id" IS NOT NULL)::int + - ("channel_id" IS NOT NULL)::int) = 1 - ); - - COMMENT ON TABLE "workspace_user_permission_scope" - IS 'Permission scoped to specific entity (project/creative/placement/channel)'; - """ - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "workspace_user_permission_scope" - DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; - """ - - -MODELS_STATE = ( - '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' -) diff --git a/migrations/models/1_20260102184542_add_check_constraint.py b/migrations/models/1_20260102184542_add_check_constraint.py new file mode 100644 index 0000000..f26ab76 --- /dev/null +++ b/migrations/models/1_20260102184542_add_check_constraint.py @@ -0,0 +1,99 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- Add CHECK constraint: exactly one FK must be set + ALTER TABLE "workspace_user_permission_scope" + ADD CONSTRAINT "check_exactly_one_scope" + CHECK ( + (("project_id" IS NOT NULL)::int + + ("creative_id" IS NOT NULL)::int + + ("placement_id" IS NOT NULL)::int + + ("channel_id" IS NOT NULL)::int) = 1 + ); + + COMMENT ON TABLE "workspace_user_permission_scope" + IS 'Permission scoped to specific entity (project/creative/placement/channel)'; \ + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "workspace_user_permission_scope" + DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; \ + """ + + +MODELS_STATE = ( + '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' +) diff --git a/migrations/models/2_20251230191746_move_invite_link_type_to_project.py b/migrations/models/2_20251230191746_move_invite_link_type_to_project.py deleted file mode 100644 index 40a6cd4..0000000 --- a/migrations/models/2_20251230191746_move_invite_link_type_to_project.py +++ /dev/null @@ -1,92 +0,0 @@ -from tortoise import BaseDBAsyncClient - -RUN_IN_TRANSACTION = True - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "placement" DROP COLUMN "invite_link_type"; - ALTER TABLE "project" ADD "invite_link_type" VARCHAR(10) NOT NULL DEFAULT 'approval'; - COMMENT ON COLUMN "project"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "project" DROP COLUMN "invite_link_type"; - ALTER TABLE "placement" ADD "invite_link_type" VARCHAR(8) NOT NULL; - COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';""" - - -MODELS_STATE = ( - "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" - "CzE0" -) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 31da633..fb1761e 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -257,37 +257,60 @@ class Postgres(DatabaseBase, Database): return await query.order_by('created_at').all() - async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None: - return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE) + async def get_active_purchase(self, project_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Purchase | None: + return await domain.Purchase.get_or_none( + project_id=project_id, creative_id=creative_id, status=domain.PurchaseStatus.ACTIVE + ) - async def get_purchase_plan(self, workspace_id: uuid.UUID, plan_id: uuid.UUID) -> domain.PurchasePlan | None: - return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id) + async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None: + return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id) - async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: - await plan.save() - - async def get_purchase_plan_channels(self, plan_id: uuid.UUID) -> list[domain.PurchasePlanChannel]: - return await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id).prefetch_related('channel').all() - - async def get_purchase_plan_channel( - self, plan_channel_id: uuid.UUID, plan_id: uuid.UUID - ) -> domain.PurchasePlanChannel | None: + async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]: return ( - await domain.PurchasePlanChannel.filter(id=plan_channel_id, purchase_plan_id=plan_id) + await domain.Purchase.filter(workspace_id=workspace_id, project_id=project_id) + .prefetch_related('channels', 'channels__channel') + .all() + ) + + async def create_purchase(self, purchase: domain.Purchase) -> None: + await purchase.save() + + async def get_purchase_channels(self, purchase_id: uuid.UUID) -> list[domain.PurchaseChannel]: + return await domain.PurchaseChannel.filter(purchase_id=purchase_id).prefetch_related('channel').all() + + async def get_purchase_channel( + self, purchase_channel_id: uuid.UUID, purchase_id: uuid.UUID + ) -> domain.PurchaseChannel | None: + return ( + await domain.PurchaseChannel.filter(id=purchase_channel_id, purchase_id=purchase_id) .prefetch_related('channel') .first() ) - async def add_channel_to_purchase_plan( + async def get_purchase_channels_by_project(self, project_id: uuid.UUID) -> list[domain.PurchaseChannel]: + return ( + await domain.PurchaseChannel.filter( + purchase__project_id=project_id, purchase__status=domain.PurchaseStatus.ACTIVE + ) + .prefetch_related('channel') + .all() + ) + + async def add_channel_to_purchase( self, - plan_id: uuid.UUID, + purchase_id: uuid.UUID, channel_id: uuid.UUID, + invite_link: str, + invite_link_type: domain.purchase.InviteLinkType, *, - status: domain.PurchasePlanChannelStatus | None = None, + status: domain.PurchaseChannelStatus | None = None, planned_cost: float | None = None, comment: str | None = None, - ) -> domain.PurchasePlanChannel: - defaults: dict[str, object | None] = {} + ) -> domain.PurchaseChannel: + defaults: dict[str, object | None] = { + 'invite_link': invite_link, + 'invite_link_type': invite_link_type, + } if status is not None: defaults['status'] = status if planned_cost is not None: @@ -295,24 +318,24 @@ class Postgres(DatabaseBase, Database): if comment is not None: defaults['comment'] = comment - plan_channel, created = await domain.PurchasePlanChannel.get_or_create( - purchase_plan_id=plan_id, + purchase_channel, created = await domain.PurchaseChannel.get_or_create( + purchase_id=purchase_id, channel_id=channel_id, defaults=defaults, ) if not created and defaults: for field, value in defaults.items(): - setattr(plan_channel, field, value) - await plan_channel.save() + setattr(purchase_channel, field, value) + await purchase_channel.save() - return plan_channel + return purchase_channel - async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: - await plan_channel.save() + async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: + await purchase_channel.save() - async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None: - await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id, channel_id=channel_id).delete() + async def remove_channel_from_purchase(self, purchase_id: uuid.UUID, channel_id: uuid.UUID) -> None: + await domain.PurchaseChannel.filter(purchase_id=purchase_id, channel_id=channel_id).delete() async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related( @@ -485,27 +508,6 @@ class Postgres(DatabaseBase, Database): return results - async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: - return await domain.TelegramState.get_or_none(telegram_id=telegram_id) - - async def set_telegram_state( - self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any] - ) -> domain.TelegramState: - existing = await self.get_telegram_state(telegram_id) - - if existing: - existing.state = state - existing.context = context - existing.updated_at = timezone.now() - await existing.save() - return existing - - new_state = await domain.TelegramState.create(telegram_id=telegram_id, state=state, context=context) - return new_state - - async def clear_telegram_state(self, telegram_id: int) -> None: - await domain.TelegramState.filter(telegram_id=telegram_id).delete() - # Count methods async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int: return await domain.Placement.filter(creative_id=creative_id).count() diff --git a/src/controller/http_v1/__init__.py b/src/controller/http_v1/__init__.py index aa5fa1e..e506ebf 100644 --- a/src/controller/http_v1/__init__.py +++ b/src/controller/http_v1/__init__.py @@ -7,7 +7,7 @@ from src.controller.http_v1.creatives import creatives_router from src.controller.http_v1.internal import internal_router from src.controller.http_v1.placements import placements_router from src.controller.http_v1.projects import projects_router -from src.controller.http_v1.purchase_plans import purchase_plan_channels_router +from src.controller.http_v1.purchases import purchase_router from src.controller.http_v1.views import views_router from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router from src.controller.http_v1.workspace_members import workspace_members_router @@ -21,7 +21,7 @@ api_v1_router.include_router(auth_router) api_v1_router.include_router(internal_router) api_v1_router.include_router(channels_router) api_v1_router.include_router(projects_router) -api_v1_router.include_router(purchase_plan_channels_router) +api_v1_router.include_router(purchase_router) api_v1_router.include_router(creatives_router) api_v1_router.include_router(placements_router) api_v1_router.include_router(views_router) diff --git a/src/controller/http_v1/placements.py b/src/controller/http_v1/placements.py index b014838..1b66f82 100644 --- a/src/controller/http_v1/placements.py +++ b/src/controller/http_v1/placements.py @@ -48,40 +48,4 @@ async def get_placement( return await deps.get_usecase().get_placement(input=input) -@placements_router.post('') -async def create_placement( - request: dto.CreatePlacementInput, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.PlacementOutput: - return await deps.get_usecase().create_placement(request, current_user.user_id, workspace_id) - - -@placements_router.patch('/{placement_id}') -async def update_placement( - placement_id: uuid.UUID, - request: dto.UpdatePlacementInput, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.PlacementOutput: - return await deps.get_usecase().update_placement( - placement_id=placement_id, - input=request, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - -@placements_router.delete('/{placement_id}') -async def delete_placement( - placement_id: uuid.UUID, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> None: - input = dto.DeletePlacementInput( - placement_id=placement_id, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - await deps.get_usecase().delete_placement(input) +# Placement creation/updates are handled by worker; API exposes only read endpoints. diff --git a/src/controller/http_v1/projects.py b/src/controller/http_v1/projects.py index c72f15a..f81b82a 100644 --- a/src/controller/http_v1/projects.py +++ b/src/controller/http_v1/projects.py @@ -12,9 +12,8 @@ projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=[ @projects_router.get('') -async def get_workspace_projects( - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +async def get_projects( + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)] ) -> Page[dto.ProjectOutput]: input = dto.GetWorkspaceProjectsInput( user_id=current_user.user_id, @@ -32,18 +31,9 @@ async def update_project_invite_link_type( input: dto.UpdateProjectInviteLinkTypeInput, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.ProjectOutput: - project = await deps.get_usecase().update_project_invite_link_type( + return await deps.get_usecase().update_project_invite_link_type( workspace_id=workspace_id, project_id=project_id, - invite_link_type=input.invite_link_type, + purchase_invite_type_default=input.purchase_invite_type_default, user_id=current_user.user_id, ) - - return dto.ProjectOutput( - id=project.id, - telegram_id=project.channel.telegram_id, - title=project.channel.title, - username=project.channel.username, - status=project.status, - invite_link_type=project.invite_link_type, - ) diff --git a/src/controller/http_v1/purchase_plans.py b/src/controller/http_v1/purchase_plans.py deleted file mode 100644 index 84bb4ac..0000000 --- a/src/controller/http_v1/purchase_plans.py +++ /dev/null @@ -1,76 +0,0 @@ -import uuid -from typing import Annotated - -from fastapi import Depends -from fastapi.routing import APIRouter -from fastapi_pagination import Page, paginate - -from src import deps, dto -from src.adapter.jwt import JWTPayload - -purchase_plan_channels_router = APIRouter( - prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase-plan/channels', - tags=['purchase plan'], -) - - -@purchase_plan_channels_router.get('') -async def get_purchase_plan_channels( - workspace_id: uuid.UUID, - project_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> Page[dto.PurchasePlanChannelOutput]: - input = dto.GetPurchasePlanChannelsInput( - user_id=current_user.user_id, - workspace_id=workspace_id, - project_id=project_id, - ) - result = await deps.get_usecase().get_purchase_plan_channels(input=input) - return paginate(result) # type: ignore[no-any-return] - - -@purchase_plan_channels_router.post('') -async def attach_channel_to_purchase_plan( - request: dto.AttachChannelToPurchasePlanInput, - workspace_id: uuid.UUID, - project_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.PurchasePlanChannelOutput: - return await deps.get_usecase().attach_channel_to_purchase_plan( - project_id=project_id, - workspace_id=workspace_id, - user_id=current_user.user_id, - input=request, - ) - - -@purchase_plan_channels_router.patch('/{channel_id}') -async def update_purchase_plan_channel( - channel_id: uuid.UUID, - request: dto.UpdatePurchasePlanChannelInput, - workspace_id: uuid.UUID, - project_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.PurchasePlanChannelOutput: - return await deps.get_usecase().update_purchase_plan_channel( - channel_id=channel_id, - project_id=project_id, - workspace_id=workspace_id, - user_id=current_user.user_id, - input=request, - ) - - -@purchase_plan_channels_router.delete('/{channel_id}') -async def remove_purchase_plan_channel( - channel_id: uuid.UUID, - workspace_id: uuid.UUID, - project_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> None: - await deps.get_usecase().remove_purchase_plan_channel( - channel_id=channel_id, - project_id=project_id, - workspace_id=workspace_id, - user_id=current_user.user_id, - ) diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py new file mode 100644 index 0000000..6271908 --- /dev/null +++ b/src/controller/http_v1/purchases.py @@ -0,0 +1,60 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +purchase_router = APIRouter( + prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase', + tags=['purchase'], +) + + +@purchase_router.post('') +async def create_purchase( + request: dto.CreatePurchaseInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PurchaseOutput: + return await deps.get_usecase().create_purchase( + project_id=project_id, + workspace_id=workspace_id, + user_id=current_user.user_id, + input=request, + ) + + +@purchase_router.get('') +async def get_purchases( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> Page[dto.PurchaseOutput]: + input = dto.GetPurchasesInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + ) + result = await deps.get_usecase().get_purchases(input=input) + return paginate(result) # type: ignore[no-any-return] + + +@purchase_router.get('/{purchase_id}') +async def get_purchase( + purchase_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PurchaseOutput: + input = dto.GetPurchaseInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + purchase_id=purchase_id, + ) + return await deps.get_usecase().get_purchase(input=input) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 984583c..44ff8d3 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -15,18 +15,16 @@ __all__ = ( 'Channel', 'Project', 'ProjectStatus', - 'PurchasePlan', - 'PurchasePlanChannel', - 'PurchasePlanStatus', - 'PurchasePlanChannelStatus', + 'Purchase', + 'PurchaseChannel', + 'PurchaseStatus', + 'PurchaseChannelStatus', 'Creative', 'replace_invite_link_with_tag', 'Placement', 'Post', 'Subscription', 'PostViewsHistory', - 'TelegramState', - 'TelegramStateEnum', 'ChannelNotFound', 'ProjectNotFound', 'CreativeStatus', @@ -45,8 +43,8 @@ __all__ = ( 'LoginTokenExpired', 'LoginTokenAlreadyUsed', 'ProjectNotFound', - 'PurchasePlanNotFound', - 'PurchasePlanChannelNotFound', + 'PurchaseNotFound', + 'PurchaseChannelNotFound', 'ChannelNotFound', 'ChannelAlreadyExists', 'ChannelNoAdminRights', @@ -74,8 +72,8 @@ from .error import ( LoginTokenNotFound, PlacementNotFound, ProjectNotFound, - PurchasePlanChannelNotFound, - PurchasePlanNotFound, + PurchaseChannelNotFound, + PurchaseNotFound, TelegramChannelNotFound, UserByUsernameNotFound, UserNotFound, @@ -89,15 +87,15 @@ from .login_token import LoginToken from .placement import Placement, PlacementStatus, PostViewsAvailability from .post import Post from .post_views_history import PostViewsHistory -from .project import InviteLinkType, Project, ProjectStatus -from .purchase_plan import ( - PurchasePlan, - PurchasePlanChannel, - PurchasePlanChannelStatus, - PurchasePlanStatus, +from .project import Project, ProjectStatus +from .purchase import ( + InviteLinkType, + Purchase, + PurchaseChannel, + PurchaseChannelStatus, + PurchaseStatus, ) from .subscription import Subscription, SubscriptionStatus -from .telegram_state import TelegramState, TelegramStateEnum from .user import User from .workspace import ( PermissionKey, diff --git a/src/domain/error.py b/src/domain/error.py index fd8b529..897658a 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -43,16 +43,16 @@ def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException: return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found') -def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException: - if plan_id is None: - return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found') - return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found') +def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException: + if purchase_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} not found') -def PurchasePlanChannelNotFound(plan_channel_id: uuid.UUID | None = None) -> HTTPException: - if plan_channel_id is None: - return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found') - return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found') +def PurchaseChannelNotFound(purchase_channel_id: uuid.UUID | None = None) -> HTTPException: + if purchase_channel_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found') def ChannelAlreadyExists(telegram_id: int) -> HTTPException: diff --git a/src/domain/placement.py b/src/domain/placement.py index 73e211a..d388370 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from .creative import Creative from .post import Post from .project import Project + from .purchase import Purchase, PurchaseChannel from .workspace import Workspace @@ -27,35 +28,44 @@ class PostViewsAvailability(str, enum.Enum): class Placement(TimestampedModel): - placement_date = fields.DatetimeField() + wanted_placement_date = fields.DatetimeField(db_column='placement_date') cost = fields.FloatField(null=True) comment = fields.TextField(null=True) invite_link = fields.CharField(max_length=512) status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True + ) project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True ) - placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( - 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True - ) creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True ) - workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( - 'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True + placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True ) + purchase: fields.ForeignKeyRelation['Purchase'] | None = fields.ForeignKeyField( + 'models.Purchase', related_name='placements', on_delete=fields.CASCADE, null=True, index=True + ) + purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] | None = fields.ForeignKeyField( + 'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, null=True, index=True + ) + post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( 'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True ) if TYPE_CHECKING: - project_id: UUID - placement_channel_id: UUID - creative_id: UUID workspace_id: UUID + project_id: UUID + creative_id: UUID + placement_channel_id: UUID + purchase_id: UUID | None post_id: UUID | None + purchase_channel_id: UUID | None class Meta: table = 'placement' diff --git a/src/domain/project.py b/src/domain/project.py index 38d0b16..b4bb7ea 100644 --- a/src/domain/project.py +++ b/src/domain/project.py @@ -4,6 +4,7 @@ from uuid import UUID from tortoise import fields +from .purchase import InviteLinkType from .base import TimestampedModel if TYPE_CHECKING: @@ -17,25 +18,21 @@ class ProjectStatus(str, enum.Enum): ARCHIVED = 'archived' -class InviteLinkType(str, enum.Enum): - PUBLIC = 'public' # открытая ссылка - APPROVAL = 'approval' # с одобрением ботом - - class Project(TimestampedModel): status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) - invite_link_type = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10) + purchase_invite_type_default = fields.CharEnumField(InviteLinkType, default=InviteLinkType.APPROVAL, max_length=10) - workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( - 'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True - ) channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( 'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True ) + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True + ) + if TYPE_CHECKING: - workspace_id: UUID channel_id: UUID + workspace_id: UUID class Meta: table = 'project' diff --git a/src/domain/purchase.py b/src/domain/purchase.py new file mode 100644 index 0000000..4c77bc0 --- /dev/null +++ b/src/domain/purchase.py @@ -0,0 +1,74 @@ +import enum +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .channel import Channel + from .creative import Creative + from .project import Project + from .workspace import Workspace + + +class PurchaseStatus(str, enum.Enum): + ACTIVE = 'active' + ARCHIVED = 'archived' + + +class PurchaseChannelStatus(str, enum.Enum): + PLANNED = 'planned' + APPROVED = 'approved' + REJECTED = 'rejected' + IN_PROGRESS = 'in_progress' + COMPLETED = 'completed' + + +class Purchase(TimestampedModel): + status = fields.CharEnumField(PurchaseStatus, default=PurchaseStatus.ACTIVE) + + project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( + 'models.Project', related_name='purchases', on_delete=fields.CASCADE, index=True + ) + creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( + 'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True + ) + + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='purchases', on_delete=fields.CASCADE, index=True + ) + + class Meta: + table = 'purchase' + indexes = (('project', 'status'),) + + +class InviteLinkType(str, enum.Enum): + PUBLIC = 'public' # открытая ссылка + APPROVAL = 'approval' # с одобрением ботом + + +class PurchaseChannel(TimestampedModel): + status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED) + planned_cost = fields.FloatField(null=True) + comment = fields.TextField(null=True) + + invite_link = fields.CharField(max_length=512) + invite_link_type = fields.CharEnumField(InviteLinkType) + + purchase: fields.ForeignKeyRelation['Purchase'] = fields.ForeignKeyField( + 'models.Purchase', related_name='channels', on_delete=fields.CASCADE, index=True + ) + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='purchase_channels', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + purchase_id: uuid.UUID + channel_id: uuid.UUID + + class Meta: + table = 'purchase_channel' + unique_together = (('purchase_id', 'channel_id'),) diff --git a/src/domain/purchase_plan.py b/src/domain/purchase_plan.py deleted file mode 100644 index 5ed4c64..0000000 --- a/src/domain/purchase_plan.py +++ /dev/null @@ -1,61 +0,0 @@ -import enum -import uuid -from typing import TYPE_CHECKING - -from tortoise import fields - -from .base import TimestampedModel - -if TYPE_CHECKING: - from .channel import Channel - from .project import Project - from .workspace import Workspace - - -class PurchasePlanStatus(str, enum.Enum): - ACTIVE = 'active' - ARCHIVED = 'archived' - - -class PurchasePlanChannelStatus(str, enum.Enum): - PLANNED = 'planned' - APPROVED = 'approved' - REJECTED = 'rejected' - IN_PROGRESS = 'in_progress' - COMPLETED = 'completed' - - -class PurchasePlan(TimestampedModel): - status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE) - - workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( - 'models.Workspace', related_name='purchase_plans', on_delete=fields.CASCADE, index=True - ) - project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( - 'models.Project', related_name='purchase_plans', on_delete=fields.CASCADE, index=True - ) - - class Meta: - table = 'purchase_plan' - indexes = (('project', 'status'),) - - -class PurchasePlanChannel(TimestampedModel): - status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED) - planned_cost = fields.FloatField(null=True) - comment = fields.TextField(null=True) - - purchase_plan: fields.ForeignKeyRelation['PurchasePlan'] = fields.ForeignKeyField( - 'models.PurchasePlan', related_name='channels', on_delete=fields.CASCADE, index=True - ) - channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( - 'models.Channel', related_name='purchase_plan_channels', on_delete=fields.CASCADE, index=True - ) - - if TYPE_CHECKING: - purchase_plan_id: uuid.UUID - channel_id: uuid.UUID - - class Meta: - table = 'purchase_plan_channel' - unique_together = (('purchase_plan_id', 'channel_id'),) diff --git a/src/domain/telegram_state.py b/src/domain/telegram_state.py deleted file mode 100644 index 92d0497..0000000 --- a/src/domain/telegram_state.py +++ /dev/null @@ -1,22 +0,0 @@ -import enum - -from tortoise import fields - -from .base import TimestampedModel - - -class TelegramStateEnum(str, enum.Enum): - CREATIVE_WAITING_WORKSPACE = 'creative_waiting_workspace' - CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel' - CREATIVE_WAITING_NAME = 'creative_waiting_name' - CREATIVE_WAITING_TEXT = 'creative_waiting_text' - PROJECT_WAITING_WORKSPACE = 'project_waiting_workspace' - - -class TelegramState(TimestampedModel): - telegram_id = fields.BigIntField(unique=True, index=True) - state = fields.CharEnumField(TelegramStateEnum) - context = fields.JSONField(default=dict) - - class Meta: - table = 'telegram_state' diff --git a/src/dto/__init__.py b/src/dto/__init__.py index ce9bf3a..755a0d7 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -9,18 +9,19 @@ __all__ = ( 'ProjectOutput', 'ValidateLoginTokenInput', 'ValidateLoginTokenOutput', - 'TelegramLoginInput', 'ChannelBotPermissions', 'ChannelOutput', 'GetChannelInput', 'GetChannelsInput', 'GetChannelsOutput', 'AttachChannelToWorkspaceInput', - 'PurchasePlanChannelOutput', - 'GetPurchasePlanChannelsInput', - 'GetPurchasePlanChannelsOutput', - 'AttachChannelToPurchasePlanInput', - 'UpdatePurchasePlanChannelInput', + 'PurchaseOutput', + 'PurchaseChannelOutput', + 'CreatePurchaseInput', + 'CreatePurchaseChannelInput', + 'GetPurchasesInput', + 'GetPurchasesOutput', + 'GetPurchaseInput', 'CreativeOutput', 'GetCreativesInput', 'GetCreativesOutput', @@ -32,9 +33,6 @@ __all__ = ( 'GetPlacementsInput', 'GetPlacementsOutput', 'GetPlacementInput', - 'CreatePlacementInput', - 'UpdatePlacementInput', - 'DeletePlacementInput', 'PostViewsHistoryOutput', 'GetViewsHistoryInput', 'GetViewsHistoryOutput', @@ -104,15 +102,7 @@ from .creative import ( GetCreativesOutput, UpdateCreativeInput, ) -from .placement import ( - CreatePlacementInput, - DeletePlacementInput, - GetPlacementInput, - GetPlacementsInput, - GetPlacementsOutput, - PlacementOutput, - UpdatePlacementInput, -) +from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput from .project import ( ChannelBotPermissions, ConnectProjectInput, @@ -124,14 +114,15 @@ from .project import ( UpdateProjectInviteLinkTypeInput, UpdateProjectPermissionsInput, ) -from .purchase_plan import ( - AttachChannelToPurchasePlanInput, - GetPurchasePlanChannelsInput, - GetPurchasePlanChannelsOutput, - PurchasePlanChannelOutput, - UpdatePurchasePlanChannelInput, +from .purchase import ( + CreatePurchaseChannelInput, + CreatePurchaseInput, + GetPurchaseInput, + GetPurchasesInput, + GetPurchasesOutput, + PurchaseChannelOutput, + PurchaseOutput, ) -from .telegram_login import TelegramLoginInput from .user import UserOutput from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput from .views import ( @@ -162,4 +153,4 @@ from .workspace import ( class CreateLoginTokenRequest(BaseModel): telegram_id: int - username: str | None \ No newline at end of file + username: str | None diff --git a/src/dto/placement.py b/src/dto/placement.py index 4773657..7ea0250 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -18,10 +18,12 @@ class PlacementOutput(pydantic.BaseModel): cost: float | None comment: str | None ad_post_url: str | None - invite_link_type: domain.InviteLinkType + invite_link_type: domain.purchase.InviteLinkType invite_link: str status: domain.PlacementStatus subscriptions_count: int + purchase_id: uuid.UUID | None = None + purchase_channel_id: uuid.UUID | None = None created_at: datetime.datetime diff --git a/src/dto/project.py b/src/dto/project.py index f62b7b5..02d3cea 100644 --- a/src/dto/project.py +++ b/src/dto/project.py @@ -32,11 +32,11 @@ class ProjectOutput(pydantic.BaseModel): title: str username: str | None status: ProjectStatus - invite_link_type: domain.InviteLinkType + purchase_invite_type_default: domain.purchase.InviteLinkType class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel): - invite_link_type: domain.InviteLinkType + purchase_invite_type_default: domain.purchase.InviteLinkType class GetWorkspaceProjectsInput(pydantic.BaseModel): diff --git a/src/dto/purchase.py b/src/dto/purchase.py new file mode 100644 index 0000000..4299c1a --- /dev/null +++ b/src/dto/purchase.py @@ -0,0 +1,53 @@ +import uuid + +import pydantic + +from src.domain.purchase import InviteLinkType, PurchaseChannelStatus, PurchaseStatus + +from .channel import ChannelOutput + + +class PurchaseChannelOutput(pydantic.BaseModel): + id: uuid.UUID + status: PurchaseChannelStatus + planned_cost: float | None = None + comment: str | None = None + invite_link: str + invite_link_type: InviteLinkType + channel: ChannelOutput + + +class PurchaseOutput(pydantic.BaseModel): + id: uuid.UUID + status: PurchaseStatus + creative_id: uuid.UUID + channels: list[PurchaseChannelOutput] + + +class CreatePurchaseChannelInput(pydantic.BaseModel): + username: str = pydantic.Field(pattern=r'^[^@]') + status: PurchaseChannelStatus | None = None + planned_cost: float | None = None + comment: str | None = None + + +class CreatePurchaseInput(pydantic.BaseModel): + creative_id: uuid.UUID + channels: list[CreatePurchaseChannelInput] + + +class GetPurchasesInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + + +class GetPurchasesOutput(pydantic.BaseModel): + purchases: list[PurchaseOutput] + + +class GetPurchaseInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + purchase_id: uuid.UUID diff --git a/src/dto/purchase_plan.py b/src/dto/purchase_plan.py deleted file mode 100644 index bcaffd6..0000000 --- a/src/dto/purchase_plan.py +++ /dev/null @@ -1,38 +0,0 @@ -import uuid - -import pydantic - -from src.domain.purchase_plan import PurchasePlanChannelStatus - -from .channel import ChannelOutput - - -class PurchasePlanChannelOutput(pydantic.BaseModel): - id: uuid.UUID - status: PurchasePlanChannelStatus - planned_cost: float | None = None - comment: str | None = None - channel: ChannelOutput - - -class GetPurchasePlanChannelsInput(pydantic.BaseModel): - user_id: uuid.UUID - workspace_id: uuid.UUID - project_id: uuid.UUID - - -class GetPurchasePlanChannelsOutput(pydantic.BaseModel): - channels: list[PurchasePlanChannelOutput] - - -class AttachChannelToPurchasePlanInput(pydantic.BaseModel): - username: str = pydantic.Field(pattern=r'^[^@]') - status: PurchasePlanChannelStatus | None = None - planned_cost: float | None = None - comment: str | None = None - - -class UpdatePurchasePlanChannelInput(pydantic.BaseModel): - status: PurchasePlanChannelStatus | None = None - planned_cost: float | None = None - comment: str | None = None diff --git a/src/dto/telegram_login.py b/src/dto/telegram_login.py deleted file mode 100644 index bf71060..0000000 --- a/src/dto/telegram_login.py +++ /dev/null @@ -1,7 +0,0 @@ -import pydantic - - -class TelegramLoginInput(pydantic.BaseModel): - telegram_id: int - username: str | None - chat_id: int diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 3489596..3a4bd4b 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -3,7 +3,6 @@ import typing from dataclasses import dataclass from uuid import UUID - if typing.TYPE_CHECKING: from aiogram.types import InlineKeyboardButton @@ -13,8 +12,8 @@ from .analytics.get_channel_analytics import get_channel_analytics from .analytics.get_creatives_analytics import get_creatives_analytics from .analytics.get_placements_analytics import get_placements_analytics from .analytics.get_spending_analytics import get_spending_analytics -from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id from .auth.create_telegram_login_token import create_telegram_login_token +from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id from .auth.get_me import get_me from .auth.validate_login_token import validate_login_token from .channel.attach_channel_to_workspace import attach_channel_to_workspace @@ -25,22 +24,18 @@ from .creative.delete_creative import delete_creative from .creative.get_creative import get_creative from .creative.get_creatives import get_creatives from .creative.update_creative import update_creative -from .placement.create_placement import create_placement -from .placement.delete_placement import delete_placement from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle from .placement.get_placement import get_placement from .placement.get_placements import get_placements -from .placement.update_placement import update_placement from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.get_project import get_project from .project.get_workspace_projects import get_workspace_projects from .project.tg_add_project import tg_add_project from .project.update_project_invite_link_type import update_project_invite_link_type from .project.update_project_permissions import update_project_permissions -from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan -from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels -from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel -from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_channel +from .purchase.create_purchase import create_purchase +from .purchase.get_purchase import get_purchase +from .purchase.get_purchases import get_purchases from .subscription.handle_subscription import handle_subscription from .subscription.handle_unsubscription import handle_unsubscription from .views.get_views_history import get_views_history @@ -155,31 +150,37 @@ class Database(typing.Protocol): self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None ) -> list[domain.Project]: ... - async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ... + async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ... - async def get_purchase_plan(self, workspace_id: UUID, plan_id: UUID) -> domain.PurchasePlan | None: ... + async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ... - async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: ... + async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ... - async def get_purchase_plan_channels(self, plan_id: UUID) -> list[domain.PurchasePlanChannel]: ... + async def create_purchase(self, purchase: domain.Purchase) -> None: ... - async def get_purchase_plan_channel( - self, plan_channel_id: UUID, plan_id: UUID - ) -> domain.PurchasePlanChannel | None: ... + async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ... - async def add_channel_to_purchase_plan( + async def get_purchase_channel( + self, purchase_channel_id: UUID, purchase_id: UUID + ) -> domain.PurchaseChannel | None: ... + + async def get_purchase_channels_by_project(self, project_id: UUID) -> list[domain.PurchaseChannel]: ... + + async def add_channel_to_purchase( self, - plan_id: UUID, + purchase_id: UUID, channel_id: UUID, + invite_link: str, + invite_link_type: domain.purchase.InviteLinkType, *, - status: domain.PurchasePlanChannelStatus | None = None, + status: domain.PurchaseChannelStatus | None = None, planned_cost: float | None = None, comment: str | None = None, - ) -> domain.PurchasePlanChannel: ... + ) -> domain.PurchaseChannel: ... - async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: ... + async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ... - async def remove_channel_from_purchase_plan(self, plan_id: UUID, channel_id: UUID) -> None: ... + async def remove_channel_from_purchase(self, purchase_id: UUID, channel_id: UUID) -> None: ... async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ... @@ -197,10 +198,6 @@ class Database(typing.Protocol): async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ... - async def create_placement(self, placement: domain.Placement) -> None: ... - - async def update_placement(self, placement: domain.Placement) -> None: ... - async def get_workspace_placements( self, workspace_id: UUID, @@ -264,14 +261,6 @@ class Database(typing.Protocol): async def get_latest_views_data_batch(self, post_ids: list[UUID]) -> dict[UUID, tuple[int, datetime.datetime]]: ... - async def get_telegram_state(self, telegram_id: int) -> domain.TelegramState | None: ... - - async def set_telegram_state( - self, telegram_id: int, state: domain.TelegramStateEnum, context: dict[str, typing.Any] - ) -> domain.TelegramState: ... - - async def clear_telegram_state(self, telegram_id: int) -> None: ... - class TelegramBotWriter(typing.Protocol): async def send_message(self, text: str, chat_id: int) -> None: ... @@ -362,14 +351,14 @@ class Usecase: return workspace - async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan: - plan = await self.database.get_active_purchase_plan(project.id) - if plan: - return plan + async def ensure_active_purchase(self, project: domain.Project, creative: domain.Creative) -> domain.Purchase: + purchase = await self.database.get_active_purchase(project.id, creative.id) + if purchase: + return purchase - plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id) - await self.database.create_purchase_plan(plan) - return plan + purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id) + await self.database.create_purchase(purchase) + return purchase validate_login_token = validate_login_token create_telegram_login_token = create_telegram_login_token @@ -384,10 +373,9 @@ class Usecase: get_channels = get_channels get_channel = get_channel attach_channel_to_workspace = attach_channel_to_workspace - attach_channel_to_purchase_plan = attach_channel_to_purchase_plan - get_purchase_plan_channels = get_purchase_plan_channels - update_purchase_plan_channel = update_purchase_plan_channel - remove_purchase_plan_channel = remove_purchase_plan_channel + create_purchase = create_purchase + get_purchases = get_purchases + get_purchase = get_purchase get_creatives = get_creatives get_creative = get_creative create_creative = create_creative @@ -395,9 +383,6 @@ class Usecase: delete_creative = delete_creative get_placements = get_placements get_placement = get_placement - create_placement = create_placement - update_placement = update_placement - delete_placement = delete_placement fetch_placement_post_cycle = fetch_placement_post_cycle handle_subscription = handle_subscription handle_unsubscription = handle_unsubscription diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py index 37e6350..f85afba 100644 --- a/src/usecase/analytics/get_channel_analytics.py +++ b/src/usecase/analytics/get_channel_analytics.py @@ -25,9 +25,8 @@ async def get_channel_analytics( if not project: raise domain.ProjectNotFound(input.project_id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) - plan = await self.ensure_active_purchase_plan(project) - plan_channels = await self.database.get_purchase_plan_channels(plan.id) - channels = [pc.channel for pc in plan_channels] + purchase_channels = await self.database.get_purchase_channels_by_project(project.id) + channels = [pc.channel for pc in purchase_channels] allowed_project_filter = None else: allowed_project_filter = allowed_project_ids diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index d40bfa4..97021f6 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -61,7 +61,7 @@ async def get_placements_analytics( placement_channel_title=p.placement_channel.title, creative_id=p.creative_id, creative_name=p.creative.name, - placement_date=p.placement_date, + placement_date=p.wanted_placement_date, cost=p.cost, subscriptions_count=subscriptions_counts.get(p.id, 0), views_count=views_map[p.post.id][0] if p.post and p.post.id in views_map else None, diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index 5feec20..689fffe 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -56,8 +56,8 @@ async def get_spending_analytics( filtered = [ p for p in placements - if (not input.date_from or p.placement_date >= input.date_from) - and (not input.date_to or p.placement_date <= input.date_to) + if (not input.date_from or p.wanted_placement_date >= input.date_from) + and (not input.date_to or p.wanted_placement_date <= input.date_to) ] total_cost = 0.0 @@ -82,7 +82,7 @@ async def get_spending_analytics( period_data: dict[str, PeriodData] = defaultdict(PeriodData) for p in filtered: - period = _format_period(p.placement_date, input.grouping) + period = _format_period(p.wanted_placement_date, input.grouping) pd = period_data[period] if p.cost is not None: diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py index 398c3d1..0e67b8a 100644 --- a/src/usecase/channel/attach_channel_to_workspace.py +++ b/src/usecase/channel/attach_channel_to_workspace.py @@ -41,12 +41,10 @@ async def attach_channel_to_workspace( await self.database.create_project(project) log.info('Project created for channel %s in workspace %s', channel.id, workspace.id) - await self.ensure_active_purchase_plan(project) - return dto.ProjectOutput( id=project.id, telegram_id=channel.telegram_id, title=channel.title, username=channel.username, status=project.status, - ) \ No newline at end of file + ) diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index f2fa7ca..74ef62f 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -2,6 +2,7 @@ import logging import uuid from typing import TYPE_CHECKING +import domain.purchase from src import domain, dto if TYPE_CHECKING: @@ -35,7 +36,7 @@ async def create_placement( raise domain.CreativeNotFound(input.creative_id) # Берем тип ссылки из настроек проекта - requires_approval = project.invite_link_type == domain.InviteLinkType.APPROVAL + requires_approval = project.purchase_invite_type_default == domain.purchase.InviteLinkType.APPROVAL if project.channel.telegram_id is None: raise domain.ChannelNotFound(project.channel.id) @@ -64,11 +65,11 @@ async def create_placement( placement_channel_title=placement_channel.title, creative_id=placement.creative_id, creative_name=creative.name, - placement_date=placement.placement_date, + placement_date=placement.wanted_placement_date, cost=placement.cost, comment=placement.comment, ad_post_url=None, - invite_link_type=project.invite_link_type, + invite_link_type=project.purchase_invite_type_default, invite_link=placement.invite_link, status=placement.status, subscriptions_count=0, diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py index 0e0e02b..0a3a4c8 100644 --- a/src/usecase/placement/fetch_placement_post_cycle.py +++ b/src/usecase/placement/fetch_placement_post_cycle.py @@ -12,52 +12,56 @@ log = logging.getLogger(__name__) async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None: log.debug('Starting fetch_placement_post_cycle') - placements_without_post = ( - await domain.Placement.filter( - post_id__isnull=True, - ) - .prefetch_related('placement_channel') + purchase_channels = ( + await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE) + .prefetch_related('channel', 'purchase') .all() ) - if not placements_without_post: - log.debug('No placements without posts found') + if not purchase_channels: + log.debug('No active purchase channels found') return - log.info(f'Found {len(placements_without_post)} placements without posts') + created_count = 0 - updated_count = 0 - - for placement in placements_without_post: - try: - matching_post = await domain.Post.filter( - channel_id=placement.placement_channel_id, - text__contains=placement.invite_link, - deleted_from_channel_at__isnull=True, - ).first() - - if matching_post: - # Validate channel consistency - if matching_post.channel_id != placement.placement_channel_id: - log.error( - 'Post %s channel_id mismatch: expected %s, got %s. Skipping placement %s', - matching_post.id, - placement.placement_channel_id, - matching_post.channel_id, - placement.id, - ) - continue - - placement.post = matching_post - await placement.save() - updated_count += 1 - - log.info( - f'Linked post {matching_post.id} (message_id={matching_post.message_id}) ' - f'to placement {placement.id}' - ) - except Exception as e: - log.exception(f'Error processing placement {placement.id}: {e}') + for purchase_channel in purchase_channels: + if purchase_channel.channel is None or purchase_channel.purchase is None: + log.warning('Purchase channel %s missing channel or purchase, skipping', purchase_channel.id) continue - log.info(f'Fetch placement post cycle completed. Updated {updated_count} placements') + posts = await domain.Post.filter( + channel_id=purchase_channel.channel_id, + text__contains=purchase_channel.invite_link, + deleted_from_channel_at__isnull=True, + ).all() + + for post in posts: + existing = await domain.Placement.filter(post_id=post.id).first() + if existing: + continue + + placement = domain.Placement( + wanted_placement_date=post.created_at, + cost=None, + comment=None, + invite_link=purchase_channel.invite_link, + workspace_id=purchase_channel.purchase.workspace_id, + project_id=purchase_channel.purchase.project_id, + creative_id=purchase_channel.purchase.creative_id, + placement_channel_id=purchase_channel.channel_id, + purchase_id=purchase_channel.purchase_id, + purchase_channel_id=purchase_channel.id, + post=post, + status=domain.PlacementStatus.ACTIVE, + ) + + await self.database.create_placement(placement) + created_count += 1 + log.info( + 'Created placement %s for purchase_channel %s from post %s', + placement.id, + purchase_channel.id, + post.id, + ) + + log.info('Fetch placement post cycle completed. Created %s placements', created_count) diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py index cf6059b..6a2499d 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -33,11 +33,11 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl placement_channel_title=placement.placement_channel.title, creative_id=placement.creative_id, creative_name=placement.creative.name, - placement_date=placement.placement_date, + placement_date=placement.wanted_placement_date, cost=placement.cost, comment=placement.comment, ad_post_url=ad_post_url, - invite_link_type=placement.project.invite_link_type, + invite_link_type=placement.project.purchase_invite_type_default, invite_link=placement.invite_link, status=placement.status, subscriptions_count=subscriptions_count, diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py index ab9c107..7a9c931 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -42,11 +42,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list placement_channel_title=placement.placement_channel.title, creative_id=placement.creative_id, creative_name=placement.creative.name, - placement_date=placement.placement_date, + placement_date=placement.wanted_placement_date, cost=placement.cost, comment=placement.comment, ad_post_url=ad_post_url, - invite_link_type=placement.project.invite_link_type, + invite_link_type=placement.project.purchase_invite_type_default, invite_link=placement.invite_link, status=placement.status, subscriptions_count=subscriptions_counts.get(placement.id, 0), diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index 2b974ad..766bb41 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -27,7 +27,7 @@ async def update_placement( context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id) if input.placement_date is not None: - placement.placement_date = input.placement_date + placement.wanted_placement_date = input.placement_date if input.cost is not None: placement.cost = input.cost if input.comment is not None: @@ -49,11 +49,11 @@ async def update_placement( placement_channel_title=placement.placement_channel.title, creative_id=placement.creative_id, creative_name=placement.creative.name, - placement_date=placement.placement_date, + placement_date=placement.wanted_placement_date, cost=placement.cost, comment=placement.comment, ad_post_url=ad_post_url, - invite_link_type=placement.project.invite_link_type, + invite_link_type=placement.project.purchase_invite_type_default, invite_link=placement.invite_link, status=placement.status, subscriptions_count=subscriptions_count, diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py index 9f207bb..8e5eed6 100644 --- a/src/usecase/project/get_project.py +++ b/src/usecase/project/get_project.py @@ -20,5 +20,5 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec title=project.channel.title, username=project.channel.username, status=project.status, - invite_link_type=project.invite_link_type, + purchase_invite_type_default=project.purchase_invite_type_default, ) diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py index f3f0be1..43aebd7 100644 --- a/src/usecase/project/get_workspace_projects.py +++ b/src/usecase/project/get_workspace_projects.py @@ -6,9 +6,7 @@ if TYPE_CHECKING: from .. import Usecase -async def get_workspace_projects( - self: 'Usecase', input: dto.GetWorkspaceProjectsInput -) -> list[dto.ProjectOutput]: +async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProjectsInput) -> list[dto.ProjectOutput]: context = await self.ensure_workspace_permission( input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ ) @@ -24,7 +22,7 @@ async def get_workspace_projects( title=project.channel.title, username=project.channel.username, status=project.status, - invite_link_type=project.invite_link_type, + purchase_invite_type_default=project.purchase_invite_type_default, ) for project in projects ] diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py index 94e4cfa..917bd5d 100644 --- a/src/usecase/project/tg_add_project.py +++ b/src/usecase/project/tg_add_project.py @@ -104,8 +104,6 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto ) await self.database.create_project(project) - await self.ensure_active_purchase_plan(project) - log.info( 'Project for channel %s connected/updated successfully in workspace %s by user %s', input.telegram_id, diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py index fd0a718..d2467a4 100644 --- a/src/usecase/project/update_project_invite_link_type.py +++ b/src/usecase/project/update_project_invite_link_type.py @@ -2,7 +2,7 @@ import logging import uuid from typing import TYPE_CHECKING -from src import domain +from src import domain, dto if TYPE_CHECKING: from .. import Usecase @@ -14,9 +14,9 @@ async def update_project_invite_link_type( self: 'Usecase', workspace_id: uuid.UUID, project_id: uuid.UUID, - invite_link_type: domain.InviteLinkType, + purchase_invite_type_default: domain.purchase.InviteLinkType, user_id: uuid.UUID, -) -> domain.Project: +) -> dto.ProjectOutput: await self.ensure_workspace_permission( workspace_id, user_id, @@ -24,19 +24,19 @@ async def update_project_invite_link_type( for_project_id=project_id, ) - async with self.database.transaction(): - project = await self.database.get_project(workspace_id, project_id=project_id) - if not project: - raise domain.ProjectNotFound(project_id) + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) - project.invite_link_type = invite_link_type - await project.save() + project.purchase_invite_type_default = purchase_invite_type_default - log.info( - 'Project %s invite_link_type updated to %s by user %s', - project_id, - invite_link_type, - user_id, - ) + await self.database.update_project(project) - return project + return dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + ) diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py new file mode 100644 index 0000000..0793c50 --- /dev/null +++ b/src/usecase/purchase/create_purchase.py @@ -0,0 +1,117 @@ +import logging +import random +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _build_purchase_output( + purchase: domain.Purchase, purchase_channels: list[domain.PurchaseChannel] +) -> dto.PurchaseOutput: + channels_output: list[dto.PurchaseChannelOutput] = [] + for purchase_channel in purchase_channels: + channel = purchase_channel.channel + if channel is None: + log.warning('Purchase channel %s has no channel prefetched', purchase_channel.id) + continue + channels_output.append( + dto.PurchaseChannelOutput( + id=purchase_channel.id, + status=purchase_channel.status, + planned_cost=purchase_channel.planned_cost, + comment=purchase_channel.comment, + invite_link=purchase_channel.invite_link, + invite_link_type=purchase_channel.invite_link_type, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), + ) + ) + + return dto.PurchaseOutput( + id=purchase.id, + status=purchase.status, + creative_id=purchase.creative_id, + channels=channels_output, + ) + + +async def create_purchase( + self: 'Usecase', + project_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.CreatePurchaseInput, +) -> dto.PurchaseOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + + creative = await self.database.get_creative(workspace_id, input.creative_id) + if not creative or creative.project_id != project.id: + raise domain.CreativeNotFound(input.creative_id) + + existing_purchase = await self.database.get_active_purchase(project.id, creative.id) + if existing_purchase: + channels = await self.database.get_purchase_channels(existing_purchase.id) + return _build_purchase_output(existing_purchase, channels) + + purchase = domain.Purchase(workspace_id=workspace_id, project_id=project.id, creative_id=creative.id) + await self.database.create_purchase(purchase) + + if project.channel.telegram_id is None: + raise domain.ChannelNotFound(project.channel.id) + + invite_link_type = project.purchase_invite_type_default + requires_approval = invite_link_type == domain.purchase.InviteLinkType.APPROVAL + + purchase_channels: list[domain.PurchaseChannel] = [] + + for channel_input in input.channels: + channel = await self.database.get_channel(username=channel_input.username) + + if not channel: + channel = domain.Channel( + username=channel_input.username, + telegram_id=random.randint(1, 10000), + title='parser_response.title', + ) + await self.database.create_channel(channel) + log.info('Created channel @%s with telegram_id=%s', channel_input.username, channel.telegram_id) + + invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + + purchase_channel = await self.database.add_channel_to_purchase( + purchase.id, + channel.id, + invite_link=invite_link, + invite_link_type=invite_link_type, + status=channel_input.status, + planned_cost=channel_input.planned_cost, + comment=channel_input.comment, + ) + purchase_channel.channel = channel + purchase_channels.append(purchase_channel) + + log.info( + 'Purchase %s created for project %s (creative %s) with %s channels', + purchase.id, + project.id, + creative.id, + len(purchase_channels), + ) + + return _build_purchase_output(purchase, purchase_channels) diff --git a/src/usecase/purchase/get_purchase.py b/src/usecase/purchase/get_purchase.py new file mode 100644 index 0000000..da67ea2 --- /dev/null +++ b/src/usecase/purchase/get_purchase.py @@ -0,0 +1,30 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto +from .create_purchase import _build_purchase_output + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) + + purchase = await self.database.get_purchase(input.workspace_id, input.purchase_id) + if not purchase or purchase.project_id != project.id: + raise domain.PurchaseNotFound(input.purchase_id) + + channels = await self.database.get_purchase_channels(purchase.id) + + return _build_purchase_output(purchase, channels) diff --git a/src/usecase/purchase/get_purchases.py b/src/usecase/purchase/get_purchases.py new file mode 100644 index 0000000..24271f2 --- /dev/null +++ b/src/usecase/purchase/get_purchases.py @@ -0,0 +1,32 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto +from .create_purchase import _build_purchase_output + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_purchases(self: 'Usecase', input: dto.GetPurchasesInput) -> list[dto.PurchaseOutput]: + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) + + purchases = await self.database.get_project_purchases(input.workspace_id, project.id) + log.debug('Fetched %s purchases for project %s', len(purchases), project.id) + + output: list[dto.PurchaseOutput] = [] + for purchase in purchases: + channels = await self.database.get_purchase_channels(purchase.id) + output.append(_build_purchase_output(purchase, channels)) + + return output diff --git a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py deleted file mode 100644 index d6be749..0000000 --- a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging -import random -import uuid -from typing import TYPE_CHECKING - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def attach_channel_to_purchase_plan( - self: 'Usecase', - project_id: uuid.UUID, - workspace_id: uuid.UUID, - user_id: uuid.UUID, - input: dto.AttachChannelToPurchasePlanInput, -) -> dto.PurchasePlanChannelOutput: - context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) - - project = await self.database.get_project(workspace_id, project_id=project_id) - if not project: - raise domain.ProjectNotFound(project_id) - - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) - - plan = await self.ensure_active_purchase_plan(project) - - channel = await self.database.get_channel(username=input.username) - - if not channel: - # parser_response = await self.parser.fetch_telegram_channel(input.username) - # if not parser_response: - # raise domain.TelegramChannelNotFound(input.username) - - channel = domain.Channel( - username=input.username, - telegram_id=random.randint(1, 10000), - title='parser_response.title', - ) - await self.database.create_channel(channel) - log.info('Created channel @%s with telegram_id=%s', input.username, channel.telegram_id) - - plan_channel = await self.database.add_channel_to_purchase_plan( - plan.id, - channel.id, - status=input.status, - planned_cost=input.planned_cost, - comment=input.comment, - ) - - log.info('Channel %s attached to purchase plan %s (project %s)', channel.id, plan.id, project.id) - - return dto.PurchasePlanChannelOutput( - id=plan_channel.id, - status=plan_channel.status, - planned_cost=plan_channel.planned_cost, - comment=plan_channel.comment, - channel=dto.ChannelOutput( - id=channel.id, - telegram_id=channel.telegram_id, - title=channel.title, - username=channel.username, - ), - ) diff --git a/src/usecase/purchase_plan/get_purchase_plan_channels.py b/src/usecase/purchase_plan/get_purchase_plan_channels.py deleted file mode 100644 index 4b7587b..0000000 --- a/src/usecase/purchase_plan/get_purchase_plan_channels.py +++ /dev/null @@ -1,44 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def get_purchase_plan_channels( - self: 'Usecase', input: dto.GetPurchasePlanChannelsInput -) -> list[dto.PurchasePlanChannelOutput]: - context = await self.ensure_workspace_permission( - input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ - ) - - project = await self.database.get_project(input.workspace_id, project_id=input.project_id) - if not project: - raise domain.ProjectNotFound(input.project_id) - - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) - - plan = await self.ensure_active_purchase_plan(project) - - plan_channels = await self.database.get_purchase_plan_channels(plan.id) - log.debug('Fetched %s channels for purchase plan %s (project %s)', len(plan_channels), plan.id, project.id) - - return [ - dto.PurchasePlanChannelOutput( - id=plan_channel.id, - status=plan_channel.status, - planned_cost=plan_channel.planned_cost, - comment=plan_channel.comment, - channel=dto.ChannelOutput( - id=plan_channel.channel.id, - telegram_id=plan_channel.channel.telegram_id, - title=plan_channel.channel.title, - username=plan_channel.channel.username, - ), - ) - for plan_channel in plan_channels - ] diff --git a/src/usecase/purchase_plan/remove_purchase_plan_channel.py b/src/usecase/purchase_plan/remove_purchase_plan_channel.py deleted file mode 100644 index f861ea0..0000000 --- a/src/usecase/purchase_plan/remove_purchase_plan_channel.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -import uuid -from typing import TYPE_CHECKING - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def remove_purchase_plan_channel( - self: 'Usecase', - channel_id: uuid.UUID, - project_id: uuid.UUID, - workspace_id: uuid.UUID, - user_id: uuid.UUID, -) -> None: - context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) - - project = await self.database.get_project(workspace_id, project_id=project_id) - if not project: - raise domain.ProjectNotFound(project_id) - - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) - - plan = await self.ensure_active_purchase_plan(project) - - plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) - if not plan_channel: - raise domain.PurchasePlanChannelNotFound(channel_id) - - await self.database.remove_channel_from_purchase_plan(plan.id, plan_channel.channel_id) - log.info('Removed channel %s from purchase plan %s (project %s)', plan_channel.channel_id, plan.id, project.id) diff --git a/src/usecase/purchase_plan/update_purchase_plan_channel.py b/src/usecase/purchase_plan/update_purchase_plan_channel.py deleted file mode 100644 index fab9f3f..0000000 --- a/src/usecase/purchase_plan/update_purchase_plan_channel.py +++ /dev/null @@ -1,72 +0,0 @@ -import logging -import uuid -from typing import TYPE_CHECKING - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def update_purchase_plan_channel( - self: 'Usecase', - channel_id: uuid.UUID, - project_id: uuid.UUID, - workspace_id: uuid.UUID, - user_id: uuid.UUID, - input: dto.UpdatePurchasePlanChannelInput, -) -> dto.PurchasePlanChannelOutput: - context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE) - - project = await self.database.get_project(workspace_id, project_id=project_id) - if not project: - raise domain.ProjectNotFound(project_id) - - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) - - plan = await self.ensure_active_purchase_plan(project) - - plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) - if not plan_channel: - raise domain.PurchasePlanChannelNotFound(channel_id) - - updated_fields: dict[str, object | None] = {} - if input.status is not None: - plan_channel.status = input.status - updated_fields['status'] = input.status - if input.planned_cost is not None: - plan_channel.planned_cost = input.planned_cost - updated_fields['planned_cost'] = input.planned_cost - if input.comment is not None: - plan_channel.comment = input.comment - updated_fields['comment'] = input.comment - - if updated_fields: - await self.database.update_purchase_plan_channel(plan_channel) - log.info( - 'Updated purchase plan channel %s for project %s fields %s', - channel_id, - project.id, - ', '.join(updated_fields.keys()), - ) - - channel: domain.Channel | None = plan_channel.channel - if channel is None: - channel = await self.database.get_channel(channel_id=plan_channel.channel_id) - if channel is None: - raise domain.ChannelNotFound(plan_channel.channel_id) - - return dto.PurchasePlanChannelOutput( - id=plan_channel.id, - status=plan_channel.status, - planned_cost=plan_channel.planned_cost, - comment=plan_channel.comment, - channel=dto.ChannelOutput( - id=channel.id, - telegram_id=channel.telegram_id, - title=channel.title, - username=channel.username, - ), - ) diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go index 790a7aa..2e79bcb 100644 --- a/telegram_bot/adapter/backend/backend_client.go +++ b/telegram_bot/adapter/backend/backend_client.go @@ -141,12 +141,12 @@ type Workspace struct { } type Project struct { - ID string `json:"id"` - TelegramID int64 `json:"telegram_id"` - Title string `json:"title"` - Username *string `json:"username"` - Status string `json:"status"` - InviteLinkType string `json:"invite_link_type"` // "public" или "approval" + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Title string `json:"title"` + Username *string `json:"username"` + Status string `json:"status"` + PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"` } type Page struct { @@ -231,7 +231,7 @@ func (c *Client) UpdateProjectInviteLinkType( path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/invite-link-type", workspaceID, projectID) payload := map[string]string{ - "invite_link_type": inviteLinkType, + "purchase_invite_type_default": inviteLinkType, } var project Project @@ -306,13 +306,14 @@ func (c *Client) AcceptWorkspaceInvite( // ============================================================================ type Creative struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - Name string `json:"name"` - Text string `json:"text"` - IsArchived bool `json:"is_archived"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Text string `json:"text"` + ProjectID string `json:"project_id"` + ProjectChannelTitle string `json:"project_channel_title"` + CreatedAt string `json:"created_at"` + Status string `json:"status"` + PlacementsCount int `json:"placements_count"` } type CreativesPage struct { @@ -406,9 +407,9 @@ func (c *Client) CreateCreative( } type UpdateCreativeInput struct { - Name *string `json:"name,omitempty"` - Text *string `json:"text,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` + Name *string `json:"name,omitempty"` + Text *string `json:"text,omitempty"` + Status *string `json:"status,omitempty"` } func (c *Client) UpdateCreative( @@ -456,17 +457,24 @@ func (c *Client) DeleteCreative( // ============================================================================ type Placement struct { - ID string `json:"id"` - ProjectID string `json:"project_id"` - PlacementChannelID string `json:"placement_channel_id"` - CreativeID *string `json:"creative_id"` - Name string `json:"name"` - Price *int `json:"price"` - Status string `json:"status"` - InviteLink *string `json:"invite_link"` - IsArchived bool `json:"is_archived"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + ProjectID string `json:"project_id"` + ProjectChannelTitle *string `json:"project_channel_title"` + PlacementChannelID string `json:"placement_channel_id"` + PlacementChannelTitle *string `json:"placement_channel_title"` + CreativeID string `json:"creative_id"` + CreativeName string `json:"creative_name"` + PlacementDate string `json:"placement_date"` + Cost *float64 `json:"cost"` + Comment *string `json:"comment"` + AdPostURL *string `json:"ad_post_url"` + InviteLinkType string `json:"invite_link_type"` + InviteLink string `json:"invite_link"` + Status string `json:"status"` + SubscriptionsCount int `json:"subscriptions_count"` + PurchaseID *string `json:"purchase_id"` + PurchaseChannelID *string `json:"purchase_channel_id"` + CreatedAt string `json:"created_at"` } type PlacementsPage struct { @@ -537,84 +545,10 @@ func (c *Client) GetPlacement( return &placement, err } -type CreatePlacementInput struct { - ProjectID string `json:"project_id"` - PlacementChannelID string `json:"placement_channel_id"` - CreativeID *string `json:"creative_id,omitempty"` - Name string `json:"name"` - Price *int `json:"price,omitempty"` -} - -func (c *Client) CreatePlacement( - ctx context.Context, - jwt string, - workspaceID string, - input CreatePlacementInput, -) (*Placement, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/placements", workspaceID) - - var placement Placement - err := c.do( - ctx, - http.MethodPost, - path, - input, - &placement, - withBearer(jwt), - ) - - return &placement, err -} - -type UpdatePlacementInput struct { - Name *string `json:"name,omitempty"` - Price *int `json:"price,omitempty"` - Status *string `json:"status,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` -} - -func (c *Client) UpdatePlacement( - ctx context.Context, - jwt string, - workspaceID string, - placementID string, - input UpdatePlacementInput, -) (*Placement, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) - - var placement Placement - err := c.do( - ctx, - http.MethodPatch, - path, - input, - &placement, - withBearer(jwt), - ) - - return &placement, err -} - -func (c *Client) DeletePlacement( - ctx context.Context, - jwt string, - workspaceID string, - placementID string, -) error { - path := fmt.Sprintf("api/v1/workspaces/%s/placements/%s", workspaceID, placementID) - - return c.do( - ctx, - http.MethodDelete, - path, - nil, - nil, - withBearer(jwt), - ) -} +// Note: Placement creation/updates are handled by worker; API exposes only read endpoints. // ============================================================================ -// Purchase Plan +// Purchases (Закупы) // ============================================================================ type Channel struct { @@ -624,24 +558,67 @@ type Channel struct { Username *string `json:"username"` } -type PurchasePlanChannel struct { - ID string `json:"id"` - Channel Channel `json:"channel"` - Status string `json:"status"` - PlannedCost *float64 `json:"planned_cost"` - Comment *string `json:"comment"` +type Purchase struct { + ID string `json:"id"` + Status string `json:"status"` + CreativeID string `json:"creative_id"` + Channels []PurchaseChannelOut `json:"channels"` } -func (c *Client) GetPurchasePlanChannels( +type PurchaseChannelOut struct { + ID string `json:"id"` + Status string `json:"status"` + PlannedCost *float64 `json:"planned_cost"` + Comment *string `json:"comment"` + InviteLink string `json:"invite_link"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` +} + +type CreatePurchaseChannelInput struct { + Username string `json:"username"` + Status *string `json:"status,omitempty"` + PlannedCost *float64 `json:"planned_cost,omitempty"` + Comment *string `json:"comment,omitempty"` +} + +type CreatePurchaseInput struct { + CreativeID string `json:"creative_id"` + Channels []CreatePurchaseChannelInput `json:"channels"` +} + +func (c *Client) CreatePurchase( ctx context.Context, jwt string, workspaceID string, projectID string, -) ([]PurchasePlanChannel, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID) + input CreatePurchaseInput, +) (*Purchase, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID) + + var purchase Purchase + err := c.do( + ctx, + http.MethodPost, + path, + input, + &purchase, + withBearer(jwt), + ) + + return &purchase, err +} + +func (c *Client) GetPurchases( + ctx context.Context, + jwt string, + workspaceID string, + projectID string, +) ([]Purchase, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase", workspaceID, projectID) var resp struct { - Items []PurchasePlanChannel `json:"items"` + Items []Purchase `json:"items"` } err := c.do( @@ -656,81 +633,26 @@ func (c *Client) GetPurchasePlanChannels( return resp.Items, err } -type AttachChannelToPurchasePlanInput struct { - Username string `json:"username"` - Status *string `json:"status,omitempty"` - PlannedCost *float64 `json:"planned_cost,omitempty"` - Comment *string `json:"comment,omitempty"` -} - -func (c *Client) AttachChannelToPurchasePlan( +func (c *Client) GetPurchase( ctx context.Context, jwt string, workspaceID string, projectID string, - input AttachChannelToPurchasePlanInput, -) (*PurchasePlanChannel, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels", workspaceID, projectID) + purchaseID string, +) (*Purchase, error) { + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase/%s", workspaceID, projectID, purchaseID) - var planChannel PurchasePlanChannel + var purchase Purchase err := c.do( ctx, - http.MethodPost, - path, - input, - &planChannel, - withBearer(jwt), - ) - - return &planChannel, err -} - -type UpdatePurchasePlanChannelInput struct { - Status *string `json:"status,omitempty"` - PlannedCost *float64 `json:"planned_cost,omitempty"` - Comment *string `json:"comment,omitempty"` -} - -func (c *Client) UpdatePurchasePlanChannel( - ctx context.Context, - jwt string, - workspaceID string, - projectID string, - channelID string, - input UpdatePurchasePlanChannelInput, -) (*PurchasePlanChannel, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID) - - var planChannel PurchasePlanChannel - err := c.do( - ctx, - http.MethodPatch, - path, - input, - &planChannel, - withBearer(jwt), - ) - - return &planChannel, err -} - -func (c *Client) RemoveChannelFromPurchasePlan( - ctx context.Context, - jwt string, - workspaceID string, - projectID string, - channelID string, -) error { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/purchase-plan/channels/%s", workspaceID, projectID, channelID) - - return c.do( - ctx, - http.MethodDelete, + http.MethodGet, path, nil, - nil, + &purchase, withBearer(jwt), ) + + return &purchase, err } // ============================================================================ diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go index 1e320fd..aa87022 100644 --- a/telegram_bot/bot/bot.go +++ b/telegram_bot/bot/bot.go @@ -101,6 +101,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { // Удаляем кнопки у предыдущего сообщения перед отправкой нового if b.LastMessageID != 0 { + log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew") b.cleanupKeyboard(b.LastMessageID) } @@ -120,6 +121,7 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { if res.Result != nil { b.LastMessageID = res.Result.ID + log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew") } } @@ -278,6 +280,7 @@ func (b *Bot) cleanupCallbackUI(u *echotron.Update) { if u.CallbackQuery.Message != nil { callbackMessageID := u.CallbackQuery.Message.ID + // Если это сообщение последнее, не удаляем клавиатуру if b.LastMessageID == callbackMessageID { return } diff --git a/telegram_bot/screens/add_placement.go b/telegram_bot/screens/add_placement.go deleted file mode 100644 index dfc5dcd..0000000 --- a/telegram_bot/screens/add_placement.go +++ /dev/null @@ -1,122 +0,0 @@ -package screens - -import ( - "fmt" - - "example.com/m/adapter/backend" - "example.com/m/bot" - "github.com/NicoNex/echotron/v3" -) - -type AddPlacement struct { - WorkspaceID string - BackState bot.State - - // Выбранные данные - SelectedProject *backend.Project - SelectedCreative *backend.Creative -} - -func (s *AddPlacement) Enter(b *bot.Bot, mode bot.RenderMode) { - text := `➕ Создать закуп - -Шаг 1: Выберите проект и креатив - -Нажмите на кнопки ниже для выбора:` - - var buttons [][]echotron.InlineKeyboardButton - - // Кнопка выбора проекта - projectButtonText := "Проект: Не выбран" - if s.SelectedProject != nil { - projectButtonText = fmt.Sprintf("Проект: %s", s.SelectedProject.Title) - } - buttons = append(buttons, bot.Row( - bot.Button(projectButtonText, "select_project"), - )) - - // Кнопка выбора креатива - creativeButtonText := "Креатив: Не выбран" - if s.SelectedCreative != nil { - creativeButtonText = fmt.Sprintf("Креатив: %s", s.SelectedCreative.Name) - } - buttons = append(buttons, bot.Row( - bot.Button(creativeButtonText, "select_creative"), - )) - - // Кнопки навигации - var navRow []echotron.InlineKeyboardButton - navRow = append(navRow, bot.Button("« Назад", "cancel")) - - // Кнопка "Далее" активна только если выбран проект - if s.SelectedProject != nil { - navRow = append(navRow, bot.Button("Далее →", "next")) - } - - buttons = append(buttons, navRow) - - keyboard := bot.Keyboard(buttons...) - b.Render(text, keyboard, mode) -} - -func (s *AddPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) { - if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { - return - } - - data := u.CallbackQuery.Data - - switch data { - case "cancel": - if s.BackState != nil { - b.SetState(s.BackState, bot.EditMessage) - } - - case "select_project": - // Переходим к выбору проекта - b.SetState(&SelectProjectForPlacement{ - WorkspaceID: s.WorkspaceID, - AddPlacementState: s, - }, bot.NewMessage) - - case "select_creative": - if s.SelectedProject == nil { - // Если проект не выбран, показываем предупреждение - b.Edit(`⚠️ Сначала выберите проект - -Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, bot.Keyboard( - bot.Row(bot.Button("« Назад", "back_to_add_placement")), - )) - return - } - - // Переходим к выбору креатива - b.SetState(&SelectCreativeForPlacement{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.SelectedProject.ID, - AddPlacementState: s, - }, bot.NewMessage) - - case "back_to_add_placement": - s.Enter(b, bot.EditMessage) - - case "next": - if s.SelectedProject == nil { - return - } - - // Переходим к управлению планом закупа (добавление/удаление каналов) - b.SetState(&PlacementPurchasePlan{ - WorkspaceID: s.WorkspaceID, - ProjectID: s.SelectedProject.ID, - AddPlacementState: s, - }, bot.EditMessage) - - default: - s.Enter(b, bot.NewMessage) - } -} - -func (s *AddPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } - -func (s *AddPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/add_purchase.go b/telegram_bot/screens/add_purchase.go new file mode 100644 index 0000000..ba50a4f --- /dev/null +++ b/telegram_bot/screens/add_purchase.go @@ -0,0 +1,160 @@ +package screens + +import ( + "context" + "fmt" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const addPurchaseCreativesPerPage = 5 + +type AddPurchase struct { + WorkspaceID string + ProjectID string + CurrentPage int + BackState bot.State +} + +func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + s.renderCreativeSelection(b, mode, jwt) +} + +func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) { + // Получаем список активных креативов + page, err := b.Backend.GetCreatives( + context.Background(), + jwt, + s.WorkspaceID, + &s.ProjectID, + false, // только активные + s.CurrentPage+1, + addPurchaseCreativesPerPage, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get creatives") + b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard()) + return + } + + text := "➕ Создание закупа\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += `Для создания закупа сначала нужно создать креатив. + +Вернитесь назад и создайте креатив в разделе "Креативы"` + + buttons = append(buttons, bot.Row( + bot.Button("← Назад", "back"), + )) + } else { + pageInfo := "" + if page.Pages > 1 { + pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages) + } + + text += fmt.Sprintf(`Шаг 1/2: Выбор креатива%s + +Выберите креатив для закупа: + +`, pageInfo) + + // Кнопки с креативами + for _, creative := range page.Items { + buttons = append(buttons, bot.Row( + bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)), + )) + } + + // Пагинация + var navRow []echotron.InlineKeyboardButton + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } + if len(navRow) > 0 { + buttons = append(buttons, navRow) + } + + // Кнопка назад + buttons = append(buttons, bot.Row( + bot.Button("← Отмена", "back"), + )) + } + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *AddPurchase) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + default: + // Проверяем, не выбрали ли креатив + if len(data) > 9 && data[:9] == "creative:" { + creativeID := data[9:] + + // Переходим к выбору каналов + b.SetState(&SelectChannelsForPurchase{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + CreativeID: creativeID, + Channels: []PurchaseChannelInput{}, + BackState: s.BackState, + }, bot.EditMessage) + } else { + s.Enter(b, bot.NewMessage) + } + } +} + +func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go index 04fabdd..4ec31e7 100644 --- a/telegram_bot/screens/creative_details.go +++ b/telegram_bot/screens/creative_details.go @@ -46,9 +46,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { s.Name = &creative.Name if creative.Text != "" { s.Text = &creative.Text + log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend") + } else { + log.Info().Msg("Creative text is empty from backend") } // Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД) + log.Info(). + Str("creative_id", s.CreativeID). + Str("name", creative.Name). + Bool("has_text", creative.Text != ""). + Bool("s_text_is_nil", s.Text == nil). + Msg("Sending creative preview and panel") + // Отправляем превью и панель управления s.SendCreativePreview(b) s.showManagementPanelWithDelete(b) diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go index ac8513b..56d1941 100644 --- a/telegram_bot/screens/creative_editor.go +++ b/telegram_bot/screens/creative_editor.go @@ -2,6 +2,7 @@ package screens import ( "fmt" + "strings" "example.com/m/bot" "github.com/NicoNex/echotron/v3" @@ -43,8 +44,24 @@ type CreativeEditorFields struct { // GetCreativeText возвращает текст для превью креатива func (e *CreativeEditorFields) GetCreativeText() string { if e.Text != nil && *e.Text != "" { + // Проверяем что текст не является "визуально пустым" HTML + // Например: "" - пустая ссылка + text := strings.TrimSpace(*e.Text) + + // Убираем пустые HTML-теги (простая проверка) + if text == "" || text == "" { + log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default") + return `💭 Я твой креатив + +У меня пока нет текста, добавь его ниже. + +` + } + + log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text") return *e.Text } + log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text") return `💭 Я твой креатив У меня пока нет текста, добавь его ниже. @@ -136,7 +153,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { } } else { // Отправляем обычное текстовое сообщение напрямую (не через SendNew) - // чтобы не обновлять LastMessageID и не затронуть cleanup + // чтобы не затронуть LastMessageID и не затронуть cleanup res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, @@ -319,12 +336,15 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons } else { // Редактируем существующую панель log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel") + + // Временно заменяем LastMessageID на ID панели управления oldLastMessageID := b.LastMessageID b.LastMessageID = *e.ControlPanelMessageID keyboard := bot.Keyboard(buttons...) b.Edit(text, keyboard) + // Восстанавливаем оригинальный LastMessageID b.LastMessageID = oldLastMessageID log.Info().Msg("Control panel updated") } diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go index 1aea3a1..98d894e 100644 --- a/telegram_bot/screens/creatives.go +++ b/telegram_bot/screens/creatives.go @@ -104,24 +104,20 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) buttons = append(buttons, row) } - // Ряд: Пагинация + Добавить + // Ряд: Пагинация + Добавить (динамическая ширина) var navRow []echotron.InlineKeyboardButton - // Левая стрелка (или пустая кнопка) + // Левая стрелка (только если есть предыдущая страница) if s.CurrentPage > 0 { navRow = append(navRow, bot.Button("←", "prev")) - } else { - navRow = append(navRow, bot.Button(" ", "empty")) } // Кнопка "Добавить креатив" всегда в центре navRow = append(navRow, bot.Button("+ Добавить", "add_creative")) - // Правая стрелка (или пустая кнопка) + // Правая стрелка (только если есть следующая страница) if s.CurrentPage+1 < page.Pages { navRow = append(navRow, bot.Button("→", "next")) - } else { - navRow = append(navRow, bot.Button(" ", "empty")) } buttons = append(buttons, navRow) diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go index 5980bcf..a3730fe 100644 --- a/telegram_bot/screens/main_menu.go +++ b/telegram_bot/screens/main_menu.go @@ -19,11 +19,11 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { switch u.CallbackQuery.Data { case "my_projects": - b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage) + b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.NewMessage) case "support": // b.SetState(&Support{}, bot.NewMessage) case "placements": - b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage) + b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.NewMessage) case "dashboard": // b.SetState(&Dashboard{}, bot.NewMessage) default: diff --git a/telegram_bot/screens/my_placements.go b/telegram_bot/screens/my_placements.go index 6be70a7..d360731 100644 --- a/telegram_bot/screens/my_placements.go +++ b/telegram_bot/screens/my_placements.go @@ -66,27 +66,37 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str return } - text := "Мои закупы • Управление размещениями\n\n" + text := "📍 Мои размещения\n\n" var buttons [][]echotron.InlineKeyboardButton if len(page.Items) == 0 { - text += "У вас пока нет закупов" - } else { - text += fmt.Sprintf("Выберите закуп для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) + text += `У вас пока нет размещений. - // Закупы по 2 в ряду +Размещения создаются автоматически при создании закупа.` + } else { + text += fmt.Sprintf("Выберите размещение для деталей (стр. %d из %d):\n\n", s.CurrentPage+1, page.Pages) + + // Размещения по 2 в ряду for i := 0; i < len(page.Items); i += 2 { var row []echotron.InlineKeyboardButton - // Первый закуп в ряду + // Первый placement в ряду placement := page.Items[i] - row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID))) + placementName := placement.CreativeName + if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" { + placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle) + } + row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID))) - // Второй закуп в ряду (если есть) + // Второй placement в ряду (если есть) if i+1 < len(page.Items) { placement = page.Items[i+1] - row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID))) + placementName = placement.CreativeName + if placement.PlacementChannelTitle != nil && *placement.PlacementChannelTitle != "" { + placementName = fmt.Sprintf("%s → %s", placement.CreativeName, *placement.PlacementChannelTitle) + } + row = append(row, bot.Button(placementName, fmt.Sprintf("placement:%s", placement.ID))) } else { // Добавляем пустую кнопку для выравнивания row = append(row, bot.Button(" ", "empty")) @@ -121,12 +131,11 @@ func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt str // Кнопки управления buttons = append(buttons, bot.Row( - bot.Button("Статистика", "statistics"), - bot.Button("Создать закуп", "add_placement"), + bot.Button("📊 Статистика", "statistics"), )) // Кнопка возврата в главное меню - buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu"))) + buttons = append(buttons, bot.Row(bot.Button("← Назад", "back"))) keyboard := bot.Keyboard(buttons...) b.Render(text, keyboard, mode) @@ -167,8 +176,12 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) { s.CurrentPage++ s.Enter(b, bot.EditMessage) - case data == "menu": - b.SetState(&MainMenu{}, bot.EditMessage) + case data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } else { + b.SetState(&MainMenu{}, bot.EditMessage) + } case data == "page_info", data == "empty": // Неактивные кнопки - ничего не делаем @@ -176,16 +189,10 @@ func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) { case data == "statistics": // TODO: реализовать экран статистики - b.Edit("📊 Статистика закупов\n\nЭта функция в разработке", bot.Keyboard( + b.Edit("📊 Статистика размещений\n\nЭта функция в разработке", bot.Keyboard( bot.Row(bot.Button("« Назад", "back_to_placements")), )) - case data == "add_placement": - b.SetState(&AddPlacement{ - WorkspaceID: s.WorkspaceID, - BackState: s, - }, bot.EditMessage) - case data == "back_to_placements": s.Enter(b, bot.EditMessage) diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go index c187f37..22197a2 100644 --- a/telegram_bot/screens/my_projects.go +++ b/telegram_bot/screens/my_projects.go @@ -115,24 +115,20 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) buttons = append(buttons, row) } - // Ряд: Пагинация + Добавить + // Ряд: Пагинация + Добавить (динамическая ширина) var navRow []echotron.InlineKeyboardButton - // Левая стрелка (или пустая кнопка) + // Левая стрелка (только если есть предыдущая страница) if s.CurrentPage > 0 { navRow = append(navRow, bot.Button("←", "prev")) - } else { - navRow = append(navRow, bot.Button(" ", "empty")) } // Кнопка "Добавить проект" всегда в центре navRow = append(navRow, bot.Button("+ Добавить", "add_project")) - // Правая стрелка (или пустая кнопка) + // Правая стрелка (только если есть следующая страница) if s.CurrentPage+1 < page.Pages { navRow = append(navRow, bot.Button("→", "next")) - } else { - navRow = append(navRow, bot.Button(" ", "empty")) } buttons = append(buttons, navRow) @@ -224,7 +220,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { Project: project, WorkspaceID: s.WorkspaceID, BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, - }, bot.EditMessage) + }, bot.NewMessage) } case strings.HasPrefix(data, "workspace:"): diff --git a/telegram_bot/screens/placement_purchase_plan.go b/telegram_bot/screens/placement_purchase_plan.go deleted file mode 100644 index 3fc5fde..0000000 --- a/telegram_bot/screens/placement_purchase_plan.go +++ /dev/null @@ -1,222 +0,0 @@ -package screens - -import ( - "context" - "fmt" - "strings" - - "example.com/m/adapter/backend" - "example.com/m/bot" - "github.com/NicoNex/echotron/v3" - "github.com/rs/zerolog/log" -) - -type PlacementPurchasePlan struct { - WorkspaceID string - ProjectID string - AddPlacementState *AddPlacement -} - -func (s *PlacementPurchasePlan) Enter(b *bot.Bot, mode bot.RenderMode) { - jwt, err := s.ensureJWT(b) - if err != nil { - log.Error().Err(err).Msg("Failed to get JWT") - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) - return - } - - s.renderChannels(b, mode, jwt) -} - -func (s *PlacementPurchasePlan) renderChannels(b *bot.Bot, mode bot.RenderMode, jwt string) { - channels, err := b.Backend.GetPurchasePlanChannels( - context.Background(), - jwt, - s.WorkspaceID, - s.ProjectID, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to get purchase plan channels") - b.SendNew("❌ Не удалось загрузить каналы", bot.Keyboard()) - return - } - - text := "📋 План закупа — Каналы\n\n" - - var buttons [][]echotron.InlineKeyboardButton - - if len(channels) == 0 { - text += "Список каналов пуст.\n\n" - } else { - text += "Нажмите на канал для удаления:\n\n" - - for _, ch := range channels { - channelName := "Без названия" - if ch.Channel.Title != nil { - channelName = *ch.Channel.Title - } else if ch.Channel.Username != nil { - channelName = "@" + *ch.Channel.Username - } - - // Показываем статус и стоимость - statusEmoji := "📌" - switch ch.Status { - case "planned": - statusEmoji = "📝" - case "approved": - statusEmoji = "✅" - case "rejected": - statusEmoji = "❌" - case "in_progress": - statusEmoji = "🔄" - case "completed": - statusEmoji = "✔️" - } - - buttonText := fmt.Sprintf("%s %s", statusEmoji, channelName) - if ch.PlannedCost != nil { - buttonText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost) - } - - buttons = append(buttons, bot.Row( - bot.Button(buttonText, fmt.Sprintf("remove:%s", ch.ID)), - )) - } - } - - // Инструкция по добавлению канала - text += "\nДобавление канала:\nОтправьте username канала (без @)" - - // Навигация - var navRow []echotron.InlineKeyboardButton - navRow = append(navRow, bot.Button("« Назад", "back")) - navRow = append(navRow, bot.Button("Далее →", "next")) - buttons = append(buttons, navRow) - - keyboard := bot.Keyboard(buttons...) - b.Render(text, keyboard, mode) -} - -func (s *PlacementPurchasePlan) ensureJWT(b *bot.Bot) (string, error) { - if b.Session.JWT != "" { - return b.Session.JWT, nil - } - - jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) - if err != nil { - return "", err - } - - b.Session.JWT = jwt - return jwt, nil -} - -func (s *PlacementPurchasePlan) HandleCallback(b *bot.Bot, u *echotron.Update) { - if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { - return - } - - data := u.CallbackQuery.Data - - switch { - case data == "back": - if s.AddPlacementState != nil { - b.SetState(s.AddPlacementState, bot.EditMessage) - } - - case data == "next": - // TODO: переход к следующему шагу (детали размещения) - b.Edit("🚧 Следующий шаг в разработке", bot.Keyboard( - bot.Row(bot.Button("« Назад", "back_to_purchase_plan")), - )) - - case data == "back_to_purchase_plan": - s.Enter(b, bot.EditMessage) - - case strings.HasPrefix(data, "remove:"): - parts := strings.Split(data, ":") - if len(parts) == 2 { - purchasePlanChannelID := parts[1] // ID записи PurchasePlanChannel - - jwt, err := s.ensureJWT(b) - if err != nil { - log.Error().Err(err).Msg("Failed to get JWT") - return - } - - // Удаляем канал из плана закупа - err = b.Backend.RemoveChannelFromPurchasePlan( - context.Background(), - jwt, - s.WorkspaceID, - s.ProjectID, - purchasePlanChannelID, - ) - if err != nil { - log.Error().Err(err).Str("purchase_plan_channel_id", purchasePlanChannelID).Msg("Failed to remove channel") - b.Edit("❌ Не удалось удалить канал", bot.Keyboard( - bot.Row(bot.Button("« Назад", "back_to_purchase_plan")), - )) - return - } - - // Обновляем экран - s.Enter(b, bot.EditMessage) - } - - default: - s.Enter(b, bot.NewMessage) - } -} - -func (s *PlacementPurchasePlan) HandleMessage(b *bot.Bot, u *echotron.Update) { - if u.Message == nil || u.Message.Text == "" { - return - } - - username := strings.TrimSpace(u.Message.Text) - - // Убираем @ если пользователь его добавил - username = strings.TrimPrefix(username, "@") - - if username == "" { - return - } - - jwt, err := s.ensureJWT(b) - if err != nil { - log.Error().Err(err).Msg("Failed to get JWT") - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) - return - } - - // Отправляем запрос на добавление канала - input := backend.AttachChannelToPurchasePlanInput{ - Username: username, - } - - _, err = b.Backend.AttachChannelToPurchasePlan( - context.Background(), - jwt, - s.WorkspaceID, - s.ProjectID, - input, - ) - - if err != nil { - log.Error().Err(err).Str("username", username).Msg("Failed to attach channel") - - // Проверяем, содержит ли ошибка информацию о том, что канал не найден - if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") { - b.SendNew(fmt.Sprintf("❌ Канал @%s не найден в Telegram.\n\nПроверьте правильность username и попробуйте еще раз.", username), bot.Keyboard()) - } else { - b.SendNew("❌ Не удалось добавить канал. Попробуйте позже.", bot.Keyboard()) - } - return - } - - // Обновляем экран со списком каналов - s.Enter(b, bot.NewMessage) -} - -func (s *PlacementPurchasePlan) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go index 793025b..15551d4 100644 --- a/telegram_bot/screens/project_details.go +++ b/telegram_bot/screens/project_details.go @@ -68,37 +68,32 @@ func (s *ProjectDetails) formatProjectDetails() string { func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { var buttons [][]echotron.InlineKeyboardButton - // Первый ряд: Креативы и Создать закуп + // Первый ряд: Креативы и Закупы buttons = append(buttons, bot.Row( - bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), - bot.Button("Создать закуп", fmt.Sprintf("create_placement:%s", s.Project.ID)), + bot.Button("✎ Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), + bot.Button("☑ Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)), )) - // Второй ряд: План закупов - buttons = append(buttons, bot.Row( - bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)), - )) - - // Третий ряд: Тип приглашений (с подтверждением) + // Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением) var linkTypeButtonText string if s.confirmingLinkTypeChange { // Показываем подтверждение var newType string - if s.Project.InviteLinkType == "public" { - newType = "С одобрением" + if s.Project.PurchaseInviteTypeDefault == "public" { + newType = "По заявке" } else { - newType = "Открытая" + newType = "Свободное" } - linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType) + linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType) } else { - // Показываем текущий тип приглашений + // Показываем текущий тип вступления var currentType string - if s.Project.InviteLinkType == "public" { - currentType = "Открытая" + if s.Project.PurchaseInviteTypeDefault == "public" { + currentType = "Свободное" } else { - currentType = "С одобрением" + currentType = "По заявке" } - linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType) + linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType) } buttons = append(buttons, bot.Row( bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)), @@ -135,12 +130,13 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { BackState: s, }, bot.NewMessage) - case strings.HasPrefix(data, "create_placement:"): + case strings.HasPrefix(data, "purchases:"): s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения - // TODO: реализовать создание закупа - b.Edit("🚧 Создать закуп\n\nФункция в разработке", bot.Keyboard( - bot.Row(bot.Button("← Назад", "back_to_project")), - )) + b.SetState(&Purchases{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.Project.ID, + BackState: s, + }, bot.NewMessage) case data == "back_to_project": s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения @@ -154,7 +150,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { } else { // Второе нажатие - меняем тип ссылки var newType string - if s.Project.InviteLinkType == "public" { + if s.Project.PurchaseInviteTypeDefault == "public" { newType = "approval" } else { newType = "public" @@ -163,7 +159,7 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { log.Info(). Str("workspace_id", s.WorkspaceID). Str("project_id", s.Project.ID). - Str("old_type", s.Project.InviteLinkType). + Str("old_type", s.Project.PurchaseInviteTypeDefault). Str("new_type", newType). Msg("Updating project invite link type") diff --git a/telegram_bot/screens/purchase_details.go b/telegram_bot/screens/purchase_details.go new file mode 100644 index 0000000..1491577 --- /dev/null +++ b/telegram_bot/screens/purchase_details.go @@ -0,0 +1,170 @@ +package screens + +import ( + "context" + "fmt" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type PurchaseDetails struct { + WorkspaceID string + ProjectID string + PurchaseID string + BackState bot.State +} + +func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + s.renderDetails(b, mode, jwt) +} + +func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) { + purchase, err := b.Backend.GetPurchase( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + s.PurchaseID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get purchase") + b.SendNew("❌ Не удалось загрузить закуп", bot.Keyboard()) + return + } + + // Получаем информацию о креативе + creative, err := b.Backend.GetCreative( + context.Background(), + jwt, + s.WorkspaceID, + purchase.CreativeID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get creative") + creative = nil + } + + text := "📦 Детали закупа\n\n" + + // Информация о креативе + if creative != nil { + text += fmt.Sprintf("Креатив: %s\n", creative.Name) + } else { + text += fmt.Sprintf("Креатив ID: %s\n", purchase.CreativeID) + } + + // Статус закупа + statusText := "Активный" + if purchase.Status == "archived" { + statusText = "Архивирован" + } + text += fmt.Sprintf("Статус: %s\n\n", statusText) + + // Информация о каналах + if len(purchase.Channels) == 0 { + text += "Каналы не добавлены\n" + } else { + text += fmt.Sprintf("Каналы (%d):\n\n", len(purchase.Channels)) + + for i, ch := range purchase.Channels { + // Определяем статус emoji + statusEmoji := "📌" + switch ch.Status { + case "planned": + statusEmoji = "📝" + case "approved": + statusEmoji = "✅" + case "rejected": + statusEmoji = "❌" + case "in_progress": + statusEmoji = "🔄" + case "completed": + statusEmoji = "✔️" + } + + // Название канала + channelName := "Без названия" + if ch.Channel.Title != nil { + channelName = *ch.Channel.Title + } else if ch.Channel.Username != nil { + channelName = "@" + *ch.Channel.Username + } + + text += fmt.Sprintf("%d. %s %s\n", i+1, statusEmoji, channelName) + + // Стоимость + if ch.PlannedCost != nil { + text += fmt.Sprintf(" 💰 Стоимость: %.0f₽\n", *ch.PlannedCost) + } + + // Комментарий + if ch.Comment != nil && *ch.Comment != "" { + text += fmt.Sprintf(" 💬 %s\n", *ch.Comment) + } + + // Тип invite link + inviteLinkTypeText := "публичная" + if ch.InviteLinkType == "approval" { + inviteLinkTypeText = "с одобрением" + } + text += fmt.Sprintf(" 🔗 Ссылка: %s\n", inviteLinkTypeText) + + text += "\n" + } + } + + var buttons [][]echotron.InlineKeyboardButton + + // Кнопка назад + buttons = append(buttons, bot.Row( + bot.Button("← Назад", "back"), + )) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *PurchaseDetails) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/purchases.go b/telegram_bot/screens/purchases.go new file mode 100644 index 0000000..76596c6 --- /dev/null +++ b/telegram_bot/screens/purchases.go @@ -0,0 +1,158 @@ +package screens + +import ( + "context" + "fmt" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type Purchases struct { + WorkspaceID string + ProjectID string + BackState bot.State +} + +func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + s.renderPurchases(b, mode, jwt) +} + +func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) { + purchases, err := b.Backend.GetPurchases( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get purchases") + b.SendNew("❌ Не удалось загрузить закупы", bot.Keyboard()) + return + } + + text := "📦 Закупы\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(purchases) == 0 { + text += `Список закупов пуст. + +Закуп — это план размещения рекламы в каналах: +▸ Выбор креатива +▸ Список каналов для размещения +▸ Планируемая стоимость +▸ Статус выполнения + +Создайте первый закуп 👇` + } else { + text += "Управление закупами для проекта.\n\n" + text += "Выберите закуп для деталей:\n\n" + + for i, purchase := range purchases { + // Определяем статус emoji + statusEmoji := "📌" + switch purchase.Status { + case "active": + statusEmoji = "🟢" + case "archived": + statusEmoji = "📦" + } + + // Считаем каналы по статусам + totalChannels := len(purchase.Channels) + completedChannels := 0 + for _, ch := range purchase.Channels { + if ch.Status == "completed" { + completedChannels++ + } + } + + buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)", + statusEmoji, + i+1, + completedChannels, + totalChannels, + ) + + buttons = append(buttons, bot.Row( + bot.Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)), + )) + } + } + + // Кнопка создания нового закупа + buttons = append(buttons, bot.Row( + bot.Button("+ Создать закуп", "add_purchase"), + )) + + // Кнопка назад + buttons = append(buttons, bot.Row( + bot.Button("← Назад", "back"), + )) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *Purchases) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case "add_purchase": + b.SetState(&AddPurchase{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + BackState: s, + }, bot.EditMessage) + + default: + // Проверяем, не нажали ли на конкретный закуп + if len(data) > 9 && data[:9] == "purchase:" { + purchaseID := data[9:] + b.SetState(&PurchaseDetails{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + PurchaseID: purchaseID, + BackState: s, + }, bot.NewMessage) + } else { + s.Enter(b, bot.NewMessage) + } + } +} + +func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go new file mode 100644 index 0000000..fd8ebae --- /dev/null +++ b/telegram_bot/screens/select_channels_for_purchase.go @@ -0,0 +1,214 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type PurchaseChannelInput struct { + Username string + PlannedCost *float64 + Comment *string +} + +type SelectChannelsForPurchase struct { + WorkspaceID string + ProjectID string + CreativeID string + Channels []PurchaseChannelInput + BackState bot.State +} + +func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { + s.renderChannelSelection(b, mode) +} + +func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) { + text := "➕ Создание закупа\n\n" + text += "Шаг 2/2: Добавление каналов\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(s.Channels) == 0 { + text += `Добавьте каналы для размещения рекламы. + +Отправьте username канала (без @) +Например: channel_name + +После добавления всех каналов нажмите "Создать закуп"` + } else { + text += fmt.Sprintf("Добавлено каналов: %d\n\n", len(s.Channels)) + + for i, ch := range s.Channels { + channelText := fmt.Sprintf("%d. @%s", i+1, ch.Username) + if ch.PlannedCost != nil { + channelText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost) + } + text += channelText + "\n" + } + + text += "\nДобавьте еще каналы или создайте закуп" + + // Кнопка создания закупа (доступна только если есть каналы) + buttons = append(buttons, bot.Row( + bot.Button("✅ Создать закуп", "create"), + )) + } + + // Кнопки навигации + buttons = append(buttons, bot.Row( + bot.Button("← Назад", "back"), + )) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *SelectChannelsForPurchase) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "back": + // Возвращаемся к выбору креатива + b.SetState(&AddPurchase{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + BackState: s.BackState, + }, bot.EditMessage) + + case "create": + if len(s.Channels) == 0 { + b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard( + bot.Row(bot.Button("← Назад", "back_to_select")), + )) + return + } + + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.Edit("❌ Ошибка авторизации", bot.Keyboard()) + return + } + + // Создаем закуп + s.createPurchase(b, jwt) + + case "back_to_select": + s.Enter(b, bot.EditMessage) + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) { + // Преобразуем каналы в формат API + var apiChannels []backend.CreatePurchaseChannelInput + for _, ch := range s.Channels { + apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{ + Username: ch.Username, + PlannedCost: ch.PlannedCost, + Comment: ch.Comment, + }) + } + + input := backend.CreatePurchaseInput{ + CreativeID: s.CreativeID, + Channels: apiChannels, + } + + purchase, err := b.Backend.CreatePurchase( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + input, + ) + + if err != nil { + log.Error().Err(err).Msg("Failed to create purchase") + b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", bot.Keyboard( + bot.Row(bot.Button("← Назад", "back_to_select")), + )) + return + } + + // Показываем успешное создание + successText := fmt.Sprintf(`✅ Закуп создан! + +ID: %s +Креатив: %s +Каналов: %d + +Закуп создан и готов к работе.`, purchase.ID, purchase.CreativeID, len(purchase.Channels)) + + b.Edit(successText, bot.Keyboard( + bot.Row(bot.Button("✅ Готово", "done")), + )) +} + +func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil || u.Message.Text == "" { + return + } + + username := strings.TrimSpace(u.Message.Text) + + // Убираем @ если пользователь его добавил + username = strings.TrimPrefix(username, "@") + + if username == "" { + return + } + + // Проверяем, не добавлен ли уже этот канал + for _, ch := range s.Channels { + if ch.Username == username { + b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard()) + return + } + } + + // Добавляем канал в список + s.Channels = append(s.Channels, PurchaseChannelInput{ + Username: username, + }) + + // Обновляем экран + s.Enter(b, bot.NewMessage) +} + +func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) { + // Обрабатываем callback после успешного создания + if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" { + // Возвращаемся к списку закупов + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + } +} diff --git a/telegram_bot/screens/select_creative_for_placement.go b/telegram_bot/screens/select_creative_for_placement.go deleted file mode 100644 index 3cee7a0..0000000 --- a/telegram_bot/screens/select_creative_for_placement.go +++ /dev/null @@ -1,188 +0,0 @@ -package screens - -import ( - "context" - "fmt" - "strings" - - "example.com/m/bot" - "github.com/NicoNex/echotron/v3" - "github.com/rs/zerolog/log" -) - -const creativesForPlacementPerPage = 6 - -type SelectCreativeForPlacement struct { - WorkspaceID string - ProjectID string - CurrentPage int - AddPlacementState *AddPlacement -} - -func (s *SelectCreativeForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) { - jwt, err := s.ensureJWT(b) - if err != nil { - log.Error().Err(err).Msg("Failed to get JWT") - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) - return - } - - s.renderCreatives(b, mode, jwt) -} - -func (s *SelectCreativeForPlacement) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) { - page, err := b.Backend.GetCreatives( - context.Background(), - jwt, - s.WorkspaceID, - &s.ProjectID, - false, // includeArchived - s.CurrentPage+1, - creativesForPlacementPerPage, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to get creatives") - b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard()) - return - } - - text := "Выбор креатива\n\n" - - var buttons [][]echotron.InlineKeyboardButton - - if len(page.Items) == 0 { - text += "У выбранного проекта пока нет креативов.\n\nСоздайте креатив через меню проекта." - } else { - text += fmt.Sprintf("Выберите креатив (стр. %d из %d):", s.CurrentPage+1, page.Pages) - - // Креативы по 2 в ряду - for i := 0; i < len(page.Items); i += 2 { - var row []echotron.InlineKeyboardButton - - // Первый креатив в ряду - creative := page.Items[i] - row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID))) - - // Второй креатив в ряду (если есть) - if i+1 < len(page.Items) { - creative = page.Items[i+1] - row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID))) - } else { - row = append(row, bot.Button(" ", "empty")) - } - - buttons = append(buttons, row) - } - - // Кнопки пагинации - var navRow []echotron.InlineKeyboardButton - - if s.CurrentPage > 0 { - navRow = append(navRow, bot.Button("←", "prev")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } - - pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") - navRow = append(navRow, pageButton) - - if s.CurrentPage+1 < page.Pages { - navRow = append(navRow, bot.Button("→", "next")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } - - buttons = append(buttons, navRow) - } - - // Опция пропустить выбор креатива (креатив опционален) - buttons = append(buttons, bot.Row(bot.Button("Без креатива", "skip"))) - - // Кнопка возврата - buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) - - keyboard := bot.Keyboard(buttons...) - b.Render(text, keyboard, mode) -} - -func (s *SelectCreativeForPlacement) ensureJWT(b *bot.Bot) (string, error) { - if b.Session.JWT != "" { - return b.Session.JWT, nil - } - - jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) - if err != nil { - return "", err - } - - b.Session.JWT = jwt - return jwt, nil -} - -func (s *SelectCreativeForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) { - if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { - return - } - - data := u.CallbackQuery.Data - - switch { - case data == "prev": - if s.CurrentPage > 0 { - s.CurrentPage-- - } - s.Enter(b, bot.EditMessage) - - case data == "next": - s.CurrentPage++ - s.Enter(b, bot.EditMessage) - - case data == "page_info", data == "empty": - return - - case data == "skip": - // Пропускаем выбор креатива - if s.AddPlacementState != nil { - s.AddPlacementState.SelectedCreative = nil - b.SetState(s.AddPlacementState, bot.EditMessage) - } - - case data == "back": - if s.AddPlacementState != nil { - b.SetState(s.AddPlacementState, bot.EditMessage) - } - - case strings.HasPrefix(data, "select:"): - parts := strings.Split(data, ":") - if len(parts) == 2 { - creativeID := parts[1] - - // Получаем JWT - jwt, err := s.ensureJWT(b) - if err != nil { - log.Error().Err(err).Msg("Failed to get JWT") - return - } - - // Загружаем полные данные креатива - creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, creativeID) - if err != nil { - log.Error().Err(err).Str("creative_id", creativeID).Msg("Failed to get creative") - return - } - - // Сохраняем выбранный креатив в состояние AddPlacement - if s.AddPlacementState != nil { - s.AddPlacementState.SelectedCreative = creative - b.SetState(s.AddPlacementState, bot.EditMessage) - } - } - - default: - s.Enter(b, bot.NewMessage) - } -} - -func (s *SelectCreativeForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } - -func (s *SelectCreativeForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/select_project_for_placement.go b/telegram_bot/screens/select_project_for_placement.go deleted file mode 100644 index 1dd1d83..0000000 --- a/telegram_bot/screens/select_project_for_placement.go +++ /dev/null @@ -1,164 +0,0 @@ -package screens - -import ( - "context" - "fmt" - "strings" - - "example.com/m/bot" - "github.com/NicoNex/echotron/v3" - "github.com/rs/zerolog/log" -) - -const projectsForPlacementPerPage = 6 - -type SelectProjectForPlacement struct { - WorkspaceID string - CurrentPage int - AddPlacementState *AddPlacement -} - -func (s *SelectProjectForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) { - jwt, err := s.ensureJWT(b) - if err != nil { - log.Error().Err(err).Msg("Failed to get JWT") - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) - return - } - - s.renderProjects(b, mode, jwt) -} - -func (s *SelectProjectForPlacement) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) { - page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsForPlacementPerPage) - if err != nil { - log.Error().Err(err).Msg("Failed to get projects") - b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard()) - return - } - - text := "Выбор проекта\n\n" - - var buttons [][]echotron.InlineKeyboardButton - - if len(page.Items) == 0 { - text += "У вас пока нет проектов" - } else { - text += fmt.Sprintf("Выберите проект (стр. %d из %d):", s.CurrentPage+1, page.Pages) - - // Проекты по 2 в ряду - for i := 0; i < len(page.Items); i += 2 { - var row []echotron.InlineKeyboardButton - - // Первый проект в ряду - project := page.Items[i] - row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID))) - - // Второй проект в ряду (если есть) - if i+1 < len(page.Items) { - project = page.Items[i+1] - row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID))) - } else { - row = append(row, bot.Button(" ", "empty")) - } - - buttons = append(buttons, row) - } - - // Кнопки пагинации - var navRow []echotron.InlineKeyboardButton - - if s.CurrentPage > 0 { - navRow = append(navRow, bot.Button("←", "prev")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } - - pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") - navRow = append(navRow, pageButton) - - if s.CurrentPage+1 < page.Pages { - navRow = append(navRow, bot.Button("→", "next")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } - - buttons = append(buttons, navRow) - } - - // Кнопка возврата - buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) - - keyboard := bot.Keyboard(buttons...) - b.Render(text, keyboard, mode) -} - -func (s *SelectProjectForPlacement) ensureJWT(b *bot.Bot) (string, error) { - if b.Session.JWT != "" { - return b.Session.JWT, nil - } - - jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) - if err != nil { - return "", err - } - - b.Session.JWT = jwt - return jwt, nil -} - -func (s *SelectProjectForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) { - if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { - return - } - - data := u.CallbackQuery.Data - - switch { - case data == "prev": - if s.CurrentPage > 0 { - s.CurrentPage-- - } - s.Enter(b, bot.EditMessage) - - case data == "next": - s.CurrentPage++ - s.Enter(b, bot.EditMessage) - - case data == "page_info", data == "empty": - return - - case data == "back": - if s.AddPlacementState != nil { - b.SetState(s.AddPlacementState, bot.EditMessage) - } - - case strings.HasPrefix(data, "select:"): - parts := strings.Split(data, ":") - if len(parts) == 2 { - projectID := parts[1] - - // Загружаем полные данные проекта - project, err := b.Backend.GetProject(context.Background(), s.WorkspaceID, projectID) - if err != nil { - log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project") - return - } - - // Сохраняем выбранный проект в состояние AddPlacement - if s.AddPlacementState != nil { - s.AddPlacementState.SelectedProject = project - // Сбрасываем креатив при смене проекта - s.AddPlacementState.SelectedCreative = nil - b.SetState(s.AddPlacementState, bot.EditMessage) - } - } - - default: - s.Enter(b, bot.NewMessage) - } -} - -func (s *SelectProjectForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } - -func (s *SelectProjectForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }