From 049024ebe899b0ca92982f826e423937f46f1315 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Thu, 15 Jan 2026 22:02:16 +0300 Subject: [PATCH] refactor --- CLAUDE.md | 51 +- ...actor_purchase_to_placement_publication.py | 365 ++++++++ src/adapter/postgres.py | 260 +++--- src/adapter/s3.py | 7 +- src/controller/http_v1/__init__.py | 10 +- src/controller/http_v1/internal.py | 10 +- src/controller/http_v1/placements.py | 34 +- src/controller/http_v1/purchases.py | 47 +- src/controller/http_v1/views.py | 2 +- src/controller/worker/fetch_placement_post.py | 6 +- src/domain/__init__.py | 42 +- src/domain/error.py | 24 +- src/domain/placement.py | 26 +- src/domain/project.py | 2 +- src/domain/purchase.py | 84 +- src/domain/subscription.py | 10 +- src/domain/workspace.py | 2 +- src/dto/__init__.py | 47 +- src/dto/placement.py | 35 +- src/dto/purchase.py | 51 +- src/dto/views.py | 4 +- src/main.py | 8 +- src/usecase/__init__.py | 113 +-- .../analytics/get_channel_analytics.py | 42 +- .../analytics/get_creatives_analytics.py | 4 +- .../analytics/get_overview_analytics.py | 54 +- .../analytics/get_placements_analytics.py | 8 +- .../analytics/get_projects_analytics.py | 52 +- .../analytics/get_spending_analytics.py | 4 +- src/usecase/creative/delete_creative.py | 6 +- src/usecase/creative/get_creative.py | 2 +- src/usecase/creative/get_creatives.py | 2 +- src/usecase/creative/update_creative.py | 2 +- src/usecase/placement/__init__.py | 5 + .../placement/fetch_placement_post_cycle.py | 63 -- .../placement/fetch_publication_post_cycle.py | 64 ++ src/usecase/placement/get_placement.py | 51 -- src/usecase/placement/get_placements.py | 66 -- src/usecase/placement/get_publication.py | 55 ++ src/usecase/placement/get_publications.py | 71 ++ src/usecase/project/archive_project.py | 1 - .../project/update_project_permissions.py | 2 +- src/usecase/purchase/__init__.py | 5 + src/usecase/purchase/create_placements.py | 148 ++++ src/usecase/purchase/create_purchase.py | 173 ---- src/usecase/purchase/get_placement.py | 35 + .../{get_purchase.py => get_placements.py} | 15 +- src/usecase/purchase/get_purchases.py | 32 - .../subscription/handle_subscription.py | 38 +- .../subscription/handle_unsubscription.py | 4 +- src/usecase/views/get_views_history.py | 18 +- telegram_bot/bot/bot.go | 5 + telegram_bot/bot/state.go | 1 + .../screens/accept_workspace_invite.go | 2 + telegram_bot/screens/add_creative.go | 778 ++++++------------ telegram_bot/screens/add_project.go | 2 + telegram_bot/screens/add_purchase.go | 2 + telegram_bot/screens/creative_details.go | 235 ++---- telegram_bot/screens/creative_editor.go | 646 +++++++-------- telegram_bot/screens/creative_editor_ui.go | 101 +++ telegram_bot/screens/creatives.go | 6 +- telegram_bot/screens/login.go | 2 + telegram_bot/screens/main_menu.go | 2 + telegram_bot/screens/my_projects.go | 109 +++ telegram_bot/screens/project_details.go | 2 + telegram_bot/screens/purchase_details.go | 2 + .../screens/purchase_optional_details.go | 2 + telegram_bot/screens/purchases.go | 2 + .../screens/select_channels_for_purchase.go | 2 + telegram_bot/screens/select_workspace.go | 2 + telegram_bot/ui/date_time_picker.go | 2 + telegram_bot/ui/message_format.go | 304 +++++++ 72 files changed, 2417 insertions(+), 2054 deletions(-) create mode 100644 migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py create mode 100644 src/usecase/placement/__init__.py delete mode 100644 src/usecase/placement/fetch_placement_post_cycle.py create mode 100644 src/usecase/placement/fetch_publication_post_cycle.py delete mode 100644 src/usecase/placement/get_placement.py delete mode 100644 src/usecase/placement/get_placements.py create mode 100644 src/usecase/placement/get_publication.py create mode 100644 src/usecase/placement/get_publications.py create mode 100644 src/usecase/purchase/__init__.py create mode 100644 src/usecase/purchase/create_placements.py delete mode 100644 src/usecase/purchase/create_purchase.py create mode 100644 src/usecase/purchase/get_placement.py rename src/usecase/purchase/{get_purchase.py => get_placements.py} (53%) delete mode 100644 src/usecase/purchase/get_purchases.py create mode 100644 telegram_bot/screens/creative_editor_ui.go create mode 100644 telegram_bot/ui/message_format.go diff --git a/CLAUDE.md b/CLAUDE.md index 3a2bc9b..314dc56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,20 +59,55 @@ uv run pytest tests/test_file.py::test_function_name ### Database Migrations ```bash # Create a new migration (auto-generated based on model changes) -make migrate-create # or: export $(cat .env | xargs) && aerich migrate +make migrate-create # or: source .env && aerich migrate # Apply migrations -make migrate-up # or: export $(cat .env | xargs) && aerich upgrade +make migrate-up # or: source .env && aerich upgrade # Rollback one migration -make migrate-down # or: export $(cat .env | xargs) && aerich downgrade - -# NOTE: For custom constraints (CHECK, custom indexes, etc.) that cannot be defined -# in Tortoise ORM models, create empty migrations and edit them manually: -export $(cat .env | xargs) && aerich migrate --name "add_check_constraint" --empty -# Then edit the created file and add `# ruff: noqa` and `# mypy: ignore-errors` at the top. +make migrate-down # or: source .env && aerich downgrade ``` +**Important Migration Workflow:** + +1. **For simple model changes**: Let aerich auto-generate the migration: + ```bash + source .env && aerich migrate --name "descriptive_name" + ``` + +2. **For complex refactorings** (table renames, data migration, etc.): + - **FIRST**: Update domain models to reflect the new structure + - **THEN**: Run `aerich migrate` to auto-generate the migration file + - **FINALLY**: Edit the generated file to add data migration logic + + Example workflow: + ```bash + # 1. Update models in src/domain/ + # 2. Generate migration (will create base ALTER TABLE statements) + source .env && aerich migrate --name "refactor_tables" + # 3. Edit the generated file in migrations/models/ to add: + # - Data migration SQL (INSERT ... SELECT) + # - Proper ordering of operations + # - Comments explaining complex logic + ``` + +3. **For custom constraints** (CHECK, custom indexes, etc.): + ```bash + source .env && aerich migrate --name "add_check_constraint" --empty + # Then edit and add: # ruff: noqa and # mypy: ignore-errors at the top + ``` + +**Migration Best Practices:** +- Always test migrations on a copy of production data +- Use transactions (`RUN_IN_TRANSACTION = True`) +- Add comments explaining complex data transformations +- For table renames with data migration: + 1. Rename old table to `old_*` + 2. Create new table structure + 3. Migrate data with `INSERT INTO new SELECT ... FROM old` + 4. Drop old table last +- Include proper downgrade logic (even if it loses some data) + ## Architecture ### Layered Architecture diff --git a/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py b/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py new file mode 100644 index 0000000..92b8cdd --- /dev/null +++ b/migrations/models/2_20260115142040_refactor_purchase_to_placement_publication.py @@ -0,0 +1,365 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + -- Шаг 1: Переименовать старую таблицу placement в old_placement + ALTER TABLE "placement" RENAME TO "old_placement"; + + -- Шаг 2: Создать новую таблицу placement (объединение purchase + purchase_channel) + CREATE TABLE "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, + "status" VARCHAR(11) NOT NULL DEFAULT 'planned', + "placement_at" TIMESTAMPTZ, + "payment_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "placement_type" VARCHAR(16), + "format" TEXT, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "invite_link_type" VARCHAR(8) NOT NULL, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE + ); + + -- Шаг 3: Мигрировать данные из purchase_channel + purchase в новый placement + INSERT INTO "placement" ( + "id", "created_at", "updated_at", "deleted_at", + "status", "placement_at", "payment_at", + "cost_type", "cost_value", "cost_before_bargain", + "placement_type", "format", "comment", + "invite_link", "invite_link_type", + "project_id", "creative_id", "channel_id" + ) + SELECT + pc.id, pc.created_at, pc.updated_at, pc.deleted_at, + pc.status, + COALESCE(pc.placement_at, p.placement_at), + p.payment_at, + COALESCE(pc.cost_type, p.cost_type), + COALESCE(pc.cost_value, p.cost_value), + COALESCE(pc.cost_before_bargain, p.cost_before_bargain), + p.purchase_type, + COALESCE(pc.format, p.format), + COALESCE(pc.comment, p.comment), + pc.invite_link, pc.invite_link_type, + p.project_id, p.creative_id, pc.channel_id + FROM "purchase_channel" pc + JOIN "purchase" p ON pc.purchase_id = p.id; + + -- Шаг 4: Создать таблицу publication (бывший placement) + CREATE TABLE "publication" ( + "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, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "placement_id" UUID NOT NULL REFERENCES "placement" ("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 + ); + + -- Шаг 5: Мигрировать данные из old_placement в publication + INSERT INTO "publication" ( + "id", "created_at", "updated_at", "deleted_at", + "wanted_placement_date", "cost", "comment", "status", + "creative_id", "placement_id", "post_id", "project_id" + ) + SELECT + op.id, op.created_at, op.updated_at, op.deleted_at, + op.wanted_placement_date, op.cost, op.comment, op.status, + op.creative_id, op.purchase_channel_id, op.post_id, op.project_id + FROM "old_placement" op; + + -- Шаг 6: Обновить subscription (placement_id -> publication_id) + ALTER TABLE "subscription" RENAME COLUMN "placement_id" TO "publication_id"; + + -- Шаг 7: Создать индексы и constraints + CREATE INDEX "idx_placement_project_4155ce" + ON "placement" ("project_id", "status"); + CREATE INDEX "idx_placement_channel_fb3968" + ON "placement" ("channel_id"); + CREATE INDEX "idx_publication_creativ_1c6807" + ON "publication" ("creative_id"); + CREATE INDEX "idx_publication_placeme_bd5bc9" + ON "publication" ("placement_id"); + CREATE INDEX "idx_publication_post_id_998755" + ON "publication" ("post_id"); + CREATE INDEX "idx_publication_project_94129d" + ON "publication" ("project_id"); + + ALTER TABLE "subscription" + ADD CONSTRAINT "fk_subscrip_publicat_1959be9e" + FOREIGN KEY ("publication_id") + REFERENCES "publication" ("id") ON DELETE CASCADE; + + CREATE UNIQUE INDEX "uid_subscriptio_publica_578bfb" + ON "subscription" ("publication_id", "telegram_user_id"); + + -- Шаг 8: Добавить комментарии + COMMENT ON TABLE "placement" + IS 'Размещение, управляемое пользователем (бывший PurchaseChannel + Purchase)'; + COMMENT ON COLUMN "placement"."status" + IS 'PLANNED: planned +APPROVED: approved +REJECTED: rejected +IN_PROGRESS: in_progress +COMPLETED: completed'; + COMMENT ON COLUMN "placement"."invite_link_type" + IS 'PUBLIC: public +APPROVAL: approval'; + COMMENT ON COLUMN "placement"."cost_type" + IS 'FIXED: fixed +CPM: cpm'; + COMMENT ON COLUMN "placement"."placement_type" + IS 'SELF_PROMO: self_promo +STANDARD: standard'; + + COMMENT ON TABLE "publication" + IS 'Публикация, мониторимая системой (бывший Placement)'; + COMMENT ON COLUMN "publication"."status" + IS 'ACTIVE: active +ARCHIVED: archived'; + + -- Шаг 9: Удалить старые таблицы + DROP TABLE IF EXISTS "old_placement"; + DROP TABLE IF EXISTS "purchase_channel"; + DROP TABLE IF EXISTS "purchase"; + """ + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + -- ВНИМАНИЕ: Downgrade может привести к потере данных при объединении placement обратно + -- в purchase + purchase_channel (если были созданы placement без привязки к одному purchase) + + -- Шаг 1: Переименовать новую таблицу placement в new_placement + ALTER TABLE "placement" RENAME TO "new_placement"; + + -- Шаг 2: Воссоздать таблицу purchase + CREATE TABLE "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', + "placement_at" TIMESTAMPTZ, + "payment_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "purchase_type" VARCHAR(16), + "format" TEXT, + "comment" TEXT, + "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE + ); + + -- Шаг 3: Воссоздать таблицу purchase_channel + CREATE TABLE "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', + "placement_at" TIMESTAMPTZ, + "cost_type" VARCHAR(8), + "cost_value" DOUBLE PRECISION, + "cost_before_bargain" DOUBLE PRECISION, + "format" TEXT, + "comment" TEXT, + "invite_link" VARCHAR(512) NOT NULL, + "invite_link_type" VARCHAR(8) NOT NULL, + "purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE + ); + + -- Шаг 4: Мигрировать данные из new_placement в purchase (сгруппировать) + -- Создаем purchase для каждого уникального project_id + creative_id + INSERT INTO "purchase" ( + "id", "created_at", "updated_at", "deleted_at", + "status", "placement_at", "payment_at", + "cost_type", "cost_value", "cost_before_bargain", + "purchase_type", "format", "comment", + "project_id", "creative_id" + ) + SELECT + gen_random_uuid(), + MIN(np.created_at), + MAX(np.updated_at), + NULL, + 'active', + MIN(np.placement_at), + MIN(np.payment_at), + MIN(np.cost_type), + MIN(np.cost_value), + MIN(np.cost_before_bargain), + MIN(np.placement_type), + MIN(np.format), + MIN(np.comment), + np.project_id, + np.creative_id + FROM "new_placement" np + GROUP BY np.project_id, np.creative_id; + + -- Шаг 5: Мигрировать данные из new_placement в purchase_channel + INSERT INTO "purchase_channel" ( + "id", "created_at", "updated_at", "deleted_at", + "status", "placement_at", "cost_type", "cost_value", + "cost_before_bargain", "format", "comment", + "invite_link", "invite_link_type", + "purchase_id", "channel_id" + ) + SELECT + np.id, np.created_at, np.updated_at, np.deleted_at, + np.status, np.placement_at, np.cost_type, np.cost_value, + np.cost_before_bargain, np.format, np.comment, + np.invite_link, np.invite_link_type, + p.id, np.channel_id + FROM "new_placement" np + JOIN "purchase" p + ON p.project_id = np.project_id + AND p.creative_id = np.creative_id; + + -- Шаг 6: Воссоздать старую таблицу placement + CREATE TABLE "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, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "purchase_channel_id" UUID NOT NULL + REFERENCES "purchase_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 + ); + + -- Шаг 7: Мигрировать данные из publication в placement + INSERT INTO "placement" ( + "id", "created_at", "updated_at", "deleted_at", + "wanted_placement_date", "cost", "comment", "status", + "creative_id", "purchase_channel_id", "post_id", "project_id" + ) + SELECT + pub.id, pub.created_at, pub.updated_at, pub.deleted_at, + pub.wanted_placement_date, pub.cost, pub.comment, pub.status, + pub.creative_id, pub.placement_id, pub.post_id, pub.project_id + FROM "publication" pub; + + -- Шаг 8: Обновить subscription (publication_id -> placement_id) + ALTER TABLE "subscription" RENAME COLUMN "publication_id" TO "placement_id"; + + -- Шаг 9: Создать индексы + CREATE INDEX "idx_purchase_project_status" ON "purchase" ("project_id", "status"); + CREATE INDEX "idx_placement_purchas_61f9e6" + ON "placement" ("purchase_channel_id"); + CREATE INDEX "idx_placement_post_id_e4a16e" ON "placement" ("post_id"); + + ALTER TABLE "subscription" + ADD CONSTRAINT "fk_subscrip_placemen_0b5d8f15" + FOREIGN KEY ("placement_id") + REFERENCES "placement" ("id") ON DELETE CASCADE; + + CREATE UNIQUE INDEX "uid_subscriptio_placeme_75b40b" + ON "subscription" ("placement_id", "telegram_user_id"); + + -- Шаг 10: Удалить новые таблицы + DROP TABLE IF EXISTS "publication"; + DROP TABLE IF EXISTS "new_placement"; + """ + + +MODELS_STATE = ( + "eJztXWtz2roW/SsMn3rm5nbCIwnN3LkzhJCWlgADpO09TcdjjCC+MTbHNkkzZ/rfj+QHlv" + "zCL7AN+wtNJW3ZXluvvbS19Xd1pcyRpL3vPPGyjKTqdeXvqsyvEP7DnXVWqfLrtZNBEnR+" + "JhllBarQTNNVXtBx8oKXNIST5kgTVHGti4qMU+WNJJFERcAFRXnpJG1k8a8N4nRlifQnpO" + "KMHz9xsijP0S+k2f9dP3MLEUlz5mXFOXm2kc7pb2sj7eGhd3tnlCSPm3GCIm1WslN6/aY/" + "KfK2+GYjzt8TGZK3RDJSeR3Nqc8gb2l9sJ1kvjFO0NUN2r7q3EmYowW/kQgY1f8sNrJAMK" + "gYTyI/zf9WY8AjKDKBVpR1gsXfv82vcr7ZSK2SR3U+tcfvGpd/GF+paPpSNTINRKq/DUFe" + "501RA1cHSEFF5LM5XvcCeotzdHGF/EFlJV3gzi3R9/YfSUC2ExyUnRZmw2zDlwzTKv6G+V" + "CW3iwNhmA87d13J9P2/Yh8yUrT/pIMiNrTLsmpG6lvrtR3pkoU3D/MfrOtpPKtN/1UIf+t" + "/DkcdN2K25ab/lkl78RvdIWTlVeOn1ONzU61gcElHcVu1vOEimUlQbG5KtZ6eUeveCxGyf" + "TKSmagV+ttD6jWkqjR/uzQDqpjdSxVfsX5zWU34rIn6/56dAm6FImh2s+ElrJDLslT/v2h" + "Xm80rurnjcvWRfPq6qJ13sJljVfyZl2FqPum97E3mLLaIwm/WYxFXUJedPEaRw3A1hZwoY" + "o/paAD3Yr/xUlIXupP+L/1i4sQzL62x8ZKAZdytfuBlVU381gQNxpSjb9j4EjLZAPl3hvo" + "/oHkBQFpGvfEa0/xerxLMFGPP/xgnU+XX+uaF9xAZK3S5UK0XmteNVuNy+YWyG1KGH4mVs" + "SkWjxTtgBJmPHC8yuvzjkmhwJV4gW0QrIftjeW7N2XMZJ445O8iFrG5ciup5iD6W+7edip" + "fgswsg5ICwOuoswIqMr/kZAaBLOWMuOA1JWoabhqThMU0tVSAfJNUZ+1Ne4gD3jyHG3rnp" + "CqCzkcBYJEhhSlrgQNMt6sVX3lTuFlfmm8NXk2eZLNTxHaQXxBVT/uys47CyWv6FLAXgF7" + "BSQHsFegWGCvgL2Kzl7FJQSyJQOOglfR0S+fXjDFqUG83y+/tl9YCMOae/f7lGnpNlDv7t" + "vf/2Bae384+GgXp4Dt9Ic3LjxXaC7yJiwxGiYrlQjbHIgApnU26hEaZ6Me2DZJlh+UC1FC" + "vuz0LjQpwVICelGLgiguFQipkeeHqdbgntFbfEgdOUDURnS20XX8UC+YnyfDgT+YlIgLxw" + "cZf+CPuSjoZxVJ1PSf+xpUKdNqthElXZS19+SBe7KuCBbhQ617VHWtFkgF7qFW03l94wM8" + "acVdebMywO/hV+RlAXmU4EgfbiqrYiPfsvlZjKvtzrT3tXtdMQs8ygQdnHCLU1ThCSeZrS" + "hmg29FaO6twMbecjd1i3LzHY2DmQNWKksGwQt5xtsxyfkCD8HtQdEL4Z2iInEpf0FvnpZ7" + "APJyP9B5aDmcrPKvWzrK1TrwN5omlgFxe9Jp33arv2FzICYlvJlJomB8aFo0nJrKjAdQ5I" + "enyPvKUpSnyjOSqz4kOZV7FkaTS6Qcp28LAlMOTDkQqsCUg2KBKQemPIafpz2DRvZBtAXA" + "cc6CEP1ai1gjCfoCK1nOMe6YOsNGSzZVaTCeFUiFakweihIBEoqAkQED9WBVU1TQdtJPVK" + "OIyz3t03h2WCgf25mhqIJN5zVTbJfhXH3cnDfr5+S3Yf5eGb+C8XtBfpsfnL8bc+O3Zaac" + "VYz8hvG/hfE3XVPd+J0Z6QuqDrNuZKZUHGEryRQQqJdBVHVG1c06Vd2Mrrryzvi3ZpSaOX" + "LNFvXiHyqjjSo88RqyDpxW/rVN+cNNuANCfgjtplV+0Iy3tevyE7gW4FrAJAeu5XQVC1zL" + "sdomJfRLwGtFPLnPfRwTRv32YED8EKwij3J7NBoPTdeENZ7ZX0jauPu525mSNBWRqZ6k9Q" + "YcLvhx3J1MriuizOGyBGXtUe4M70f9rlFcUFZro/EmcW+o1SKwPLVaIMlDslweDvaaOUEv" + "dMtCP8y5H675t6SqZCRBkTkrUsD1hbjU7h5TmQrydV2s3vW+k4FvIf4ig2RndI8HwfUqyf" + "CXsXeXAdILL218YL6TFD7AIZwVc4G7IHJl6x+3w4ebfrcyGnc7vUnPclzcdggj0zBl/5JE" + "kywad9t9PzBnaKGoiJvx6hI/Nz6qXnmA1z1PpxkWvLXkPDZMuv07smi6H15XNCQtyJpppT" + "zKeAAd3LbHeNggXzLn1WTLpcsoyyX3QE8tly7dQwZuniu/2TX4+IgjURIP8kOfH8ELYpuz" + "jYopJQKg+oIqyi94LOEkUX6OswfuEivLkacDHHqgkEk1AvvVkzPM1RGegXodbPAavq+2vd" + "vu2/YuLxVhsWay8TG3QFmpE9kFZWCzoi7ExY0VO0Hg4OgHHP3I4+iHX+fNAD46Rktp8XON" + "ShEAdAImp8XPqam88DGTYYqTR3DaBk7b5H3axgjm5ucrZAV5C3ETsktkerTmh6t7rZCm4T" + "cn//sJx272slgDV5Aj9xgAV5AjVSy4ghzrziU17XkUGRgOmBXKJrL6AXpjBmGBITDV/jhw" + "e2RYqMqKsxdnyQcYn2pgtMnbTwJI4Wp6kg5Ykn2wJC8ietW4J1HTFVVMywkQq/YrqfCTUd" + "9boUf4YsZqOSFehGkpARyJuzWF8yUc3Zrf9sGeGE+xxnGkC0/mkh6oE6BOwMIG6gQUC9TJ" + "sRsz5hQrKBs/Z7hA7sQldarkCbVmiNkLWMnDjG6HswpL1wvodWBUpxtHBIx5e3czrbtNZn" + "dl5eVr4zSKQsW5sNyY/Kwyx8MpxBijCmVsgb3afgcWbBQRAmYYmGGwWgczDBQLZtixL0BL" + "GMwg8iULvYGdIsrFvnjBirjEWedlSIWc/cUJVbOrzkMqjD5Xc4jDOLXzKCchz4NPQp7DeZ" + "wU6zwHNvciOypwbrkTgQ52re2GkMGutW87zAC/b3RdpUXQ3cOS7/zbx1RSbm9ne2Qnn71+" + "uKMmd7+HwuIBp2Zy8A6hmo4fBcm2rBAa0lUwUrDd2oIKCVuj4rOakVd5KobrpZPeXJihZJ" + "nQsFSoWSveK6JCzLao8mZ9CzMabY0Sq3mCxdIP+BA1WKw9MvlHzz3+T4Zbhg5lRABZfOSc" + "IpDFR6pYIIuPlSx+5WWiDSeuGkE+rk4DKylntz0m/Qr+3hThYQx9VHeycQshutseTrYd8x" + "ZVIbejIIpW0iha2zktpkufS+4UocvLEXL3+FlMvCBgW1TYwtxHIWAbBGzLL2Abc4Vb2gaY" + "7aZRXk3QNRNGwPCgHuB5zRaJHcAn3Wll8NDvR9vL1Taz7Vum3KGaUFUV2nI/7JYUA4vPnp" + "QbtuBNKc1dMusjys6ml9WwdNyqliq/4uxbNcFRHvY+gCKHvQ9QLOx9HDs3Xuw7JjJedR7g" + "ioljZnUfBpOHm0ln3LshzO5GtpZqs2TsbiT0Q8D3Yk+/UZIZxysOw1PeB8k9q/XInKBH8k" + "R4QTYapcu0iQGgn+yJQBhGrbI+k2npmaydgPOiaTydbTfDxbSvDLCcWvU9WNWVFky/jlek" + "6AcM0D5cj1sRwVyPpwlkS/YAjbOX6QVonCO39oHGOVLFAo1zrHbSdib1m8tuxGVg5DmXYD" + "aR53ZPaCk7pBl37kO93mhc1c8bl62L5tXVRet8G4DOmxUWie6m95EEo2O0541ORxYpxt8e" + "gIN5MlqmJH6QLEtTv7iIQNPgUoE8jZHnivMnqprOxcWSlQI0bTQlPgGYjNBpY+mx9sGlYC" + "8uBexIGozZUEZTBf/sRi6iqV+wY8ExTO0gE3u3aQ0WdfgCBCxqMLzAogbFgkWd/bRXdDXG" + "s6gLuHW2b/s6w52zKGtpSVmKGBnlGaVdSvdJTVNSUTHHvcB1IeNCYviJGw42WYXT6Rm1lR" + "gTKq4XAOMPDBlrsgy/VDJQvKZo5K1m2+A80EbzgUbvfe0zp9o7dqIM+li1TAjCYNOWiXoI" + "9i3Yt9ksl8EMAvsWFAv27YnYt3E3jFLtFeXRBQu0WWSdQE8bMTbLs/i5GCtgu4HtduiTye" + "7mEmZ3OC0qgvVhMRHZGyHeS7zgYDLYJ7CMBfsEFAv2yanYJyU8SLtG8pyox6PA6qg7uO0N" + "Pl5XrCKPcrvT6Y6mRoREQUBr/CaP8rj7dfiFJKnoRXkuRMxEc40z52ZvMbdAPYIncnTQ7T" + "AcE7bTO2sJ92olhi7kmCrcDHWW6GYo/7EvAxBLfyrVM6DvBjCjg72lh66g53hZxiiMF9nt" + "bsxSWcCJACcCnEihbC7gRE5GscCJACdSGE4kxq3mX3sGH2ItNB/lm/6wY9AhM0kREtIhYa" + "d+bTrkKpAOufJEGAOLHix6sOiLYVbFt+jBIE1tkPpd/bufS3+LuUqM5NhRrFuRi4TSwfgM" + "qh3tYjbYJheV4+DWrNje6A56AeE8EmgPoD3AOgbaAxQLtMex0x7sTJuE+mBryNmFvdq+ve" + "8NuLuHfv+6ws9XoswtcLlHeTQefu52phNu3G3fXldsr3GO9FAq99u4N+1S2a+qqKNHuYOl" + "CKVii9vXdtnyTr5VgVPAqmHUb3e6992B8wb2tVXbd3BK2G/hFLFqaQ/a/f9Nex27Erxck9" + "50UbDrcPJJyxk+TLlOv9f5MqFLvopYQxudEyRReNbwu39qDwbd/vbTnnhZxssz+8vsXPvD" + "7GzjjZLQRvVmlNMCzeDDAk03c+S7novPhpwemxSFEskqhPQefOhz50bKsPnstmmjW2xbGz" + "iB2Wba5tkbb2CZ7WEYAMvs6BfwYJkdqWLBMgPLDCwzsMyKaZlZ7xTTJGOlUizL8rp6OeXG" + "vutS8MiwsWKnh5v7JvCowLnlThA5c1SNixsjdXqoAe8EvFMxeSefvp0BlNGDpOTVoXdfps" + "cMWbvBs+fUDNDrUFWVFT7XEiNC47On1iyaH11XWRF0rzUiNEFzNZxFC3RqKit8rGlQJNK9" + "jVRReKr60OtWzlkYkc47ZQrDlwfeaxX1OitLgel48pQ8n3mbVb3WvGq2GpfN7SVW25QwL3" + "b7nqpgevwFqcEMjT96lMiJx3ujRznSNWKAaBUvJ4C18/MIAOJSwfezkzwXaaDIuu88+3ky" + "HAQQBo6IC8gHGX/gj7ko6GcVSdT0n8WENQRF8tUM02uD9+6+/d2Na6c/vHGbKqSCm3hBCb" + "OfXn7/A5u7TFM=" +) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 4469935..fe4c363 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -311,105 +311,6 @@ class Postgres(DatabaseBase, Database): project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id) return project is not None - 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(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None: - return await domain.Purchase.get_or_none(id=purchase_id, project__workspace_id=workspace_id) - - async def get_project_purchases(self, workspace_id: uuid.UUID, project_id: uuid.UUID) -> list[domain.Purchase]: - return ( - await domain.Purchase.filter(project__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 get_purchase_channel_by_id(self, purchase_channel_id: uuid.UUID) -> domain.PurchaseChannel | None: - return ( - await domain.PurchaseChannel.filter(id=purchase_channel_id) - .prefetch_related('channel', 'purchase', 'purchase__project') - .first() - ) - - 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, - purchase_id: uuid.UUID, - channel_id: uuid.UUID, - invite_link: str, - invite_link_type: domain.purchase.InviteLinkType, - *, - status: domain.PurchaseChannelStatus | None = None, - comment: str | None = None, - placement_at: datetime.datetime | None = None, - cost_type: domain.purchase.CostType | None = None, - cost_value: float | None = None, - cost_before_bargain: float | None = None, - format: str | None = 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 comment is not None: - defaults['comment'] = comment - if placement_at is not None: - defaults['placement_at'] = placement_at - if cost_type is not None: - defaults['cost_type'] = cost_type - if cost_value is not None: - defaults['cost_value'] = cost_value - if cost_before_bargain is not None: - defaults['cost_before_bargain'] = cost_before_bargain - if format is not None: - defaults['format'] = format - - 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(purchase_channel, field, value) - await purchase_channel.save() - - return purchase_channel - - async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: - await purchase_channel.save() - - 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, project__workspace_id=workspace_id @@ -442,26 +343,51 @@ class Postgres(DatabaseBase, Database): async def delete_creative(self, creative_id: uuid.UUID) -> None: await domain.Creative.filter(id=creative_id).delete() + # Placement methods (user-managed, запланированные размещения) + async def create_placement(self, placement: domain.Placement) -> None: + await placement.save() + + async def create_placements(self, placements: list[domain.Placement]) -> None: + if placements: + await domain.Placement.bulk_create(placements) + async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: return await domain.Placement.get_or_none( id=placement_id, project__workspace_id=workspace_id + ).prefetch_related('project', 'project__channel', 'channel', 'creative') + + async def get_project_placements( + self, workspace_id: uuid.UUID, project_id: uuid.UUID, include_archived: bool = False + ) -> list[domain.Placement]: + query = domain.Placement.filter(project__workspace_id=workspace_id, project_id=project_id) + + if not include_archived: + query = query.filter(status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS]) + + return ( + await query.prefetch_related('project', 'project__channel', 'channel', 'creative') + .order_by('-created_at') + .all() + ) + + async def update_placement_user(self, placement: domain.Placement) -> None: + await placement.save() + + # Publication methods (system-managed, фактически размещенные посты) + async def get_publication(self, workspace_id: uuid.UUID, publication_id: uuid.UUID) -> domain.Publication | None: + return await domain.Publication.get_or_none( + id=publication_id, placement__project__workspace_id=workspace_id ).prefetch_related( - 'project', - 'project__channel', - 'purchase_channel', - 'purchase_channel__channel', - 'creative', + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', 'post', 'post__channel', ) - async def create_placement(self, placement: domain.Placement) -> None: - await placement.save() - - async def update_placement(self, placement: domain.Placement) -> None: - await placement.save() - - async def get_workspace_placements( + async def get_workspace_publications( self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, @@ -471,75 +397,85 @@ class Postgres(DatabaseBase, Database): allowed_project_ids: set[uuid.UUID] | None = None, date_from: datetime.datetime | None = None, date_to: datetime.datetime | None = None, - ) -> list[domain.Placement]: - query = domain.Placement.filter(project__workspace_id=workspace_id) + ) -> list[domain.Publication]: + query = domain.Publication.filter(placement__project__workspace_id=workspace_id) if project_id: - query = query.filter(project_id=project_id) + query = query.filter(placement__project_id=project_id) elif allowed_project_ids is not None: if not allowed_project_ids: return [] - query = query.filter(project_id__in=list(allowed_project_ids)) + query = query.filter(placement__project_id__in=list(allowed_project_ids)) if placement_channel_id: - query = query.filter(purchase_channel__channel_id=placement_channel_id) + query = query.filter(placement__channel_id=placement_channel_id) if creative_id: - query = query.filter(creative_id=creative_id) + query = query.filter(placement__creative_id=creative_id) if date_from: - query = query.filter(wanted_placement_date__gte=date_from) + query = query.filter(created_at__gte=date_from) if date_to: - query = query.filter(wanted_placement_date__lte=date_to) + query = query.filter(created_at__lte=date_to) if not include_archived: - query = query.filter(status=domain.PlacementStatus.ACTIVE) + query = query.filter(status=domain.PublicationStatus.ACTIVE) return ( await query.prefetch_related( - 'project', - 'project__channel', - 'purchase_channel', - 'purchase_channel__channel', - 'purchase_channel__purchase', - 'creative', + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', 'post', 'post__channel', ) - .order_by('-wanted_placement_date') + .order_by('-created_at') .all() ) - async def delete_placement(self, placement_id: uuid.UUID) -> None: - await domain.Placement.filter(id=placement_id).delete() + async def create_publication(self, publication: domain.Publication) -> None: + await publication.save() - async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: - return await domain.Placement.get_or_none( - purchase_channel__invite_link=invite_link - ).prefetch_related('project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative') + async def update_publication(self, publication: domain.Publication) -> None: + await publication.save() + async def delete_publication(self, publication_id: uuid.UUID) -> None: + await domain.Publication.filter(id=publication_id).delete() + + async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None: + return await domain.Publication.get_or_none(placement__invite_link=invite_link).prefetch_related( + 'placement', + 'placement__project', + 'placement__project__channel', + 'placement__channel', + 'placement__creative', + ) + + # Subscription methods async def create_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() - async def get_subscription_by_subscriber_and_placement( - self, telegram_user_id: uuid.UUID, placement_id: uuid.UUID + async def get_subscription_by_subscriber_and_publication( + self, telegram_user_id: uuid.UUID, publication_id: uuid.UUID ) -> domain.Subscription | None: return await domain.Subscription.get_or_none( - telegram_user_id=telegram_user_id, placement_id=placement_id + telegram_user_id=telegram_user_id, publication_id=publication_id ) async def update_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() - async def get_subscriptions_for_placements( + async def get_subscriptions_for_publications( self, - placement_ids: list[uuid.UUID], + publication_ids: list[uuid.UUID], *, date_from: datetime.datetime | None = None, date_to: datetime.datetime | None = None, ) -> list[domain.Subscription]: - if not placement_ids: + if not publication_ids: return [] - query = domain.Subscription.filter(placement_id__in=placement_ids) + query = domain.Subscription.filter(publication_id__in=publication_ids) if date_from: query = query.filter(created_at__gte=date_from) @@ -554,10 +490,10 @@ class Postgres(DatabaseBase, Database): return ( await domain.Subscription.filter( telegram_user_id=telegram_user_id, - placement__project_id=project_id, + publication__placement__project_id=project_id, status=domain.SubscriptionStatus.ACTIVE, ) - .prefetch_related('placement', 'telegram_user') + .prefetch_related('publication', 'telegram_user') .all() ) @@ -567,10 +503,10 @@ class Postgres(DatabaseBase, Database): return ( await domain.Subscription.filter( telegram_user_id=telegram_user_id, - placement__project_id=project_id, + publication__placement__project_id=project_id, status=domain.SubscriptionStatus.ACTIVE, ) - .prefetch_related('placement', 'telegram_user') + .prefetch_related('publication', 'telegram_user') .first() ) @@ -625,43 +561,43 @@ class Postgres(DatabaseBase, Database): return results # Count methods - async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int: - return await domain.Placement.filter(creative_id=creative_id).count() + async def count_publications_by_creative(self, creative_id: uuid.UUID) -> int: + return await domain.Publication.filter(placement__creative_id=creative_id).count() - async def count_subscriptions_by_placement(self, placement_id: uuid.UUID) -> int: - return await domain.Subscription.filter(placement_id=placement_id).count() + async def count_subscriptions_by_publication(self, publication_id: uuid.UUID) -> int: + return await domain.Subscription.filter(publication_id=publication_id).count() - async def count_placements_by_creative_batch(self, creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + async def count_publications_by_creative_batch(self, creative_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: if not creative_ids: return {} from tortoise.functions import Count results = ( - await domain.Placement.filter(creative_id__in=creative_ids) - .group_by('creative_id') + await domain.Publication.filter(placement__creative_id__in=creative_ids) + .group_by('placement__creative_id') .annotate(count=Count('id')) - .values('creative_id', 'count') + .values('placement__creative_id', 'count') ) - counts = {row['creative_id']: row['count'] for row in results} + counts = {row['placement__creative_id']: row['count'] for row in results} return {cid: counts.get(cid, 0) for cid in creative_ids} - async def count_subscriptions_by_placement_batch(self, placement_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: - if not placement_ids: + async def count_subscriptions_by_publication_batch(self, publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]: + if not publication_ids: return {} from tortoise.functions import Count results = ( - await domain.Subscription.filter(placement_id__in=placement_ids) - .group_by('placement_id') + await domain.Subscription.filter(publication_id__in=publication_ids) + .group_by('publication_id') .annotate(count=Count('id')) - .values('placement_id', 'count') + .values('publication_id', 'count') ) - counts = {row['placement_id']: row['count'] for row in results} - return {pid: counts.get(pid, 0) for pid in placement_ids} + counts = {row['publication_id']: row['count'] for row in results} + return {pid: counts.get(pid, 0) for pid in publication_ids} - async def has_placements_for_creative(self, creative_id: uuid.UUID) -> bool: - return await domain.Placement.filter(creative_id=creative_id).exists() + async def has_publications_for_creative(self, creative_id: uuid.UUID) -> bool: + return await domain.Publication.filter(placement__creative_id=creative_id).exists() diff --git a/src/adapter/s3.py b/src/adapter/s3.py index 3294518..9ed98a1 100644 --- a/src/adapter/s3.py +++ b/src/adapter/s3.py @@ -1,8 +1,7 @@ import logging -import uuid -import aioboto3 -from botocore.config import Config +import aioboto3 # type: ignore[import-untyped] +from botocore.config import Config # type: ignore[import-untyped] from pydantic import BaseModel from types_aiobotocore_s3.client import S3Client @@ -89,7 +88,7 @@ class S3(S3Storage): config=self.boto_config, ) as client: response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key) - data = await response['Body'].read() + data: bytes = await response['Body'].read() log.info(f'Downloaded file from S3: {key}') return data diff --git a/src/controller/http_v1/__init__.py b/src/controller/http_v1/__init__.py index e506ebf..6bd1412 100644 --- a/src/controller/http_v1/__init__.py +++ b/src/controller/http_v1/__init__.py @@ -5,11 +5,11 @@ from src.controller.http_v1.auth import auth_router from src.controller.http_v1.channels import channels_router 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.placements import publications_router from src.controller.http_v1.projects import projects_router -from src.controller.http_v1.purchases import purchase_router +from src.controller.http_v1.purchases import placements_user_router from src.controller.http_v1.views import views_router -from src.controller.http_v1.workspace_invites import workspace_invites_router, workspace_invites_global_router +from src.controller.http_v1.workspace_invites import workspace_invites_global_router, workspace_invites_router from src.controller.http_v1.workspace_members import workspace_members_router from src.controller.http_v1.workspaces import workspaces_router @@ -21,9 +21,9 @@ 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_router) +api_v1_router.include_router(placements_user_router) # User-managed placements api_v1_router.include_router(creatives_router) -api_v1_router.include_router(placements_router) +api_v1_router.include_router(publications_router) # System-managed publications api_v1_router.include_router(views_router) api_v1_router.include_router(analytics_router) api_v1_router.include_router(workspaces_router) diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index e37f270..df1d2b2 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -1,3 +1,5 @@ +from typing import Any + from fastapi.routing import APIRouter from pydantic import BaseModel @@ -68,7 +70,7 @@ async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) -> @internal_router.post('/telegram/updates') -async def handle_telegram_update(update: dict) -> None: +async def handle_telegram_update(update: dict[str, Any]) -> None: """Обрабатывает системные события от Golang бота (echotron формат)""" import json import logging @@ -105,7 +107,7 @@ async def handle_telegram_update(update: dict) -> None: log.warning('Unknown update type from Golang bot: %s', list(update.keys())) -async def _handle_chat_join_request(event: dict) -> None: +async def _handle_chat_join_request(event: dict[str, Any]) -> None: """Обработка запроса на вступление в канал через invite link""" import logging @@ -138,7 +140,7 @@ async def _handle_chat_join_request(event: dict) -> None: ) -async def _handle_chat_member_updated(event: dict) -> None: +async def _handle_chat_member_updated(event: dict[str, Any]) -> None: """Обработка изменения статуса участника канала (вступление/выход)""" import logging @@ -192,7 +194,7 @@ async def _handle_chat_member_updated(event: dict) -> None: ) -async def _handle_my_chat_member(event: dict) -> None: +async def _handle_my_chat_member(event: dict[str, Any]) -> None: """Обработка добавления/удаления бота из канала""" import logging diff --git a/src/controller/http_v1/placements.py b/src/controller/http_v1/placements.py index 1b66f82..60b69e0 100644 --- a/src/controller/http_v1/placements.py +++ b/src/controller/http_v1/placements.py @@ -3,24 +3,25 @@ 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 -placements_router = APIRouter(prefix='/workspaces/{workspace_id}/placements', tags=['placements']) +# Publications router - System-managed actual published posts +publications_router = APIRouter(prefix='/workspaces/{workspace_id}/publications', tags=['publications']) -@placements_router.get('') -async def list_placements( +@publications_router.get('') +async def list_publications( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], project_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, -) -> Page[dto.PlacementOutput]: - input = dto.GetPlacementsInput( +) -> dto.GetPublicationsOutput: + """List all publications (system-managed actual posts) in workspace""" + input = dto.GetPublicationsInput( user_id=current_user.user_id, workspace_id=workspace_id, project_id=project_id, @@ -29,23 +30,24 @@ async def list_placements( include_archived=include_archived, ) - result = await deps.get_usecase().get_placements(input=input) - return paginate(result) # type: ignore[no-any-return] + result = await deps.get_usecase().get_publications(input=input) + return result -@placements_router.get('/{placement_id}') -async def get_placement( - placement_id: uuid.UUID, +@publications_router.get('/{publication_id}') +async def get_publication( + publication_id: uuid.UUID, workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.PlacementOutput: - input = dto.GetPlacementInput( - placement_id=placement_id, +) -> dto.PublicationOutput: + """Get single publication by ID""" + input = dto.GetPublicationInput( + publication_id=publication_id, user_id=current_user.user_id, workspace_id=workspace_id, ) - return await deps.get_usecase().get_placement(input=input) + return await deps.get_usecase().get_publication(input=input) -# Placement creation/updates are handled by worker; API exposes only read endpoints. +# Publication creation/updates are handled by worker; API exposes only read endpoints. diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py index 6271908..a9454cf 100644 --- a/src/controller/http_v1/purchases.py +++ b/src/controller/http_v1/purchases.py @@ -3,25 +3,26 @@ 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'], +# Placement router - User-managed planned placements +placements_user_router = APIRouter( + prefix='/workspaces/{workspace_id}/projects/{project_id}/placements', + tags=['placements'], ) -@purchase_router.post('') -async def create_purchase( - request: dto.CreatePurchaseInput, +@placements_user_router.post('') +async def create_placements( + request: dto.CreatePlacementsInput, 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( +) -> dto.GetPlacementsOutput: + """Create multiple placements for different channels (bulk creation)""" + return await deps.get_usecase().create_placements( project_id=project_id, workspace_id=workspace_id, user_id=current_user.user_id, @@ -29,32 +30,34 @@ async def create_purchase( ) -@purchase_router.get('') -async def get_purchases( +@placements_user_router.get('') +async def get_placements( workspace_id: uuid.UUID, project_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> Page[dto.PurchaseOutput]: - input = dto.GetPurchasesInput( +) -> dto.GetPlacementsOutput: + """Get all placements for a project""" + input = dto.GetPlacementsInput( 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] + result = await deps.get_usecase().get_placements(input=input) + return result -@purchase_router.get('/{purchase_id}') -async def get_purchase( - purchase_id: uuid.UUID, +@placements_user_router.get('/{placement_id}') +async def get_placement( + placement_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( +) -> dto.PlacementOutput: + """Get single placement by ID""" + input = dto.GetPlacementInput( user_id=current_user.user_id, workspace_id=workspace_id, project_id=project_id, - purchase_id=purchase_id, + placement_id=placement_id, ) - return await deps.get_usecase().get_purchase(input=input) + return await deps.get_usecase().get_placement_user(input=input) diff --git a/src/controller/http_v1/views.py b/src/controller/http_v1/views.py index feaf478..5052809 100644 --- a/src/controller/http_v1/views.py +++ b/src/controller/http_v1/views.py @@ -21,7 +21,7 @@ async def get_views_history( to_date: datetime.datetime | None = None, ) -> Page[dto.PostViewsHistoryOutput]: input_data = dto.GetViewsHistoryInput( - placement_id=placement_id, + publication_id=placement_id, user_id=current_user.user_id, workspace_id=workspace_id, from_date=from_date, diff --git a/src/controller/worker/fetch_placement_post.py b/src/controller/worker/fetch_placement_post.py index cc24e64..415f96a 100644 --- a/src/controller/worker/fetch_placement_post.py +++ b/src/controller/worker/fetch_placement_post.py @@ -10,6 +10,8 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -class FetchPlacementPostWorker(WorkerBase): +class FetchPublicationPostWorker(WorkerBase): + """Worker that fetches posts from channels and creates Publications from Placements""" + async def _cycle_func(self) -> None: - await deps.get_usecase().fetch_placement_post_cycle(self.config.INTERVAL_SECONDS) + await deps.get_usecase().fetch_publication_post_cycle(self.config.INTERVAL_SECONDS) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 349a119..613c057 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -16,24 +16,24 @@ __all__ = ( 'Channel', 'Project', 'ProjectStatus', - 'Purchase', - 'PurchaseChannel', - 'PurchaseStatus', - 'PurchaseChannelStatus', + 'Placement', + 'PlacementStatus', + 'PlacementType', + 'Publication', + 'PublicationStatus', 'Creative', 'replace_invite_link_with_tag', 'validate_media_size', 'MAX_CREATIVE_MEDIA_BYTES', - 'Placement', 'Post', 'Subscription', 'PostViewsHistory', 'ChannelNotFound', 'ProjectNotFound', 'CreativeStatus', - 'PlacementStatus', 'SubscriptionStatus', 'InviteLinkType', + 'CostType', 'PostViewsAvailability', 'LoginToken', 'UserNotFound', @@ -48,8 +48,8 @@ __all__ = ( 'LoginTokenAlreadyUsed', 'ProjectNotFound', 'ProjectChannelConflict', - 'PurchaseNotFound', - 'PurchaseChannelNotFound', + 'PlacementNotFound', + 'PublicationNotFound', 'ChannelNotFound', 'ChannelAlreadyExists', 'ChannelNoAdminRights', @@ -59,12 +59,17 @@ __all__ = ( 'CreativeInviteLinkNotFound', 'CreativeMultipleInviteLinks', 'CreativeMediaTooLarge', - 'PlacementNotFound', 'UserByUsernameNotFound', ) from .channel import Channel -from .creative import Creative, CreativeStatus, MAX_CREATIVE_MEDIA_BYTES, replace_invite_link_with_tag, validate_media_size +from .creative import ( + MAX_CREATIVE_MEDIA_BYTES, + Creative, + CreativeStatus, + replace_invite_link_with_tag, + validate_media_size, +) from .error import ( ChannelAlreadyExists, ChannelNoAdminRights, @@ -78,10 +83,9 @@ from .error import ( LoginTokenExpired, LoginTokenNotFound, PlacementNotFound, - ProjectNotFound, ProjectChannelConflict, - PurchaseChannelNotFound, - PurchaseNotFound, + ProjectNotFound, + PublicationNotFound, TelegramChannelNotFound, UserByUsernameNotFound, UserNotFound, @@ -93,20 +97,20 @@ from .error import ( WorkspaceNotFound, ) from .login_token import LoginToken -from .placement import Placement, PlacementStatus, PostViewsAvailability +from .placement import PostViewsAvailability, Publication, PublicationStatus from .post import Post from .post_views_history import PostViewsHistory from .project import Project, ProjectStatus from .purchase import ( + CostType, InviteLinkType, - Purchase, - PurchaseChannel, - PurchaseChannelStatus, - PurchaseStatus, + Placement, + PlacementStatus, + PlacementType, ) from .subscription import Subscription, SubscriptionStatus -from .user import User from .telegram_user import TelegramUser +from .user import User from .workspace import ( PermissionKey, PermissionScopeType, diff --git a/src/domain/error.py b/src/domain/error.py index 9575a10..7b0fcb5 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -47,16 +47,16 @@ def ProjectChannelConflict() -> HTTPException: return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace') -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 PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException: + if placement_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_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 PublicationNotFound(publication_id: uuid.UUID | None = None) -> HTTPException: + if publication_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Publication not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Publication {publication_id} not found') def ChannelAlreadyExists(telegram_id: int) -> HTTPException: @@ -90,16 +90,10 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException: def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: return HTTPException( status.HTTP_400_BAD_REQUEST, - f'Creative {creative_id} is used in active placements and cannot be deleted', + f'Creative {creative_id} is used in active publications and cannot be deleted', ) -def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException: - if placement_id is None: - return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found') - return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found') - - def WorkspaceNotFound(workspace_id: uuid.UUID | None = None) -> HTTPException: if workspace_id is None: return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found') diff --git a/src/domain/placement.py b/src/domain/placement.py index f6b9f5c..9ed0969 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -10,10 +10,12 @@ if TYPE_CHECKING: from .creative import Creative from .post import Post from .project import Project - from .purchase import PurchaseChannel + from .purchase import Placement + +__all__ = ['Publication', 'PublicationStatus', 'PostViewsAvailability'] -class PlacementStatus(str, enum.Enum): +class PublicationStatus(str, enum.Enum): ACTIVE = 'active' # Пост найден и отслеживается ARCHIVED = 'archived' # Вручную архивировано @@ -25,32 +27,34 @@ class PostViewsAvailability(str, enum.Enum): MANUAL = 'manual' # Просмотры вводятся вручную -class Placement(TimestampedModel): +class Publication(TimestampedModel): + """Публикация, мониторимая системой (бывший Placement)""" + wanted_placement_date = fields.DatetimeField(db_column='placement_date') cost = fields.FloatField(null=True) comment = fields.TextField(null=True) - status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) + status = fields.CharEnumField(PublicationStatus, default=PublicationStatus.ACTIVE) project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( - 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True + 'models.Project', related_name='publications', on_delete=fields.CASCADE, index=True ) creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( - 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True + 'models.Creative', related_name='publications', on_delete=fields.CASCADE, index=True ) - purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] = fields.ForeignKeyField( - 'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, index=True + placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( + 'models.Placement', related_name='publications', on_delete=fields.CASCADE, index=True ) post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( - 'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True + 'models.Post', related_name='publications', on_delete=fields.SET_NULL, null=True, index=True ) if TYPE_CHECKING: project_id: UUID creative_id: UUID post_id: UUID | None - purchase_channel_id: UUID + placement_id: UUID class Meta: - table = 'placement' + table = 'publication' diff --git a/src/domain/project.py b/src/domain/project.py index b4bb7ea..02127ac 100644 --- a/src/domain/project.py +++ b/src/domain/project.py @@ -4,8 +4,8 @@ from uuid import UUID from tortoise import fields -from .purchase import InviteLinkType from .base import TimestampedModel +from .purchase import InviteLinkType if TYPE_CHECKING: from .channel import Channel diff --git a/src/domain/purchase.py b/src/domain/purchase.py index 238f0b9..0d494db 100644 --- a/src/domain/purchase.py +++ b/src/domain/purchase.py @@ -11,10 +11,7 @@ if TYPE_CHECKING: from .creative import Creative from .project import Project - -class PurchaseStatus(enum.StrEnum): - ACTIVE = 'active' - ARCHIVED = 'archived' +__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType'] class CostType(enum.StrEnum): @@ -22,7 +19,7 @@ class CostType(enum.StrEnum): CPM = 'cpm' -class PurchaseChannelStatus(enum.StrEnum): +class PlacementStatus(enum.StrEnum): PLANNED = 'planned' APPROVED = 'approved' REJECTED = 'rejected' @@ -30,66 +27,71 @@ class PurchaseChannelStatus(enum.StrEnum): COMPLETED = 'completed' -class PurchaseType(enum.StrEnum): +class PlacementType(enum.StrEnum): SELF_PROMO = 'self_promo' STANDARD = 'standard' -class Purchase(TimestampedModel): - status = fields.CharEnumField(PurchaseStatus, default=PurchaseStatus.ACTIVE) - placement_at = fields.DatetimeField(null=True) - payment_at = fields.DatetimeField(null=True) - cost_type = fields.CharEnumField(CostType, null=True, max_length=8) - cost_value = fields.FloatField(null=True) - cost_before_bargain = fields.FloatField(null=True) - purchase_type = fields.CharEnumField(PurchaseType, null=True, max_length=16) - format = fields.TextField(null=True) - comment = fields.TextField(null=True) - - 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 - ) - - if TYPE_CHECKING: - project_id: uuid.UUID - creative_id: uuid.UUID - - class Meta: - table = 'purchase' - indexes = (('project', 'status'),) - - class InviteLinkType(enum.StrEnum): PUBLIC = 'public' # открытая ссылка APPROVAL = 'approval' # с одобрением ботом -class PurchaseChannel(TimestampedModel): - status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED) +class Placement(TimestampedModel): + """Размещение, управляемое пользователем (бывший PurchaseChannel + Purchase)""" + + status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.PLANNED) placement_at = fields.DatetimeField(null=True) + payment_at = fields.DatetimeField(null=True) cost_type = fields.CharEnumField(CostType, null=True, max_length=8) cost_value = fields.FloatField(null=True) cost_before_bargain = fields.FloatField(null=True) + placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16) format = fields.TextField(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 + project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( + 'models.Project', 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 ) channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( - 'models.Channel', related_name='purchase_channels', on_delete=fields.CASCADE, index=True + 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True ) if TYPE_CHECKING: - purchase_id: uuid.UUID + project_id: uuid.UUID + creative_id: uuid.UUID channel_id: uuid.UUID class Meta: - table = 'purchase_channel' - unique_together = (('purchase_id', 'channel_id'),) + table = 'placement' + indexes = (('project', 'status'),) + + +# DEPRECATED: старые модели, будут удалены после миграции + +# class PurchaseStatus(enum.StrEnum): +# ACTIVE = 'active' +# ARCHIVED = 'archived' +# +# class PurchaseChannelStatus(enum.StrEnum): +# PLANNED = 'planned' +# APPROVED = 'approved' +# REJECTED = 'rejected' +# IN_PROGRESS = 'in_progress' +# COMPLETED = 'completed' +# +# class PurchaseType(enum.StrEnum): +# SELF_PROMO = 'self_promo' +# STANDARD = 'standard' +# +# class Purchase(TimestampedModel): +# ... +# +# class PurchaseChannel(TimestampedModel): +# ... diff --git a/src/domain/subscription.py b/src/domain/subscription.py index aeb367b..1f30e99 100644 --- a/src/domain/subscription.py +++ b/src/domain/subscription.py @@ -7,7 +7,7 @@ from tortoise import fields from .base import TimestampedModel if TYPE_CHECKING: - from .placement import Placement + from .placement import Publication from .telegram_user import TelegramUser @@ -21,17 +21,17 @@ class Subscription(TimestampedModel): status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE) unsubscribed_at = fields.DatetimeField(null=True) - placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( - 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True + publication: fields.ForeignKeyRelation['Publication'] = fields.ForeignKeyField( + 'models.Publication', related_name='subscriptions', on_delete=fields.CASCADE, index=True ) telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField( 'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True ) if TYPE_CHECKING: - placement_id: UUID + publication_id: UUID telegram_user_id: UUID class Meta: table = 'subscription' - unique_together = (('placement_id', 'telegram_user_id'),) + unique_together = (('publication_id', 'telegram_user_id'),) diff --git a/src/domain/workspace.py b/src/domain/workspace.py index 87e4854..9969ec1 100644 --- a/src/domain/workspace.py +++ b/src/domain/workspace.py @@ -11,8 +11,8 @@ from .base import TimestampedModel if TYPE_CHECKING: from .channel import Channel from .creative import Creative - from .placement import Placement from .project import Project + from .purchase import Placement from .user import User diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 591dfa7..da2002a 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -17,16 +17,18 @@ __all__ = ( 'GetChannelsInput', 'GetChannelsOutput', 'AttachChannelToWorkspaceInput', - 'PurchaseOutput', - 'PurchaseChannelOutput', - 'PurchaseDetails', - 'PurchaseChannelDetails', + 'PlacementOutput', + 'PlacementDetails', 'CostInfo', - 'CreatePurchaseInput', - 'CreatePurchaseChannelInput', - 'GetPurchasesInput', - 'GetPurchasesOutput', - 'GetPurchaseInput', + 'CreatePlacementsInput', + 'CreatePlacementChannelInput', + 'GetPlacementsInput', + 'GetPlacementsOutput', + 'GetPlacementInput', + 'PublicationOutput', + 'GetPublicationsInput', + 'GetPublicationsOutput', + 'GetPublicationInput', 'CreativeButton', 'CreativeOutput', 'GetCreativesInput', @@ -35,10 +37,6 @@ __all__ = ( 'CreateCreativeInput', 'UpdateCreativeInput', 'DeleteCreativeInput', - 'PlacementOutput', - 'GetPlacementsInput', - 'GetPlacementsOutput', - 'GetPlacementInput', 'PostViewsHistoryOutput', 'GetViewsHistoryInput', 'GetViewsHistoryOutput', @@ -133,7 +131,12 @@ from .creative import ( GetCreativesOutput, UpdateCreativeInput, ) -from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput +from .placement import ( + GetPublicationInput, + GetPublicationsInput, + GetPublicationsOutput, + PublicationOutput, +) from .project import ( ArchiveProjectInput, ChannelBotPermissions, @@ -149,15 +152,13 @@ from .project import ( ) from .purchase import ( CostInfo, - CreatePurchaseChannelInput, - CreatePurchaseInput, - GetPurchaseInput, - GetPurchasesInput, - GetPurchasesOutput, - PurchaseChannelDetails, - PurchaseChannelOutput, - PurchaseDetails, - PurchaseOutput, + CreatePlacementChannelInput, + CreatePlacementsInput, + GetPlacementInput, + GetPlacementsInput, + GetPlacementsOutput, + PlacementDetails, + PlacementOutput, ) from .user import UserOutput from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput diff --git a/src/dto/placement.py b/src/dto/placement.py index 4fc5e77..a6567b9 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -6,28 +6,29 @@ import pydantic from src import domain -class PlacementOutput(pydantic.BaseModel): +class PublicationOutput(pydantic.BaseModel): + """Публикация - фактический пост, мониторимый системой""" + id: uuid.UUID project_id: uuid.UUID project_channel_title: str | None - placement_channel_id: uuid.UUID + placement_channel_id: uuid.UUID # Канал, где размещен пост placement_channel_title: str | None creative_id: uuid.UUID creative_name: str - placement_date: datetime.datetime + placement_date: datetime.datetime # wanted_placement_date cost: float | None comment: str | None ad_post_url: str | None invite_link_type: domain.purchase.InviteLinkType invite_link: str - status: domain.PlacementStatus + status: domain.PublicationStatus subscriptions_count: int - purchase_id: uuid.UUID | None = None - purchase_channel_id: uuid.UUID | None = None + placement_id: uuid.UUID # Ссылка на Placement created_at: datetime.datetime -class GetPlacementsInput(pydantic.BaseModel): +class GetPublicationsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID project_id: uuid.UUID | None = None @@ -36,31 +37,31 @@ class GetPlacementsInput(pydantic.BaseModel): include_archived: bool = False -class GetPlacementsOutput(pydantic.BaseModel): - placements: list[PlacementOutput] +class GetPublicationsOutput(pydantic.BaseModel): + publications: list[PublicationOutput] -class GetPlacementInput(pydantic.BaseModel): - placement_id: uuid.UUID +class GetPublicationInput(pydantic.BaseModel): + publication_id: uuid.UUID user_id: uuid.UUID workspace_id: uuid.UUID -class CreatePlacementInput(pydantic.BaseModel): - purchase_channel_id: uuid.UUID +class CreatePublicationInput(pydantic.BaseModel): + placement_id: uuid.UUID placement_date: datetime.datetime cost: float | None = None comment: str | None = None -class UpdatePlacementInput(pydantic.BaseModel): +class UpdatePublicationInput(pydantic.BaseModel): placement_date: datetime.datetime | None = None cost: float | None = None comment: str | None = None - status: domain.PlacementStatus | None = None + status: domain.PublicationStatus | None = None -class DeletePlacementInput(pydantic.BaseModel): - placement_id: uuid.UUID +class DeletePublicationInput(pydantic.BaseModel): + publication_id: uuid.UUID user_id: uuid.UUID workspace_id: uuid.UUID diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 7081aa5..6fdea9b 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -3,7 +3,7 @@ import uuid import pydantic -from src.domain.purchase import CostType, InviteLinkType, PurchaseChannelStatus, PurchaseStatus, PurchaseType +from src.domain.purchase import CostType, InviteLinkType, PlacementStatus, PlacementType from .channel import ChannelOutput @@ -13,66 +13,55 @@ class CostInfo(pydantic.BaseModel): value: float -class PurchaseDetails(pydantic.BaseModel): +class PlacementDetails(pydantic.BaseModel): placement_at: datetime.datetime | None = None payment_at: datetime.datetime | None = None cost: CostInfo | None = None cost_before_bargain: float | None = None - purchase_type: PurchaseType | None = None + placement_type: PlacementType | None = None format: str | None = None comment: str | None = None -class PurchaseChannelDetails(pydantic.BaseModel): - placement_at: datetime.datetime | None = None - cost: CostInfo | None = None - cost_before_bargain: float | None = None - format: str | None = None - - -class PurchaseChannelOutput(pydantic.BaseModel): +class PlacementOutput(pydantic.BaseModel): id: uuid.UUID - status: PurchaseChannelStatus + status: PlacementStatus comment: str | None = None invite_link: str invite_link_type: InviteLinkType channel: ChannelOutput - details: PurchaseChannelDetails | None = None + details: PlacementDetails | None = None -class PurchaseOutput(pydantic.BaseModel): - id: uuid.UUID - status: PurchaseStatus - creative_id: uuid.UUID - channels: list[PurchaseChannelOutput] - details: PurchaseDetails | None = None +class CreatePlacementChannelInput(pydantic.BaseModel): + """Input для создания одного placement (channel + детали)""" - -class CreatePurchaseChannelInput(pydantic.BaseModel): username: str = pydantic.Field(pattern=r'^[^@]') - status: PurchaseChannelStatus | None = None + status: PlacementStatus | None = None comment: str | None = None - details: PurchaseChannelDetails | None = None + details: PlacementDetails | None = None -class CreatePurchaseInput(pydantic.BaseModel): +class CreatePlacementsInput(pydantic.BaseModel): + """Input для создания нескольких placements (бывший CreatePurchaseInput)""" + creative_id: uuid.UUID - channels: list[CreatePurchaseChannelInput] - details: PurchaseDetails | None = None + channels: list[CreatePlacementChannelInput] + details: PlacementDetails | None = None # Общие детали для всех placements -class GetPurchasesInput(pydantic.BaseModel): +class GetPlacementsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID project_id: uuid.UUID -class GetPurchasesOutput(pydantic.BaseModel): - purchases: list[PurchaseOutput] +class GetPlacementsOutput(pydantic.BaseModel): + placements: list[PlacementOutput] -class GetPurchaseInput(pydantic.BaseModel): +class GetPlacementInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID project_id: uuid.UUID - purchase_id: uuid.UUID + placement_id: uuid.UUID diff --git a/src/dto/views.py b/src/dto/views.py index 193d032..5c647a8 100644 --- a/src/dto/views.py +++ b/src/dto/views.py @@ -15,9 +15,9 @@ class PostViewsHistoryOutput(pydantic.BaseModel): class GetViewsHistoryInput(pydantic.BaseModel): - """Получить историю просмотров для закупа.""" + """Получить историю просмотров для публикации.""" - placement_id: uuid.UUID + publication_id: uuid.UUID user_id: uuid.UUID workspace_id: uuid.UUID from_date: datetime.datetime | None = None # Фильтр: с какой даты diff --git a/src/main.py b/src/main.py index 554c8bb..1795bed 100644 --- a/src/main.py +++ b/src/main.py @@ -16,7 +16,7 @@ from src.adapter.s3 import S3 from src.adapter.telegram_bot import TelegramBot from src.config import settings from src.controller.http_v1 import api_router -from src.controller.worker.fetch_placement_post import FetchPlacementPostWorker +from src.controller.worker.fetch_placement_post import FetchPublicationPostWorker from src.usecase import Usecase @@ -24,11 +24,11 @@ from src.usecase import Usecase async def lifespan(_: FastAPI) -> AsyncGenerator[None]: await postgres.connect() await s3.connect() - await fetch_placement_post_worker.start() + await fetch_publication_post_worker.start() yield - await fetch_placement_post_worker.stop() + await fetch_publication_post_worker.stop() await s3.close() await postgres.close() @@ -51,7 +51,7 @@ usecase = Usecase( ) deps.set_usecase(usecase) -fetch_placement_post_worker = FetchPlacementPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5)) +fetch_publication_post_worker = FetchPublicationPostWorker(config=WorkerConfig(INTERVAL_SECONDS=5)) app = FastAPI( lifespan=lifespan, diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 5660cbf..3934a98 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -26,9 +26,9 @@ 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.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.fetch_publication_post_cycle import fetch_publication_post_cycle +from .placement.get_publication import get_publication +from .placement.get_publications import get_publications from .project.archive_project import archive_project from .project.delete_project import delete_project from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id @@ -38,9 +38,9 @@ from .project.move_project_to_workspace import move_project_to_workspace 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.create_purchase import create_purchase -from .purchase.get_purchase import get_purchase -from .purchase.get_purchases import get_purchases +from .purchase.create_placements import create_placements +from .purchase.get_placement import get_placement_user +from .purchase.get_placements import get_placements from .subscription.handle_subscription import handle_subscription from .subscription.handle_unsubscription import handle_unsubscription from .views.get_views_history import get_views_history @@ -171,43 +171,18 @@ class Database(typing.Protocol): async def check_channel_exists_in_workspace(self, channel_id: UUID, workspace_id: UUID) -> bool: ... - async def get_active_purchase(self, project_id: UUID, creative_id: UUID) -> domain.Purchase | None: ... + # Placement methods (user-managed, бывший PurchaseChannel) + async def create_placement(self, placement: domain.Placement) -> None: ... - async def get_purchase(self, workspace_id: UUID, purchase_id: UUID) -> domain.Purchase | None: ... + async def create_placements(self, placements: list[domain.Placement]) -> None: ... - async def get_project_purchases(self, workspace_id: UUID, project_id: UUID) -> list[domain.Purchase]: ... + async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ... - async def create_purchase(self, purchase: domain.Purchase) -> None: ... + async def get_project_placements( + self, workspace_id: UUID, project_id: UUID, include_archived: bool = False + ) -> list[domain.Placement]: ... - async def get_purchase_channels(self, purchase_id: UUID) -> list[domain.PurchaseChannel]: ... - - async def get_purchase_channel( - self, purchase_channel_id: UUID, purchase_id: UUID - ) -> domain.PurchaseChannel | None: ... - - async def get_purchase_channel_by_id(self, purchase_channel_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, - purchase_id: UUID, - channel_id: UUID, - invite_link: str, - invite_link_type: domain.purchase.InviteLinkType, - *, - status: domain.PurchaseChannelStatus | None = None, - comment: str | None = None, - placement_at: datetime.datetime | None = None, - cost_type: domain.purchase.CostType | None = None, - cost_value: float | None = None, - cost_before_bargain: float | None = None, - format: str | None = None, - ) -> domain.PurchaseChannel: ... - - async def update_purchase_channel(self, purchase_channel: domain.PurchaseChannel) -> None: ... - - async def remove_channel_from_purchase(self, purchase_id: UUID, channel_id: UUID) -> None: ... + async def update_placement_user(self, placement: domain.Placement) -> None: ... async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ... @@ -223,9 +198,10 @@ class Database(typing.Protocol): async def delete_creative(self, creative_id: UUID) -> None: ... - async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ... + # Publication methods (system-managed, бывший Placement) + async def get_publication(self, workspace_id: UUID, publication_id: UUID) -> domain.Publication | None: ... - async def get_workspace_placements( + async def get_workspace_publications( self, workspace_id: UUID, project_id: UUID | None = None, @@ -235,27 +211,28 @@ class Database(typing.Protocol): allowed_project_ids: set[UUID] | None = None, date_from: datetime.datetime | None = None, date_to: datetime.datetime | None = None, - ) -> list[domain.Placement]: ... + ) -> list[domain.Publication]: ... - async def create_placement(self, placement: domain.Placement) -> None: ... + async def create_publication(self, publication: domain.Publication) -> None: ... - async def update_placement(self, placement: domain.Placement) -> None: ... + async def update_publication(self, publication: domain.Publication) -> None: ... - async def delete_placement(self, placement_id: UUID) -> None: ... + async def delete_publication(self, publication_id: UUID) -> None: ... - async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ... + async def get_publication_by_invite_link(self, invite_link: str) -> domain.Publication | None: ... + # Subscription methods async def create_subscription(self, subscription: domain.Subscription) -> None: ... - async def get_subscription_by_subscriber_and_placement( - self, telegram_user_id: UUID, placement_id: UUID + async def get_subscription_by_subscriber_and_publication( + self, telegram_user_id: UUID, publication_id: UUID ) -> domain.Subscription | None: ... async def update_subscription(self, subscription: domain.Subscription) -> None: ... - async def get_subscriptions_for_placements( + async def get_subscriptions_for_publications( self, - placement_ids: list[UUID], + publication_ids: list[UUID], *, date_from: datetime.datetime | None = None, date_to: datetime.datetime | None = None, @@ -270,15 +247,17 @@ class Database(typing.Protocol): ) -> domain.Subscription | None: ... # Count methods - async def count_placements_by_creative(self, creative_id: UUID) -> int: ... + async def count_publications_by_creative(self, creative_id: UUID) -> int: ... - async def count_subscriptions_by_placement(self, placement_id: UUID) -> int: ... + async def count_subscriptions_by_publication(self, publication_id: UUID) -> int: ... - async def count_placements_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ... + async def count_publications_by_creative_batch(self, creative_ids: list[UUID]) -> dict[UUID, int]: ... - async def count_subscriptions_by_placement_batch(self, placement_ids: list[UUID]) -> dict[UUID, int]: ... + async def count_subscriptions_by_publication_batch( + self, publication_ids: list[UUID] + ) -> dict[UUID, int]: ... - async def has_placements_for_creative(self, creative_id: UUID) -> bool: ... + async def has_publications_for_creative(self, creative_id: UUID) -> bool: ... # Post methods async def create_post(self, post: domain.Post) -> None: ... @@ -409,15 +388,6 @@ class Usecase: return workspace - 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 - - purchase = domain.Purchase(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 get_jwt_by_telegram_id = get_jwt_by_telegram_id @@ -434,17 +404,20 @@ class Usecase: get_channels = get_channels get_channel = get_channel attach_channel_to_workspace = attach_channel_to_workspace - create_purchase = create_purchase - get_purchases = get_purchases - get_purchase = get_purchase + # Placement (user-managed) use cases + create_placements = create_placements + get_placements = get_placements + get_placement_user = get_placement_user + # Creative use cases get_creatives = get_creatives get_creative = get_creative create_creative = create_creative update_creative = update_creative delete_creative = delete_creative - get_placements = get_placements - get_placement = get_placement - fetch_placement_post_cycle = fetch_placement_post_cycle + # Publication (system-managed) use cases + get_publications = get_publications + get_publication = get_publication + fetch_publication_post_cycle = fetch_publication_post_cycle handle_subscription = handle_subscription handle_unsubscription = handle_unsubscription get_views_history = get_views_history diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py index 7c5dce3..7677ba2 100644 --- a/src/usecase/analytics/get_channel_analytics.py +++ b/src/usecase/analytics/get_channel_analytics.py @@ -25,32 +25,24 @@ async def get_channel_analytics( if not project: raise domain.ProjectNotFound(input.project_id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) - 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 - if allowed_project_ids is None: - # Получить каналы через Projects - projects = await self.database.get_workspace_projects(input.workspace_id) - channels = [p.channel for p in projects] - else: - channels = [] - placements = await self.database.get_workspace_placements( + placements = await self.database.get_workspace_publications( input.workspace_id, input.project_id, include_archived=False, allowed_project_ids=allowed_project_filter, ) - if input.project_id is None and allowed_project_ids is not None: - channel_map: dict[UUID, domain.Channel] = {} - for placement in placements: - purchase_channel = placement.purchase_channel - if purchase_channel and purchase_channel.channel: - channel_map[purchase_channel.channel_id] = purchase_channel.channel - channels = list(channel_map.values()) + # Collect unique channels from placements + channel_map: dict[UUID, domain.Channel] = {} + for publication in placements: + placement = publication.placement + if placement and placement.channel: + channel_map[placement.channel_id] = placement.channel + channels = list(channel_map.values()) @dataclass class ChannelStats: @@ -67,22 +59,22 @@ async def get_channel_analytics( # Batch fetch subscriptions counts placement_ids = [p.id for p in placements] - subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids) - for p in placements: - purchase_channel = p.purchase_channel - if not purchase_channel: + for publication in placements: + placement = publication.placement + if not placement: continue - stats = channel_stats.get(purchase_channel.channel_id) + stats = channel_stats.get(placement.channel_id) if stats is None: continue - stats.total_cost += p.cost if p.cost is not None else 0 - stats.total_subscriptions += subscriptions_counts.get(p.id, 0) + stats.total_cost += publication.cost if publication.cost is not None else 0 + stats.total_subscriptions += subscriptions_counts.get(publication.id, 0) # Get views from batch data - if p.post and p.post.id in views_map: - views_count = views_map[p.post.id][0] + if publication.post and publication.post.id in views_map: + views_count = views_map[publication.post.id][0] stats.total_views += views_count stats.placements_count += 1 diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 8e8ce34..d465abe 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -33,7 +33,7 @@ async def get_creatives_analytics( include_archived=False, allowed_project_ids=allowed_project_ids, ) - placements = await self.database.get_workspace_placements( + placements = await self.database.get_workspace_publications( input.workspace_id, input.project_id, include_archived=False, @@ -55,7 +55,7 @@ async def get_creatives_analytics( # Batch fetch subscriptions counts placement_ids = [p.id for p in placements] - subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids) for p in placements: stats = creative_stats.get(p.creative_id) diff --git a/src/usecase/analytics/get_overview_analytics.py b/src/usecase/analytics/get_overview_analytics.py index d8e1f44..a7e2263 100644 --- a/src/usecase/analytics/get_overview_analytics.py +++ b/src/usecase/analytics/get_overview_analytics.py @@ -55,7 +55,7 @@ async def get_overview_analytics( period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1) previous_period_start = input.date_from - period_length - placements = await self.database.get_workspace_placements( + placements = await self.database.get_workspace_publications( input.workspace_id, project_id=input.project_id, include_archived=False, @@ -64,18 +64,18 @@ async def get_overview_analytics( date_to=input.date_to, ) - current_placements: list[domain.Placement] = [] - previous_placements: list[domain.Placement] = [] + current_placements: list[domain.Publication] = [] + previous_placements: list[domain.Publication] = [] - for placement in placements: - placement_date = placement.wanted_placement_date + for publication in placements: + placement_date = publication.wanted_placement_date if placement_date >= input.date_from and placement_date <= input.date_to: - current_placements.append(placement) + current_placements.append(publication) elif placement_date >= previous_period_start and placement_date < input.date_from: - previous_placements.append(placement) + previous_placements.append(publication) placement_ids = [p.id for p in placements] - subscriptions = await self.database.get_subscriptions_for_placements( + subscriptions = await self.database.get_subscriptions_for_publications( placement_ids, date_from=previous_period_start, date_to=input.date_to ) @@ -89,30 +89,30 @@ async def get_overview_analytics( continue if created_at >= input.date_from and created_at <= input.date_to: - subs_per_placement_current[sub.placement_id] += 1 + subs_per_placement_current[sub.publication_id] += 1 subs_per_day_current[created_at.date()] += 1 elif created_at >= previous_period_start and created_at < input.date_from: - subs_per_placement_previous[sub.placement_id] += 1 + subs_per_placement_previous[sub.publication_id] += 1 post_ids = [p.post.id for p in placements if p.post] views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} def _aggregate_totals( - placement_list: list[domain.Placement], subs_per_placement: dict[UUID, int] + placement_list: list[domain.Publication], subs_per_placement: dict[UUID, int] ) -> tuple[float, int, int]: total_cost = 0.0 total_subscriptions = 0 total_views = 0 - for placement in placement_list: - if placement.cost is not None: - total_cost += placement.cost + for publication in placement_list: + if publication.cost is not None: + total_cost += publication.cost - subs = subs_per_placement.get(placement.id, 0) + subs = subs_per_placement.get(publication.id, 0) total_subscriptions += subs - if placement.post and placement.post.id in views_map: - total_views += views_map[placement.post.id][0] + if publication.post and publication.post.id in views_map: + total_views += views_map[publication.post.id][0] return total_cost, total_subscriptions, total_views @@ -130,20 +130,20 @@ async def get_overview_analytics( project_spending_map: dict[UUID, float] = defaultdict(float) project_meta: dict[UUID, domain.Project] = {} - for placement in current_placements: - placement_day = placement.wanted_placement_date.date() - project_meta[placement.project_id] = placement.project + for publication in current_placements: + placement_day = publication.wanted_placement_date.date() + project_meta[publication.project_id] = publication.project - cost_value = placement.cost or 0.0 + cost_value = publication.cost or 0.0 cost_per_day[placement_day] += cost_value - project_spending_map[placement.project_id] += cost_value - subs = subs_per_placement_current.get(placement.id, 0) + project_spending_map[publication.project_id] += cost_value + subs = subs_per_placement_current.get(publication.id, 0) - purchase_channel = placement.purchase_channel - if purchase_channel: - channel_id = purchase_channel.channel_id + placement = publication.placement + if placement: + channel_id = placement.channel_id if channel_id not in channel_stats: - channel_stats[channel_id] = ChannelAggregate(channel=purchase_channel.channel) + channel_stats[channel_id] = ChannelAggregate(channel=placement.channel) channel_stats[channel_id].total_cost += cost_value channel_stats[channel_id].total_subs += subs diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 28f0713..350ad80 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -37,7 +37,7 @@ async def get_placements_analytics( context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) allowed_project_ids = None - placements = await self.database.get_workspace_placements( + placements = await self.database.get_workspace_publications( input.workspace_id, input.project_id, include_archived=False, @@ -50,15 +50,15 @@ async def get_placements_analytics( # Batch fetch subscriptions counts placement_ids = [p.id for p in placements] - subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids) return [ dto.PlacementAnalyticsOutput( id=p.id, project_id=p.project_id, project_channel_title=p.project.channel.title, - placement_channel_id=p.purchase_channel.channel_id, - placement_channel_title=p.purchase_channel.channel.title, + placement_channel_id=p.placement.channel_id, + placement_channel_title=p.placement.channel.title, creative_id=p.creative_id, creative_name=p.creative.name, placement_date=p.wanted_placement_date, diff --git a/src/usecase/analytics/get_projects_analytics.py b/src/usecase/analytics/get_projects_analytics.py index 98a545f..f671107 100644 --- a/src/usecase/analytics/get_projects_analytics.py +++ b/src/usecase/analytics/get_projects_analytics.py @@ -60,20 +60,20 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s raise ValueError('Invalid date grouping') -def _get_grouping_date(placement: domain.Placement, date_grouping: dto.DateGroupingType) -> datetime.datetime: +def _get_grouping_date(publication: domain.Publication, date_grouping: dto.DateGroupingType) -> datetime.datetime: match date_grouping: case dto.DateGroupingType.PLACEMENT_DATE: - return placement.wanted_placement_date + return publication.wanted_placement_date case dto.DateGroupingType.PURCHASE_DATE: - if placement.purchase_channel and placement.purchase_channel.purchase: - return placement.purchase_channel.purchase.created_at - return placement.wanted_placement_date # Fallback + if publication.placement: + return publication.placement.created_at + return publication.wanted_placement_date # Fallback case dto.DateGroupingType.LINK_DATE: - if placement.purchase_channel: - return placement.purchase_channel.created_at - return placement.wanted_placement_date # Fallback + if publication.placement: + return publication.placement.created_at + return publication.wanted_placement_date # Fallback case _: - return placement.wanted_placement_date + return publication.wanted_placement_date @dataclass @@ -96,7 +96,7 @@ def _calculate_metrics( all_metrics = requested_metrics is None or len(requested_metrics) == 0 def should_include(metric: dto.ProjectMetrics) -> bool: - return all_metrics or metric in requested_metrics + return all_metrics or (requested_metrics is not None and metric in requested_metrics) metrics = dto.ProjectMetricsData() @@ -183,7 +183,7 @@ async def get_projects_analytics( else: allowed_project_ids = set(input.project_ids) - placements = await self.database.get_workspace_placements( + placements = await self.database.get_workspace_publications( input.workspace_id, project_id=None, include_archived=False, @@ -194,28 +194,28 @@ async def get_projects_analytics( # Фильтрация по датам на основе date_grouping filtered_placements = [] - for placement in placements: - grouping_date = _get_grouping_date(placement, input.date_grouping) + for publication in placements: + grouping_date = _get_grouping_date(publication, input.date_grouping) if input.date_from and grouping_date < input.date_from: continue if input.date_to and grouping_date > input.date_to: continue - filtered_placements.append(placement) + filtered_placements.append(publication) # Batch fetch views data post_ids = [p.post.id for p in filtered_placements if p.post] views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {} # Batch fetch subscriptions counts - placement_ids = [p.id for p in filtered_placements] - subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + publication_ids = [p.id for p in filtered_placements] + subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids) # Группировка по периодам period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics) total_metrics = PeriodMetrics() - for placement in filtered_placements: - grouping_date = _get_grouping_date(placement, input.date_grouping) + for publication in filtered_placements: + grouping_date = _get_grouping_date(publication, input.date_grouping) period = _format_period(grouping_date, input.grouping) pd = period_data[period] @@ -223,29 +223,29 @@ async def get_projects_analytics( pd.purchases_count += 1 total_metrics.purchases_count += 1 - cost = placement.cost or 0.0 + cost = publication.cost or 0.0 pd.total_cost += cost total_metrics.total_cost += cost - subs_count = subscriptions_counts.get(placement.id, 0) + subs_count = subscriptions_counts.get(publication.id, 0) pd.total_subscriptions += subs_count pd.clicks_count += subs_count total_metrics.total_subscriptions += subs_count total_metrics.clicks_count += subs_count views_count = 0 - if placement.post and placement.post.id in views_map: - views_count = views_map[placement.post.id][0] + if publication.post and publication.post.id in views_map: + views_count = views_map[publication.post.id][0] pd.total_views += views_count pd.reach_volume += views_count total_metrics.total_views += views_count total_metrics.reach_volume += views_count # Расчет скидок - purchase_channel = placement.purchase_channel - if purchase_channel and purchase_channel.cost_before_bargain and purchase_channel.cost_before_bargain > cost: - discount = purchase_channel.cost_before_bargain - cost - discount_percent = (discount / purchase_channel.cost_before_bargain) * 100 if purchase_channel.cost_before_bargain > 0 else 0.0 + placement = publication.placement + if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost: + discount = placement.cost_before_bargain - cost + discount_percent = (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0 pd.total_discounts += discount pd.discount_count += 1 diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index 689fffe..a3a0a44 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -46,7 +46,7 @@ async def get_spending_analytics( context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) allowed_project_ids = None - placements = await self.database.get_workspace_placements( + placements = await self.database.get_workspace_publications( input.workspace_id, input.project_id, include_archived=False, @@ -76,7 +76,7 @@ async def get_spending_analytics( # Batch fetch subscriptions counts placement_ids = [p.id for p in filtered] - subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) + subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(placement_ids) # Группировка по периодам period_data: dict[str, PeriodData] = defaultdict(PeriodData) diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py index a73df67..c590368 100644 --- a/src/usecase/creative/delete_creative.py +++ b/src/usecase/creative/delete_creative.py @@ -22,9 +22,9 @@ async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> No context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id) - has_placements = await self.database.has_placements_for_creative(creative.id) - if has_placements: - log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id) + has_publications = await self.database.has_publications_for_creative(creative.id) + if has_publications: + log.warning('Creative %s is used in publications and cannot be deleted', input.creative_id) raise domain.CreativeInUse(input.creative_id) media_key = creative.media_s3_key diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index 120b37f..90cef7b 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -20,7 +20,7 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id) - placements_count = await self.database.count_placements_by_creative(creative.id) + placements_count = await self.database.count_publications_by_creative(creative.id) return dto.CreativeOutput( id=creative.id, diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index 7e75904..79516ab 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -25,7 +25,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d ) creative_ids = [c.id for c in creatives] - placements_counts = await self.database.count_placements_by_creative_batch(creative_ids) + placements_counts = await self.database.count_publications_by_creative_batch(creative_ids) return [ dto.CreativeOutput( diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index 6cf5127..d1ca369 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -69,7 +69,7 @@ async def update_creative( await self.database.update_creative(creative) - placements_count = await self.database.count_placements_by_creative(creative.id) + placements_count = await self.database.count_publications_by_creative(creative.id) return dto.CreativeOutput( id=creative.id, diff --git a/src/usecase/placement/__init__.py b/src/usecase/placement/__init__.py new file mode 100644 index 0000000..35d32d4 --- /dev/null +++ b/src/usecase/placement/__init__.py @@ -0,0 +1,5 @@ +from .fetch_publication_post_cycle import fetch_publication_post_cycle +from .get_publication import get_publication +from .get_publications import get_publications + +__all__ = ['fetch_publication_post_cycle', 'get_publication', 'get_publications'] diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py deleted file mode 100644 index cf69361..0000000 --- a/src/usecase/placement/fetch_placement_post_cycle.py +++ /dev/null @@ -1,63 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from src import domain - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) -> None: - log.debug('Starting fetch_placement_post_cycle') - - purchase_channels = ( - await domain.PurchaseChannel.filter(purchase__status=domain.PurchaseStatus.ACTIVE) - .prefetch_related('channel', 'purchase') - .all() - ) - - if not purchase_channels: - log.debug('No active purchase channels found') - return - - created_count = 0 - - 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 - - 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, - project_id=purchase_channel.purchase.project_id, - creative_id=purchase_channel.purchase.creative_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.debug('Fetch placement post cycle completed. Created %s placements', created_count) diff --git a/src/usecase/placement/fetch_publication_post_cycle.py b/src/usecase/placement/fetch_publication_post_cycle.py new file mode 100644 index 0000000..07d943f --- /dev/null +++ b/src/usecase/placement/fetch_publication_post_cycle.py @@ -0,0 +1,64 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def fetch_publication_post_cycle(self: 'Usecase', interval_seconds: int) -> None: + """Находит посты в каналах по invite_link из Placement и создает Publication""" + log.debug('Starting fetch_publication_post_cycle') + + # Получаем все approved Placements с invite_link + placements = ( + await domain.Placement.filter( + status__in=[domain.PlacementStatus.APPROVED, domain.PlacementStatus.IN_PROGRESS] + ) + .prefetch_related('channel', 'project') + .all() + ) + + if not placements: + log.debug('No active placements found') + return + + created_count = 0 + + for placement in placements: + if placement.channel is None or placement.invite_link is None: + log.warning('Placement %s missing channel or invite_link, skipping', placement.id) + continue + + # Ищем посты в канале, содержащие invite_link из placement + posts = await domain.Post.filter( + channel_id=placement.channel_id, + text__contains=placement.invite_link, + deleted_from_channel_at__isnull=True, + ).all() + + for post in posts: + # Проверяем, не создана ли уже публикация для этого поста + existing = await domain.Publication.filter(post_id=post.id).first() + if existing: + continue + + publication = domain.Publication( + placement_id=placement.id, + post_id=post.id, + status=domain.PublicationStatus.ACTIVE, + ) + + await self.database.create_publication(publication) + created_count += 1 + log.info( + 'Created publication %s for placement %s from post %s', + publication.id, + placement.id, + post.id, + ) + + log.debug('Fetch publication post cycle completed. Created %s publications', created_count) diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py deleted file mode 100644 index 9fd10fa..0000000 --- a/src/usecase/placement/get_placement.py +++ /dev/null @@ -1,51 +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_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: - context = await self.ensure_workspace_permission( - input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ - ) - - placement = await self.database.get_placement(input.workspace_id, input.placement_id) - if not placement: - log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) - raise domain.PlacementNotFound(input.placement_id) - - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) - - ad_post_url = placement.post.url if placement.post else None - purchase_channel = placement.purchase_channel - if not purchase_channel or not purchase_channel.channel: - log.warning('Placement %s missing purchase channel data', placement.id) - raise domain.PurchaseChannelNotFound() - - subscriptions_count = await self.database.count_subscriptions_by_placement(placement.id) - - return dto.PlacementOutput( - id=placement.id, - project_id=placement.project_id, - project_channel_title=placement.project.channel.title, - placement_channel_id=purchase_channel.channel_id, - placement_channel_title=purchase_channel.channel.title, - creative_id=placement.creative_id, - creative_name=placement.creative.name, - placement_date=placement.wanted_placement_date, - cost=placement.cost, - comment=placement.comment, - ad_post_url=ad_post_url, - invite_link_type=purchase_channel.invite_link_type, - invite_link=purchase_channel.invite_link, - status=placement.status, - subscriptions_count=subscriptions_count, - purchase_id=purchase_channel.purchase_id, - purchase_channel_id=purchase_channel.id, - created_at=placement.created_at, - ) diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py deleted file mode 100644 index 0f1eb1e..0000000 --- a/src/usecase/placement/get_placements.py +++ /dev/null @@ -1,66 +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_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list[dto.PlacementOutput]: - context = await self.ensure_workspace_permission( - input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ - ) - - allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ) - - if input.project_id is not None: - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id) - allowed_project_ids = None - - placements = await self.database.get_workspace_placements( - input.workspace_id, - project_id=input.project_id, - placement_channel_id=input.placement_channel_id, - creative_id=input.creative_id, - include_archived=input.include_archived, - allowed_project_ids=allowed_project_ids, - ) - - placement_ids = [p.id for p in placements] - subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) - - placement_outputs = [] - for placement in placements: - ad_post_url = placement.post.url if placement.post else None - purchase_channel = placement.purchase_channel - if not purchase_channel or not purchase_channel.channel: - log.warning('Placement %s missing purchase channel data', placement.id) - continue - - placement_outputs.append( - dto.PlacementOutput( - id=placement.id, - project_id=placement.project_id, - project_channel_title=placement.project.channel.title, - placement_channel_id=purchase_channel.channel_id, - placement_channel_title=purchase_channel.channel.title, - creative_id=placement.creative_id, - creative_name=placement.creative.name, - placement_date=placement.wanted_placement_date, - cost=placement.cost, - comment=placement.comment, - ad_post_url=ad_post_url, - invite_link_type=purchase_channel.invite_link_type, - invite_link=purchase_channel.invite_link, - status=placement.status, - subscriptions_count=subscriptions_counts.get(placement.id, 0), - purchase_id=purchase_channel.purchase_id, - purchase_channel_id=purchase_channel.id, - created_at=placement.created_at, - ) - ) - - return placement_outputs diff --git a/src/usecase/placement/get_publication.py b/src/usecase/placement/get_publication.py new file mode 100644 index 0000000..c5738fc --- /dev/null +++ b/src/usecase/placement/get_publication.py @@ -0,0 +1,55 @@ +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_publication(self: 'Usecase', input: dto.GetPublicationInput) -> dto.PublicationOutput: + """Получить одну публикацию по ID (system-managed post)""" + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + publication = await self.database.get_publication(input.workspace_id, input.publication_id) + if not publication: + log.warning('Publication %s not found for user %s', input.publication_id, input.user_id) + raise domain.PublicationNotFound(input.publication_id) + + placement = publication.placement + if not placement: + log.error('Publication %s missing placement data', publication.id) + raise domain.PlacementNotFound(publication.placement_id) + + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + + if not placement.channel or not placement.project or not placement.creative: + log.error('Publication %s placement missing related data', publication.id) + raise domain.PlacementNotFound(placement.id) + + ad_post_url = publication.post.url if publication.post else None + subscriptions_count = await self.database.count_subscriptions_by_publication(publication.id) + + return dto.PublicationOutput( + id=publication.id, + project_id=placement.project_id, + project_channel_title=placement.project.channel.title if placement.project.channel else None, + placement_channel_id=placement.channel_id, + placement_channel_title=placement.channel.title, + creative_id=placement.creative_id, + creative_name=placement.creative.name, + placement_date=placement.placement_at or publication.created_at, + cost=placement.cost_value, + comment=placement.comment, + ad_post_url=ad_post_url, + invite_link_type=placement.invite_link_type, + invite_link=placement.invite_link, + status=publication.status, + subscriptions_count=subscriptions_count, + placement_id=placement.id, + created_at=publication.created_at, + ) diff --git a/src/usecase/placement/get_publications.py b/src/usecase/placement/get_publications.py new file mode 100644 index 0000000..a103729 --- /dev/null +++ b/src/usecase/placement/get_publications.py @@ -0,0 +1,71 @@ +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_publications(self: 'Usecase', input: dto.GetPublicationsInput) -> dto.GetPublicationsOutput: + """Получить список публикаций (system-managed posts)""" + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ) + + if input.project_id is not None: + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id) + allowed_project_ids = None + + publications = await self.database.get_workspace_publications( + input.workspace_id, + project_id=input.project_id, + placement_channel_id=input.placement_channel_id, + creative_id=input.creative_id, + include_archived=input.include_archived, + allowed_project_ids=allowed_project_ids, + ) + + publication_ids = [p.id for p in publications] + subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids) + + publication_outputs = [] + for publication in publications: + placement = publication.placement + if not placement: + log.warning('Publication %s missing placement data', publication.id) + continue + + if not placement.channel or not placement.project or not placement.creative: + log.warning('Publication %s placement missing related data', publication.id) + continue + + ad_post_url = publication.post.url if publication.post else None + + publication_outputs.append( + dto.PublicationOutput( + id=publication.id, + project_id=placement.project_id, + project_channel_title=placement.project.channel.title if placement.project.channel else None, + placement_channel_id=placement.channel_id, + placement_channel_title=placement.channel.title, + creative_id=placement.creative_id, + creative_name=placement.creative.name, + placement_date=placement.placement_at or publication.created_at, + cost=placement.cost_value, + comment=placement.comment, + ad_post_url=ad_post_url, + invite_link_type=placement.invite_link_type, + invite_link=placement.invite_link, + status=publication.status, + subscriptions_count=subscriptions_counts.get(publication.id, 0), + placement_id=placement.id, + created_at=publication.created_at, + ) + ) + + return dto.GetPublicationsOutput(publications=publication_outputs) diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py index dffc0a7..585d7e4 100644 --- a/src/usecase/project/archive_project.py +++ b/src/usecase/project/archive_project.py @@ -1,4 +1,3 @@ -import uuid from typing import TYPE_CHECKING from src import domain, dto diff --git a/src/usecase/project/update_project_permissions.py b/src/usecase/project/update_project_permissions.py index 33af326..3841387 100644 --- a/src/usecase/project/update_project_permissions.py +++ b/src/usecase/project/update_project_permissions.py @@ -22,7 +22,7 @@ async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPe log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions') return if user.telegram_user is None: - log.warning(f'User %s missing telegram profile when updating permissions', user.id) + log.warning('User %s missing telegram profile when updating permissions', user.id) return project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) diff --git a/src/usecase/purchase/__init__.py b/src/usecase/purchase/__init__.py new file mode 100644 index 0000000..5cd09fc --- /dev/null +++ b/src/usecase/purchase/__init__.py @@ -0,0 +1,5 @@ +from .create_placements import create_placements +from .get_placement import get_placement_user +from .get_placements import get_placements + +__all__ = ['create_placements', 'get_placement_user', 'get_placements'] diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py new file mode 100644 index 0000000..7a2e881 --- /dev/null +++ b/src/usecase/purchase/create_placements.py @@ -0,0 +1,148 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None: + if cost_type is None or cost_value is None: + return None + return dto.CostInfo(type=cost_type, value=cost_value) + + +def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetails | None: + details = dto.PlacementDetails( + placement_at=placement.placement_at, + payment_at=placement.payment_at, + cost=_build_cost_info(placement.cost_type, placement.cost_value), + cost_before_bargain=placement.cost_before_bargain, + placement_type=placement.placement_type, + format=placement.format, + comment=placement.comment, + ) + if details.model_dump(exclude_none=True): + return details + return None + + +def _build_placement_output(placement: domain.Placement) -> dto.PlacementOutput: + channel = placement.channel + if channel is None: + log.error('Placement %s has no channel prefetched', placement.id) + raise ValueError(f'Placement {placement.id} has no channel') + + return dto.PlacementOutput( + id=placement.id, + status=placement.status, + comment=placement.comment, + invite_link=placement.invite_link, + invite_link_type=placement.invite_link_type, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), + details=_build_placement_details(placement), + ) + + +async def create_placements( + self: 'Usecase', + project_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.CreatePlacementsInput, +) -> dto.GetPlacementsOutput: + """Create multiple placements for different channels (bulk creation, бывший create_purchase)""" + 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) + + 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.InviteLinkType.APPROVAL + + details = input.details + placements: list[domain.Placement] = [] + + for channel_input in input.channels: + channel = await self.database.get_channel(username=channel_input.username) + + if not channel: + parser_response = await self.parser.fetch_telegram_channel(channel_input.username) + if not parser_response: + raise domain.TelegramChannelNotFound(channel_input.username) + + channel = domain.Channel( + username=parser_response.username, + telegram_id=parser_response.telegram_id, + title=parser_response.title, + ) + await self.database.create_channel(channel) + log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id) + + invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + log.info( + 'Created invite link for placement channel_telegram_id=%s requires_approval=%s invite_link=%s', + project.channel.telegram_id, + requires_approval, + invite_link, + ) + + # Объединяем общие детали с деталями конкретного канала + channel_details = channel_input.details + placement = domain.Placement( + project_id=project.id, + creative_id=creative.id, + channel_id=channel.id, + invite_link=invite_link, + invite_link_type=invite_link_type, + status=channel_input.status or domain.PlacementStatus.PLANNED, + comment=channel_input.comment, + # Приоритет: channel details > общие details + placement_at=(channel_details.placement_at if channel_details else None) or ( + details.placement_at if details else None + ), + payment_at=details.payment_at if details else None, + cost_type=(channel_details.cost.type if channel_details and channel_details.cost else None) or ( + details.cost.type if details and details.cost else None + ), + cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None) or ( + details.cost.value if details and details.cost else None + ), + cost_before_bargain=(channel_details.cost_before_bargain if channel_details else None) or ( + details.cost_before_bargain if details else None + ), + placement_type=details.placement_type if details else None, + format=(channel_details.format if channel_details else None) or (details.format if details else None), + ) + + await self.database.create_placement(placement) + placement.channel = channel + placements.append(placement) + + log.info( + 'Created %s placements for project %s (creative %s)', + len(placements), + project.id, + creative.id, + ) + + return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements]) diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py deleted file mode 100644 index 12c52f7..0000000 --- a/src/usecase/purchase/create_purchase.py +++ /dev/null @@ -1,173 +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__) - - -def _build_cost_info(cost_type: domain.purchase.CostType | None, cost_value: float | None) -> dto.CostInfo | None: - if cost_type is None or cost_value is None: - return None - return dto.CostInfo(type=cost_type, value=cost_value) - - -def _build_purchase_details(purchase: domain.Purchase) -> dto.PurchaseDetails | None: - details = dto.PurchaseDetails( - placement_at=purchase.placement_at, - payment_at=purchase.payment_at, - cost=_build_cost_info(purchase.cost_type, purchase.cost_value), - cost_before_bargain=purchase.cost_before_bargain, - purchase_type=purchase.purchase_type, - format=purchase.format, - comment=purchase.comment, - ) - if details.model_dump(exclude_none=True): - return details - return None - - -def _build_purchase_channel_details(purchase_channel: domain.PurchaseChannel) -> dto.PurchaseChannelDetails | None: - details = dto.PurchaseChannelDetails( - placement_at=purchase_channel.placement_at, - cost=_build_cost_info(purchase_channel.cost_type, purchase_channel.cost_value), - cost_before_bargain=purchase_channel.cost_before_bargain, - format=purchase_channel.format, - ) - if details.model_dump(exclude_none=True): - return details - return None - - -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, - 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, - ), - details=_build_purchase_channel_details(purchase_channel), - ) - ) - - return dto.PurchaseOutput( - id=purchase.id, - status=purchase.status, - creative_id=purchase.creative_id, - channels=channels_output, - details=_build_purchase_details(purchase), - ) - - -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) - - details = input.details - purchase = domain.Purchase( - project_id=project.id, - creative_id=creative.id, - placement_at=details.placement_at if details else None, - payment_at=details.payment_at if details else None, - cost_type=details.cost.type if details and details.cost else None, - cost_value=details.cost.value if details and details.cost else None, - cost_before_bargain=details.cost_before_bargain if details else None, - purchase_type=details.purchase_type if details else None, - format=details.format if details else None, - comment=details.comment if details else None, - ) - 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: - parser_response = await self.parser.fetch_telegram_channel(channel_input.username) - if not parser_response: - raise domain.TelegramChannelNotFound(channel_input.username) - - channel = domain.Channel( - username=parser_response.username, - telegram_id=parser_response.telegram_id, - title=parser_response.title, - ) - await self.database.create_channel(channel) - log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id) - - invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) - log.info( - 'Created invite link for purchase %s channel_telegram_id=%s requires_approval=%s invite_link=%s', - purchase.id, - project.channel.telegram_id, - requires_approval, - invite_link, - ) - - channel_details = channel_input.details - 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, - comment=channel_input.comment, - placement_at=channel_details.placement_at if channel_details else None, - cost_type=channel_details.cost.type if channel_details and channel_details.cost else None, - cost_value=channel_details.cost.value if channel_details and channel_details.cost else None, - cost_before_bargain=channel_details.cost_before_bargain if channel_details else None, - format=channel_details.format if channel_details else None, - ) - 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_placement.py b/src/usecase/purchase/get_placement.py new file mode 100644 index 0000000..2216615 --- /dev/null +++ b/src/usecase/purchase/get_placement.py @@ -0,0 +1,35 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +from .create_placements import _build_placement_output + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: + """Get single placement by ID (user-managed)""" + 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) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) + if not placement or placement.project_id != project.id: + raise domain.PlacementNotFound(input.placement_id) + + # Prefetch channel for output building + if placement.channel is None: + channel = await self.database.get_channel(channel_id=placement.channel_id) + placement.channel = channel + + return _build_placement_output(placement) diff --git a/src/usecase/purchase/get_purchase.py b/src/usecase/purchase/get_placements.py similarity index 53% rename from src/usecase/purchase/get_purchase.py rename to src/usecase/purchase/get_placements.py index da67ea2..ee5863d 100644 --- a/src/usecase/purchase/get_purchase.py +++ b/src/usecase/purchase/get_placements.py @@ -2,7 +2,8 @@ import logging from typing import TYPE_CHECKING from src import domain, dto -from .create_purchase import _build_purchase_output + +from .create_placements import _build_placement_output if TYPE_CHECKING: from .. import Usecase @@ -10,7 +11,8 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.PurchaseOutput: +async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: + """Get all placements for a project (formerly get_purchases)""" context = await self.ensure_workspace_permission( input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ ) @@ -21,10 +23,7 @@ async def get_purchase(self: 'Usecase', input: dto.GetPurchaseInput) -> dto.Purc 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) + placements = await self.database.get_project_placements(input.workspace_id, project.id) + log.debug('Fetched %s placements for project %s', len(placements), project.id) - channels = await self.database.get_purchase_channels(purchase.id) - - return _build_purchase_output(purchase, channels) + return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements]) diff --git a/src/usecase/purchase/get_purchases.py b/src/usecase/purchase/get_purchases.py deleted file mode 100644 index 24271f2..0000000 --- a/src/usecase/purchase/get_purchases.py +++ /dev/null @@ -1,32 +0,0 @@ -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/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py index 4341b08..6465e96 100644 --- a/src/usecase/subscription/handle_subscription.py +++ b/src/usecase/subscription/handle_subscription.py @@ -17,9 +17,13 @@ async def handle_subscription( first_name: str | None = None, last_name: str | None = None, ) -> None: - placement = await self.database.get_placement_by_invite_link(invite_link) - if not placement: - log.warning('Placement not found for invite_link: %s', invite_link) + publication = await self.database.get_publication_by_invite_link(invite_link) + if not publication: + log.warning('Publication not found for invite_link: %s', invite_link) + return + + if not publication.placement: + log.error('Publication %s has no placement', publication.id) return subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) @@ -38,26 +42,26 @@ async def handle_subscription( await self.database.update_telegram_user(subscriber) active_subscription = await self.database.get_active_subscription_by_subscriber_and_project( - subscriber.id, placement.project_id + subscriber.id, publication.placement.project_id ) if active_subscription: - # Пользователь уже подписан на канал через другой placement + # Пользователь уже подписан на канал через другую публикацию # Это не должно случиться (Telegram не даст подписаться дважды), # но если случилось - логируем и игнорируем log.warning( - 'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, ' - 'ignoring new subscription attempt via placement %s', + 'User %s (telegram_id: %s) already has active subscription to channel %s via publication %s, ' + 'ignoring new subscription attempt via publication %s', subscriber.id, user_telegram_id, - placement.project_id, - active_subscription.placement_id, - placement.id, + publication.placement.project_id, + active_subscription.publication_id, + publication.id, ) return - # Проверяем, была ли раньше подписка через ЭТОТ placement (для реактивации) - existing_sub = await self.database.get_subscription_by_subscriber_and_placement(subscriber.id, placement.id) + # Проверяем, была ли раньше подписка через ЭТУ публикацию (для реактивации) + existing_sub = await self.database.get_subscription_by_subscriber_and_publication(subscriber.id, publication.id) if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED: existing_sub.status = domain.SubscriptionStatus.ACTIVE @@ -65,24 +69,24 @@ async def handle_subscription( await self.database.update_subscription(existing_sub) log.info( - 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same placement %s', + 'Subscription reactivated: subscriber %s (telegram_id: %s) resubscribed via same publication %s', subscriber.id, user_telegram_id, - placement.id, + publication.id, ) return subscription = domain.Subscription( - placement_id=placement.id, + publication_id=publication.id, telegram_user_id=subscriber.id, invite_link=invite_link, ) await self.database.create_subscription(subscription) log.info( - 'Subscription created: subscriber %s (telegram_id: %s) subscribed via placement %s (invite_link: %s)', + 'Subscription created: subscriber %s (telegram_id: %s) subscribed via publication %s (invite_link: %s)', subscriber.id, user_telegram_id, - placement.id, + publication.id, invite_link, ) diff --git a/src/usecase/subscription/handle_unsubscription.py b/src/usecase/subscription/handle_unsubscription.py index 0f25a46..a213db2 100644 --- a/src/usecase/subscription/handle_unsubscription.py +++ b/src/usecase/subscription/handle_unsubscription.py @@ -39,8 +39,8 @@ async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_ await self.database.update_subscription(subscription) log.info( - 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from placement %s', + 'Subscription marked as unsubscribed: subscriber %s (telegram_id: %s) unsubscribed from publication %s', subscriber.id, user_telegram_id, - subscription.placement_id, + subscription.publication_id, ) diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py index bb68172..cb08cca 100644 --- a/src/usecase/views/get_views_history.py +++ b/src/usecase/views/get_views_history.py @@ -14,18 +14,22 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ ) - placement = await self.database.get_placement(input.workspace_id, input.placement_id) - if not placement: - log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) - raise domain.PlacementNotFound(input.placement_id) + publication = await self.database.get_publication(input.workspace_id, input.publication_id) + if not publication: + log.warning('Publication %s not found for user %s', input.publication_id, input.user_id) + raise domain.PublicationNotFound(input.publication_id) - context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + if not publication.placement: + log.error('Publication %s has no placement', publication.id) + raise domain.PlacementNotFound(publication.placement_id) - if not placement.post: + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, publication.placement.project_id) + + if not publication.post: return [] histories = await self.database.get_views_history( - placement.post.id, + publication.post.id, from_date=input.from_date, to_date=input.to_date, ) diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go index fed61fc..505a058 100644 --- a/telegram_bot/bot/bot.go +++ b/telegram_bot/bot/bot.go @@ -103,6 +103,11 @@ func (b *Bot) SetState(s State, mode RenderMode) { Str("mode", fmt.Sprintf("%v", mode)). Msg("State transition") + // Вызываем Exit() у старого состояния для cleanup (например, отмены горутин) + if b.CurrentState != nil { + b.CurrentState.Exit() + } + b.CurrentState = s if b.exec != nil { diff --git a/telegram_bot/bot/state.go b/telegram_bot/bot/state.go index 23b2573..343cc39 100644 --- a/telegram_bot/bot/state.go +++ b/telegram_bot/bot/state.go @@ -14,6 +14,7 @@ type State interface { HandleCallback(*Bot, *echotron.Update) HandleMessage(*Bot, *echotron.Update) Handle(*Bot, *echotron.Update) + Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин) } type Session struct { diff --git a/telegram_bot/screens/accept_workspace_invite.go b/telegram_bot/screens/accept_workspace_invite.go index aa5f778..92fb1ef 100644 --- a/telegram_bot/screens/accept_workspace_invite.go +++ b/telegram_bot/screens/accept_workspace_invite.go @@ -43,3 +43,5 @@ func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) { b.SetState(&MainMenu{}, bot.EditMessage) } + +func (s *AcceptWorkspaceInvite) Exit() {} diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go index fc9dfd5..2994372 100644 --- a/telegram_bot/screens/add_creative.go +++ b/telegram_bot/screens/add_creative.go @@ -4,25 +4,30 @@ import ( "context" "fmt" "regexp" + "strconv" "strings" "example.com/m/adapter/backend" "example.com/m/bot" + "example.com/m/ui" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) -type AddCreative struct { - CreativeEditorFields // Встраивание общих полей +type AddCreativeCtx struct { + CreativeEditorFields - ProjectID string - WorkspaceID string - BackState bot.State - WaitingForCreative bool // Ждем пересылки сообщения с креативом - CreativeMessageSent bool // Креатив уже получен и показан - WaitingForTextEdit bool // Ждем редактирования текста креатива + ProjectID string + WorkspaceID string + BackState bot.State } +type AddCreativeStart struct{ ctx *AddCreativeCtx } + +type AddCreativeEdit struct{ ctx *AddCreativeCtx } + +type AddCreativeInput struct{ ctx *AddCreativeCtx } + const msgWaitCreative = ` + Добавить креатив @@ -31,15 +36,102 @@ const msgWaitCreative = ` • Обрабатываем любое сообщение Telegram • Отправляйте сразу оформленный пост • На следующем шаге можно добавить кнопки или изменить контент + +` +const msgConfirmCreativeFormat = ` +➕ Добавляем этот креатив? + +Название креатива: %s + +Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов. + +` +const msgDownloadMediaError = ` +❌ Не удалось загрузить медиа + +Попробуйте удалить медиа и добавить заново. + +` +const msgInviteLinkRequired = ` +❌ Ошибка валидации + +Текст должен содержать ОДНУ инвайт-ссылку вашего канала! + +Формат ссылки: +https://t.me/+xxx + +Пример правильного текста: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn + +Нажмите "✎ Текст" ниже чтобы исправить. + +` +const msgInviteLinkTooMany = ` +❌ Слишком много ссылок + +Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. +У вас их несколько. Удалите лишние. + +Нажмите "✎ Текст" ниже чтобы исправить. + +` +const msgCreateError = ` +❌ Ошибка создания + +Не удалось создать креатив. +Попробуйте ещё раз или вернитесь назад. + +` +const msgCreativeCreated = ` +✅ Креатив успешно создан! + +📄 Название: %s + +` +const msgCreativeCreatedButtons = ` +🔘 Кнопок добавлено: %d + ` -func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { +func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) { kb := Keyboard(Row(Button("« Отмена", "cancel"))) - s.WaitingForCreative = true b.Render(msgWaitCreative, kb, mode) } -func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) { +func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + if u.CallbackQuery.Data == "cancel" && s.ctx.BackState != nil { + b.SetState(s.ctx.BackState, bot.EditMessage) + } +} + +func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + s.ctx.extractCreativeFromMessage(u.Message) + if s.ctx.Name == nil { + name := s.ctx.generateCreativeName() + s.ctx.Name = &name + } + + s.ctx.DeleteUserMessage(b, u.Message.ID) + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.NewMessage) +} + +func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {} + +func (s *AddCreativeStart) Exit() {} + +func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) { + s.ctx.showCreativeConfirmation(b) +} + +func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } @@ -48,413 +140,175 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) { switch { case data == "cancel": - if s.BackState != nil { - b.SetState(s.BackState, bot.EditMessage) + if s.ctx.BackState != nil { + b.SetState(s.ctx.BackState, bot.EditMessage) } - case data == "edit_text": - // Начинаем редактирование текста - s.WaitingForTextEdit = true - s.ShowControlPanel(b, `✎ Введите текст креатива: - -⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала - -Формат ссылки: -https://t.me/+xxx - -Пример: -Присоединяйтесь к нашему каналу! -https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_edit")), - }) - + s.ctx.InputMode = inputModeText + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "add_button": - // Начинаем процесс добавления кнопки - s.WaitingForButtonText = true - s.PendingButtonType = "invite" - - s.ShowControlPanel(b, `+ Добавить кнопку - -Введите текст кнопки: - -Например: -• Перейти на сайт -• Написать нам - -Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ - Row( - Button("✓ Ссылка на канал", "button_type_invite"), - Button("Своя ссылка", "button_type_custom"), - ), - Row(Button("« Отмена", "cancel_add_button")), - }) - + s.ctx.InputMode = inputModeButtonText + s.ctx.PendingButtonType = "invite" + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "add_media": - // Начинаем процесс добавления медиа - s.WaitingForMedia = true - - s.ShowControlPanel(b, `+ Добавить медиа - -Отправьте фото, видео или GIF - -Это медиа будет отображаться в креативе вместе с текстом`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_media")), - }) - + s.ctx.InputMode = inputModeMedia + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "delete_media": - // Удаляем медиа - s.MediaType = "" - s.MediaFileID = "" - s.MediaChanged = true - s.UpdateCreativePreview(b) - - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - - case data == "cancel_add_button": - // Отменяем добавление кнопки - // Если мы уже создали кнопку с placeholder - удаляем её - if s.WaitingForButtonURL && len(s.Buttons) > 0 { - lastButton := s.Buttons[len(s.Buttons)-1] - if lastButton.URL == "https://example.com" || lastButton.URL == inviteLinkPlaceholder { - s.Buttons = s.Buttons[:len(s.Buttons)-1] - s.UpdateCreativePreview(b) - } - } - s.WaitingForButtonText = false - s.WaitingForButtonURL = false - s.PendingButtonType = "" - s.CurrentButtonText = nil - - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - - case data == "button_type_invite": - s.PendingButtonType = "invite" - s.ShowControlPanel(b, `+ Добавить кнопку - -Введите текст кнопки: - -Например: -• Перейти на сайт -• Написать нам - -Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ - Row( - Button("✓ Ссылка на канал", "button_type_invite"), - Button("Своя ссылка", "button_type_custom"), - ), - Row(Button("« Отмена", "cancel_add_button")), - }) - - case data == "button_type_custom": - s.PendingButtonType = "custom" - s.ShowControlPanel(b, `+ Добавить кнопку - -Введите текст кнопки: - -Например: -• Перейти на сайт -• Написать нам - -Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ - Row( - Button("Ссылка на канал", "button_type_invite"), - Button("✓ Своя ссылка", "button_type_custom"), - ), - Row(Button("« Отмена", "cancel_add_button")), - }) - - case data == "cancel_edit": - // Отменяем редактирование текста - s.WaitingForTextEdit = false - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - - case data == "cancel_media": - // Отменяем добавление медиа - s.WaitingForMedia = false - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - + s.ctx.MediaType = "" + s.ctx.MediaFileID = "" + s.ctx.MediaChanged = true + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) case data == "confirm_create": - // Создаём креатив - s.createCreative(b) - + s.ctx.createCreative(b) case strings.HasPrefix(data, "delete_button:"): - // Удаляем кнопку по индексу - parts := strings.Split(data, ":") - if len(parts) == 2 { - var index int - if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { - if index >= 0 && index < len(s.Buttons) { - s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) - s.UpdateCreativePreview(b) - - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - } - } + indexStr := strings.TrimPrefix(data, "delete_button:") + index, err := strconv.Atoi(indexStr) + if err != nil || index < 0 || index >= len(s.ctx.Buttons) { + return } - + s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...) + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) default: s.Enter(b, bot.NewMessage) } } -func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { +func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil || u.Message.Text == "" { + return + } + + newName := u.Message.Text + runes := []rune(newName) + if len(runes) > 200 { + newName = string(runes[:200]) + } + + s.ctx.Name = &newName + s.ctx.DeleteUserMessage(b, u.Message.ID) + s.Enter(b, bot.EditMessage) +} + +func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddCreativeEdit) Exit() {} + +func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) { + switch s.ctx.InputMode { + case inputModeText: + s.ctx.ShowTextEditPanel(b) + case inputModeButtonText: + s.ctx.ShowAddButtonPanel(b) + case inputModeButtonURL: + s.ctx.ShowButtonURLPanel(b) + case inputModeMedia: + s.ctx.ShowMediaPanel(b) + } +} + +func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + switch u.CallbackQuery.Data { + case "button_type_invite": + s.ctx.PendingButtonType = "invite" + s.ctx.ShowAddButtonPanel(b) + case "button_type_custom": + s.ctx.PendingButtonType = "custom" + s.ctx.ShowAddButtonPanel(b) + case "cancel_add_button": + s.ctx.CancelPendingButton(b) + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case "cancel_edit", "cancel_media": + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + } +} + +func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil { return } - // Шаг 1: Ждем пересылки креатива (любое сообщение) - if s.WaitingForCreative { - // Извлекаем данные из сообщения - s.extractCreativeFromMessage(u.Message) - - // Генерируем название из текста или дефолтное - if s.Name == nil { - name := s.generateCreativeName() - s.Name = &name - } - - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - - s.WaitingForCreative = false - s.CreativeMessageSent = true - - // Показываем превью + панель с предложением добавить - s.showCreativeConfirmation(b) - return - } - - // Обработка медиа (когда явно просим добавить медиа) - if s.WaitingForMedia { - handled := false - - if u.Message.Photo != nil && len(u.Message.Photo) > 0 { - photo := u.Message.Photo[len(u.Message.Photo)-1] - s.MediaType = "photo" - s.MediaFileID = photo.FileID - s.MediaChanged = true - handled = true - } else if u.Message.Video != nil { - s.MediaType = "video" - s.MediaFileID = u.Message.Video.FileID - s.MediaChanged = true - handled = true - } else if u.Message.Animation != nil { - s.MediaType = "animation" - s.MediaFileID = u.Message.Animation.FileID - s.MediaChanged = true - handled = true - } - - if handled { - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - - s.WaitingForMedia = false - s.UpdateCreativePreview(b) - - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - return - } - } - - // Обработка добавления кнопки - текст кнопки - if s.WaitingForButtonText { + switch s.ctx.InputMode { + case inputModeText: + if u.Message.Text == "" { + return + } + text := ui.FormatMessageHTML(u.Message) + s.ctx.Text = &text + s.ctx.DeleteUserMessage(b, u.Message.ID) + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case inputModeButtonText: if u.Message.Text == "" { return } - buttonText := u.Message.Text - s.CurrentButtonText = &buttonText - s.WaitingForButtonText = false + s.ctx.DeleteUserMessage(b, u.Message.ID) - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") + if s.ctx.PendingButtonType == "custom" { + s.ctx.AddCustomButtonPlaceholder(buttonText) + s.ctx.UpdateCreativePreview(b) + s.ctx.InputMode = inputModeButtonURL + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + return } - if s.PendingButtonType == "custom" { - s.WaitingForButtonURL = true - s.Buttons = append(s.Buttons, InlineButton{ - Text: buttonText, - URL: "https://example.com", - }) - s.UpdateCreativePreview(b) - s.ShowControlPanel(b, `✧ Введите URL для кнопки: - -URL должен начинаться с https:// - -Например: -https://example.com -https://t.me/yourchannel`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_add_button")), - }) - } else { - s.Buttons = append(s.Buttons, InlineButton{ - Text: buttonText, - URL: inviteLinkPlaceholder, - }) - s.UpdateCreativePreview(b) - s.PendingButtonType = "" - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - } - return - } - - // Обработка добавления кнопки - URL - if s.WaitingForButtonURL { + s.ctx.AddInviteButton(buttonText) + s.ctx.UpdateCreativePreview(b) + s.ctx.PendingButtonType = "" + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case inputModeButtonURL: if u.Message.Text == "" { return } + url := strings.TrimSpace(u.Message.Text) + s.ctx.DeleteUserMessage(b, u.Message.ID) - url := u.Message.Text - - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - - if !strings.HasPrefix(url, "https://") { - s.ShowControlPanel(b, `❌ Неверный формат URL - -URL должен начинаться с https:// - -Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_add_button")), - }) + if !s.ctx.IsValidButtonURL(url, true) { + s.ctx.ShowInvalidButtonURLPanel(b) return } - if len(s.Buttons) > 0 { - s.Buttons[len(s.Buttons)-1].URL = url + s.ctx.UpdateLastButtonURL(url) + s.ctx.UpdateCreativePreview(b) + s.ctx.PendingButtonType = "" + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + case inputModeMedia: + if !s.ctx.SetMediaFromMessage(u.Message) { + return } - s.WaitingForButtonURL = false - s.CurrentButtonText = nil - s.PendingButtonType = "" - - s.UpdateCreativePreview(b) - - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - return - } - - // Если ждем редактирования текста креатива - if s.WaitingForTextEdit && u.Message.Text != "" { - text := s.GetFormattedText(u.Message) - s.Text = &text - - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - - s.WaitingForTextEdit = false - s.UpdateCreativePreview(b) - - if s.CreativeMessageSent { - s.showCreativeConfirmation(b) - } else { - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - } - return - } - - // Если креатив уже показан и пользователь вводит текст - это изменение названия - if s.CreativeMessageSent && u.Message.Text != "" { - newName := u.Message.Text - - // Ограничение 200 символов - runes := []rune(newName) - if len(runes) > 200 { - newName = string(runes[:200]) - } - - s.Name = &newName - - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - - // Обновляем панель с новым названием - s.showCreativeConfirmation(b) - return + s.ctx.DeleteUserMessage(b, u.Message.ID) + s.ctx.UpdateCreativePreview(b) + s.ctx.ClearInputMode() + b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) } } -func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } +func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {} -// extractCreativeFromMessage извлекает данные креатива из сообщения -func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) { - // Извлекаем текст с форматированием (GetFormattedText теперь поддерживает и text и caption) +func (s *AddCreativeInput) Exit() {} + +func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) { if message.Text != "" || message.Caption != "" { - text := s.GetFormattedText(message) + text := ui.FormatMessageHTML(message) if text != "" { s.Text = &text } } - // Извлекаем медиа - if message.Photo != nil && len(message.Photo) > 0 { - photo := message.Photo[len(message.Photo)-1] - s.MediaType = "photo" - s.MediaFileID = photo.FileID - s.MediaChanged = true - } else if message.Video != nil { - s.MediaType = "video" - s.MediaFileID = message.Video.FileID - s.MediaChanged = true - } else if message.Animation != nil { - s.MediaType = "animation" - s.MediaFileID = message.Animation.FileID - s.MediaChanged = true - } + s.SetMediaFromMessage(message) - // Извлекаем кнопки из ReplyMarkup if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 { for _, row := range message.ReplyMarkup.InlineKeyboard { for _, button := range row { - // Берем только кнопки с URL if button.URL != "" { s.Buttons = append(s.Buttons, InlineButton{ Text: button.Text, @@ -466,10 +320,8 @@ func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) { } } -// generateCreativeName генерирует название из текста или дефолтное -func (s *AddCreative) generateCreativeName() string { +func (s *AddCreativeCtx) generateCreativeName() string { if s.Text != nil && *s.Text != "" { - // Удаляем ВСЕ HTML теги с помощью regex re := regexp.MustCompile(`<[^>]*>`) cleanText := re.ReplaceAllString(*s.Text, "") @@ -485,119 +337,28 @@ func (s *AddCreative) generateCreativeName() string { return "Новый креатив" } -// escapeHTML экранирует HTML-символы для безопасного вывода -func escapeHTML(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - return s -} - -// buildEditorButtons создает кнопки для панели редактирования креатива -func (s *AddCreative) buildEditorButtons(confirmText, cancelCallback string) [][]echotron.InlineKeyboardButton { - var buttons [][]echotron.InlineKeyboardButton - - // Кнопки редактирования - buttons = append(buttons, Row( - Button("✎ Текст", "edit_text"), - Button("+ Медиа", "add_media"), - )) - buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button"))) - - // Кнопка удаления медиа (если медиа добавлено) - if s.MediaFileID != "" { - buttons = append(buttons, Row( - Button("⌫ Убрать медиа", "delete_media"), - )) - } - - // Кнопки удаления добавленных кнопок - if len(s.Buttons) > 0 { - var row []echotron.InlineKeyboardButton - for i, btn := range s.Buttons { - row = append(row, Button( - fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text), - fmt.Sprintf("delete_button:%d", i), - )) - - if len(row) == 2 { - buttons = append(buttons, row) - row = nil - } - } - - if len(row) > 0 { - buttons = append(buttons, row) - } - } - - // Финальные кнопки - if s.Text != nil { - buttons = append(buttons, Row( - Button("← "+cancelCallback, "cancel"), - Button(confirmText, "confirm_create"), - )) - } else { - buttons = append(buttons, Row( - Button("← "+cancelCallback, "cancel"), - )) - } - - return buttons -} - -// showCreativeConfirmation показывает превью креатива + панель с предложением добавить -func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) { - - // 1. Отправляем/обновляем превью креатива +func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) { if s.CreativeMessageID == nil { s.SendCreativePreview(b) } else { s.UpdateCreativePreview(b) } - // 2. Формируем текст панели управления - // Экранируем HTML-символы в названии для безопасного вывода - escapedName := escapeHTML(*s.Name) - confirmText := fmt.Sprintf(`➕ Добавляем этот креатив? - -Название креатива: %s - -Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.`, escapedName) - - // 3. Показываем панель с кнопками редактирования + сохранения - buttons := s.buildEditorButtons("✓ Сохранить", "Отмена") + escapedName := ui.EscapeHTML(*s.Name) + confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName) + buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel") s.ShowControlPanel(b, confirmText, buttons) } -// sendCreativeAndPanel отправляет превью креатива и панель управления -func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { - - // 1. Отправляем превью креатива - s.SendCreativePreview(b) - - // 2. Отправляем панель управления - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") -} - -func (s *AddCreative) createCreative(b *bot.Bot) { - // JWT уже создан в Bot.Update(), просто берем из сессии - jwt := b.Session.JWT - if jwt == "" { - log.Error().Msg("JWT is empty in session") - b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start login", Keyboard()) - return - } - +func (s *AddCreativeCtx) createCreative(b *bot.Bot) { var err error var mediaData []byte if s.MediaFileID != "" { mediaData, err = b.DownloadFileBytes(s.MediaFileID) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") - // Показываем ошибку через панель управления - buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") - s.ShowControlPanel(b, "❌ Не удалось загрузить медиа\n\nПопробуйте удалить медиа и добавить заново.", buttons) + buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") + s.ShowControlPanel(b, msgDownloadMediaError, buttons) return } } @@ -610,84 +371,41 @@ func (s *AddCreative) createCreative(b *bot.Bot) { }) } - // Логируем что отправляем - log.Info(). - Str("name", *s.Name). - Str("text", *s.Text). - Int("buttons_count", len(buttons)). - Interface("buttons", buttons). - Str("media_type", s.MediaType). - Msg("Creating creative with data") - - // Создаём креатив через API - creative, err := b.Backend.CreateCreative( - context.Background(), - jwt, - s.WorkspaceID, - s.ProjectID, - backend.CreateCreativeInput{ - Name: *s.Name, - Text: *s.Text, - MediaType: s.MediaType, - MediaFileID: s.MediaFileID, - MediaData: mediaData, - Buttons: buttons, - }, - ) + creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{ + Name: *s.Name, + Text: *s.Text, + MediaType: s.MediaType, + MediaFileID: s.MediaFileID, + MediaData: mediaData, + Buttons: buttons, + }) if err != nil { log.Error().Err(err).Msg("Failed to create creative") - // Проверяем, это ошибка валидации инвайт-ссылки errMsg := err.Error() var userMsg string if strings.Contains(errMsg, "Creative text must contain one invite link") { - userMsg = `❌ Ошибка валидации - -Текст должен содержать ОДНУ инвайт-ссылку вашего канала! - -Формат ссылки: -https://t.me/+xxx - -Пример правильного текста: -Присоединяйтесь к нашему каналу! -https://t.me/+AbCdEfGhIjKlMn - -Нажмите "✎ Текст" ниже чтобы исправить.` + userMsg = msgInviteLinkRequired } else if strings.Contains(errMsg, "Creative text must contain only one invite link") { - userMsg = `❌ Слишком много ссылок - -Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. -У вас их несколько. Удалите лишние. - -Нажмите "✎ Текст" ниже чтобы исправить.` + userMsg = msgInviteLinkTooMany } else { - userMsg = `❌ Ошибка создания - -Не удалось создать креатив. -Попробуйте ещё раз или вернитесь назад.` + userMsg = msgCreateError } - // Показываем ошибку через панель управления, чтобы не ломать порядок сообщений - buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") + buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") s.ShowControlPanel(b, userMsg, buttons) return } - // Успех! Показываем сообщение и возвращаемся - successText := `✅ Креатив успешно создан! - -📄 Название: ` + creative.Name - + successText := fmt.Sprintf(msgCreativeCreated, creative.Name) if len(s.Buttons) > 0 { - successText += fmt.Sprintf(` -🔘 Кнопок добавлено: %d`, len(s.Buttons)) + successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons)) } b.SendNew(successText, Keyboard()) - // Возвращаемся на экран креативов if s.BackState != nil { b.SetState(s.BackState, bot.NewMessage) } diff --git a/telegram_bot/screens/add_project.go b/telegram_bot/screens/add_project.go index 3f3460b..bccd2ab 100644 --- a/telegram_bot/screens/add_project.go +++ b/telegram_bot/screens/add_project.go @@ -58,3 +58,5 @@ func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddProject) Exit() {} diff --git a/telegram_bot/screens/add_purchase.go b/telegram_bot/screens/add_purchase.go index ee3c738..7be21c5 100644 --- a/telegram_bot/screens/add_purchase.go +++ b/telegram_bot/screens/add_purchase.go @@ -300,3 +300,5 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddPurchase) Exit() {} diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go index cbac406..0d18598 100644 --- a/telegram_bot/screens/creative_details.go +++ b/telegram_bot/screens/creative_details.go @@ -7,6 +7,7 @@ import ( "example.com/m/adapter/backend" "example.com/m/bot" + "example.com/m/ui" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) @@ -36,7 +37,8 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { // Заполняем поля из загруженного креатива s.Name = &creative.Name if creative.Text != "" { - s.Text = &creative.Text + sanitizedText := ui.SanitizeHTML(creative.Text) + s.Text = &sanitizedText log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend") } else { log.Info().Msg("Creative text is empty from backend") @@ -48,6 +50,7 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) } s.MediaChanged = false + s.ClearInputMode() log.Info(). Str("creative_id", s.CreativeID). @@ -77,47 +80,17 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { } case data == "edit_text": - s.ShowControlPanel(b, `✎ Введите новый текст креатива: - -⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала - -Формат ссылки: -https://t.me/+xxx - -Пример: -Присоединяйтесь к нашему каналу! -https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена редактирования", "cancel_edit")), - }) + s.InputMode = inputModeText + s.ShowTextEditPanel(b) case data == "add_button": - s.WaitingForButtonText = true + s.InputMode = inputModeButtonText s.PendingButtonType = "invite" - s.ShowControlPanel(b, `+ Добавить кнопку - -Введите текст кнопки: - -Например: -• Перейти на сайт -• Написать нам - -Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ - Row( - Button("✓ Ссылка на канал", "button_type_invite"), - Button("Своя ссылка", "button_type_custom"), - ), - Row(Button("« Отмена", "cancel_add_button")), - }) + s.ShowAddButtonPanel(b) case data == "add_media": - s.WaitingForMedia = true - s.ShowControlPanel(b, `+ Добавить медиа - -Отправьте фото, видео или GIF - -Это медиа будет отображаться в креативе вместе с текстом`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_media")), - }) + s.InputMode = inputModeMedia + s.ShowMediaPanel(b) case data == "delete_media": s.MediaType = "" @@ -128,57 +101,21 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ case data == "cancel_add_button": // Отменяем добавление кнопки - if s.WaitingForButtonURL && len(s.Buttons) > 0 { - lastButton := s.Buttons[len(s.Buttons)-1] - if lastButton.URL == "https://example.com" || lastButton.URL == inviteLinkPlaceholder { - s.Buttons = s.Buttons[:len(s.Buttons)-1] - s.UpdateCreativePreview(b) - } - } - s.WaitingForButtonText = false - s.WaitingForButtonURL = false - s.PendingButtonType = "" - s.CurrentButtonText = nil + s.CancelPendingButton(b) s.showManagementPanelWithDelete(b) case data == "button_type_invite": s.PendingButtonType = "invite" - s.ShowControlPanel(b, `+ Добавить кнопку - -Введите текст кнопки: - -Например: -• Перейти на сайт -• Написать нам - -Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ - Row( - Button("✓ Ссылка на канал", "button_type_invite"), - Button("Своя ссылка", "button_type_custom"), - ), - Row(Button("« Отмена", "cancel_add_button")), - }) + s.InputMode = inputModeButtonText + s.ShowAddButtonPanel(b) case data == "button_type_custom": s.PendingButtonType = "custom" - s.ShowControlPanel(b, `+ Добавить кнопку - -Введите текст кнопки: - -Например: -• Перейти на сайт -• Написать нам - -Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ - Row( - Button("Ссылка на канал", "button_type_invite"), - Button("✓ Своя ссылка", "button_type_custom"), - ), - Row(Button("« Отмена", "cancel_add_button")), - }) + s.InputMode = inputModeButtonText + s.ShowAddButtonPanel(b) case data == "cancel_edit", data == "cancel_media": - s.WaitingForMedia = false + s.ClearInputMode() s.showManagementPanelWithDelete(b) case data == "confirm_save": @@ -225,146 +162,80 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { return } - // Обрабатываем добавление медиа - if s.WaitingForMedia { - handled := false - if u.Message.Photo != nil && len(u.Message.Photo) > 0 { - photos := u.Message.Photo - largestPhoto := photos[len(photos)-1] - s.MediaType = "photo" - s.MediaFileID = largestPhoto.FileID - s.MediaChanged = true - s.WaitingForMedia = false - handled = true - - } else if u.Message.Video != nil { - s.MediaType = "video" - s.MediaFileID = u.Message.Video.FileID - s.MediaChanged = true - s.WaitingForMedia = false - handled = true - - } else if u.Message.Animation != nil { - s.MediaType = "animation" - s.MediaFileID = u.Message.Animation.FileID - s.MediaChanged = true - s.WaitingForMedia = false - handled = true - } - - if handled { - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - - s.UpdateCreativePreview(b) - s.showManagementPanelWithDelete(b) + switch s.InputMode { + case inputModeMedia: + if !s.SetMediaFromMessage(u.Message) { return } - } - - // Обрабатываем ввод текста кнопки - if s.WaitingForButtonText && u.Message.Text != "" { - buttonText := u.Message.Text - s.CurrentButtonText = &buttonText - s.WaitingForButtonText = false - - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") + s.DeleteUserMessage(b, u.Message.ID) + s.UpdateCreativePreview(b) + s.ClearInputMode() + s.showManagementPanelWithDelete(b) + return + case inputModeButtonText: + if u.Message.Text == "" { + return } + buttonText := u.Message.Text + s.DeleteUserMessage(b, u.Message.ID) // Создаём кнопку с placeholder URL if s.PendingButtonType == "custom" { - s.WaitingForButtonURL = true - s.Buttons = append(s.Buttons, InlineButton{ - Text: buttonText, - URL: "https://example.com", - }) + s.AddCustomButtonPlaceholder(buttonText) s.UpdateCreativePreview(b) - s.ShowControlPanel(b, `✧ Введите URL для кнопки: - -Например: -https://example.com`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_add_button")), - }) + s.InputMode = inputModeButtonURL + s.ShowButtonURLPanel(b) } else { - s.Buttons = append(s.Buttons, InlineButton{ - Text: buttonText, - URL: inviteLinkPlaceholder, - }) + s.AddInviteButton(buttonText) s.UpdateCreativePreview(b) s.PendingButtonType = "" + s.ClearInputMode() s.showManagementPanelWithDelete(b) } return - } - - // Обрабатываем ввод URL кнопки - if s.WaitingForButtonURL && u.Message.Text != "" { - url := strings.TrimSpace(u.Message.Text) - - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") + case inputModeButtonURL: + if u.Message.Text == "" { + return } + url := strings.TrimSpace(u.Message.Text) + s.DeleteUserMessage(b, u.Message.ID) // Простая валидация URL - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { - s.ShowControlPanel(b, `❌ Неверный формат URL - -URL должен начинаться с http:// или https:// - -Попробуйте ещё раз:`, [][]echotron.InlineKeyboardButton{ - Row(Button("« Отмена", "cancel_add_button")), - }) + if !s.IsValidButtonURL(url, true) { + s.ShowInvalidButtonURLPanel(b) return } // Обновляем URL последней кнопки - if len(s.Buttons) > 0 { - s.Buttons[len(s.Buttons)-1].URL = url - } + s.UpdateLastButtonURL(url) - s.WaitingForButtonURL = false - s.CurrentButtonText = nil + s.ClearInputMode() s.PendingButtonType = "" s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) return - } - - // Обрабатываем редактирование текста креатива - if !s.WaitingForButtonText && !s.WaitingForButtonURL && !s.WaitingForMedia && u.Message.Text != "" { - text := s.GetFormattedText(u.Message) + case inputModeText: + if u.Message.Text == "" { + return + } + text := ui.FormatMessageHTML(u.Message) s.Text = &text - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } + s.DeleteUserMessage(b, u.Message.ID) s.UpdateCreativePreview(b) + s.ClearInputMode() s.showManagementPanelWithDelete(b) return } // Обрабатываем имя креатива (если нет других состояний) if u.Message.Text != "" { - text := s.GetFormattedText(u.Message) + text := ui.FormatMessageHTML(u.Message) s.Text = &text - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } + s.DeleteUserMessage(b, u.Message.ID) s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) @@ -373,6 +244,8 @@ URL должен начинаться с http:// или https func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } +func (s *CreativeDetails) Exit() {} + func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) { var confirmText, confirmCallback string diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go index 4273b08..7bd36f2 100644 --- a/telegram_bot/screens/creative_editor.go +++ b/telegram_bot/screens/creative_editor.go @@ -2,10 +2,10 @@ package screens import ( "fmt" - "sort" "strings" "example.com/m/bot" + "example.com/m/ui" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) @@ -28,12 +28,11 @@ type CreativeEditorFields struct { MediaFileID string // File ID из Telegram MediaChanged bool + // Режим ввода + InputMode string + // Состояние для добавления кнопки - WaitingForButtonText bool - WaitingForButtonURL bool - CurrentButtonText *string - WaitingForMedia bool - PendingButtonType string + PendingButtonType string // ID сообщения с креативом (которое постоянно обновляется) CreativeMessageID *int @@ -41,115 +40,108 @@ type CreativeEditorFields struct { ControlPanelMessageID *int // Предыдущее состояние для определения изменения типа сообщения - previousHasMedia bool + previousHasMedia bool + previousMediaType string + previousMediaFileID string } const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра +const buttonURLPlaceholder = "https://example.com" + +const ( + inputModeText = "text" + inputModeButtonText = "button_text" + inputModeButtonURL = "button_url" + inputModeMedia = "media" +) +const msgCreativeDefault = ` +💭 Я твой креатив + +У меня пока нет текста, добавь его ниже. + + +` +const msgCreativeManagement = ` +👆Превью креатива выше — так он будет выглядеть при отправке. + +Выберите, что изменить: + +` + +func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { + keyboard := echotron.InlineKeyboardMarkup{ + InlineKeyboard: buttons, + } + if len(buttons) == 0 { + keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} + } + return keyboard +} // GetCreativeText возвращает текст для превью креатива func (e *CreativeEditorFields) GetCreativeText() string { - var result string - - if e.Text != nil && *e.Text != "" { - text := strings.TrimSpace(*e.Text) - - // Проверяем что текст не пустой - if text == "" { - log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is empty, using default") - return result + `💭 Я твой креатив - -У меня пока нет текста, добавь его ниже. - -` - } - - // Заменяем пригласительные ссылки с классом tg-link на preview-ссылку - // Находим все вхождения ... и заменяем на кликабельную ссылку - replacementCount := 0 - for strings.Contains(text, ``) { - startIdx := strings.Index(text, ``) - if startIdx == -1 { - break - } - endIdx := strings.Index(text[startIdx:], "") - if endIdx == -1 { - log.Warn().Msg("Found but no closing ") - break - } - endIdx += startIdx - - // Извлекаем текст между тегами - linkText := text[startIdx+len(``) : endIdx] - log.Info(). - Int("replacement_num", replacementCount). - Str("linkText_before_sanitize", linkText). - Msg("Processing tg-link") - - // ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности - // Это нужно для старых креативов с неправильным HTML - tagsRemoved := 0 - for { - openTag := strings.Index(linkText, "<") - if openTag == -1 { - break - } - closeTag := strings.Index(linkText[openTag:], ">") - if closeTag == -1 { - log.Warn().Str("linkText", linkText).Msg("Found < but no closing >") - break - } - // Удаляем тег - removedTag := linkText[openTag : openTag+closeTag+1] - linkText = linkText[:openTag] + linkText[openTag+closeTag+1:] - tagsRemoved++ - log.Info(). - Str("removed_tag", removedTag). - Str("linkText_after", linkText). - Int("tags_removed_total", tagsRemoved). - Msg("Removed HTML tag from link text") - } - - linkText = strings.TrimSpace(linkText) - - // Если текст ссылки не пустой, добавляем атрибут href с примером ссылки - // Если пустой, показываем просто текст примера ссылки (без тега) - var replacement string - if linkText != "" { - // Есть текст - добавляем href атрибут к тегу - replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL, linkText) - } else { - // Пусто - показываем просто текст примера ссылки - replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL) - } - - log.Info(). - Str("linkText_after_sanitize", linkText). - Int("tags_removed", tagsRemoved). - Str("replacement", replacement). - Msg("Sanitized link text") - - text = text[:startIdx] + replacement + text[endIdx+len(""):] - replacementCount++ - } - - log.Info(). - Int("replacements_made", replacementCount). - Str("final_text", text). - Msg("Finished processing tg-links") - - // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках) - text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) - - log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text") - return result + text + if e.Text == nil { + return msgCreativeDefault } - log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text") - return result + `💭 Я твой креатив -У меня пока нет текста, добавь его ниже. + text := strings.TrimSpace(ui.SanitizeHTML(*e.Text)) + if text == "" { + return msgCreativeDefault + } -` + // Заменяем пригласительные ссылки с классом tg-link на preview-ссылку + // Находим все вхождения ... и заменяем на кликабельную ссылку + for strings.Contains(text, ``) { + startIdx := strings.Index(text, ``) + if startIdx == -1 { + break + } + endIdx := strings.Index(text[startIdx:], "") + if endIdx == -1 { + log.Warn().Msg("Found but no closing ") + break + } + endIdx += startIdx + + // Извлекаем текст между тегами + linkText := text[startIdx+len(``) : endIdx] + // ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности + // Это нужно для старых креативов с неправильным HTML + for { + openTag := strings.Index(linkText, "<") + if openTag == -1 { + break + } + closeTag := strings.Index(linkText[openTag:], ">") + if closeTag == -1 { + log.Warn().Str("linkText", linkText).Msg("Found < but no closing >") + break + } + // Удаляем тег + linkText = linkText[:openTag] + linkText[openTag+closeTag+1:] + } + + linkText = strings.TrimSpace(linkText) + + // Если текст ссылки не пустой, добавляем атрибут href с примером ссылки + // Если пустой, показываем просто текст примера ссылки (без тега) + var replacement string + if linkText != "" { + // Есть текст - добавляем href атрибут к тегу + replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL, linkText) + } else { + // Пусто - показываем просто текст примера ссылки + replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL) + } + + text = text[:startIdx] + replacement + text[endIdx+len(""):] + } + + // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках) + text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) + + return text } // GetCreativeButtons возвращает кнопки для превью креатива @@ -178,14 +170,25 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { creativeButtons := e.GetCreativeButtons() // Создаем клавиатуру с пустым массивом (не nil!) - keyboard := echotron.InlineKeyboardMarkup{ - InlineKeyboard: creativeButtons, - } - if len(creativeButtons) == 0 { - keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} - } + keyboard := buildCreativeKeyboard(creativeButtons) var msgID int + sendText := func() (int, error) { + res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + LinkPreviewOptions: echotron.LinkPreviewOptions{ + IsDisabled: true, + }, + }) + if err != nil { + return 0, err + } + if res.Result == nil { + return 0, fmt.Errorf("send message: empty result") + } + return res.Result.ID, nil + } // Если есть медиа, отправляем с медиа if e.MediaFileID != "" { @@ -228,15 +231,8 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { if err != nil { log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media") // Fallback to text message - используем прямой SendMessage - res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ - ReplyMarkup: keyboard, - ParseMode: echotron.HTML, - LinkPreviewOptions: echotron.LinkPreviewOptions{ - IsDisabled: true, - }, - }) - if err == nil && res.Result != nil { - msgID = res.Result.ID + if id, err := sendText(); err == nil { + msgID = id } } else if res.Result != nil { msgID = res.Result.ID @@ -244,26 +240,19 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { } else { // Отправляем обычное текстовое сообщение напрямую (не через SendNew) // чтобы не затронуть LastMessageID и не затронуть cleanup - res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ - ReplyMarkup: keyboard, - ParseMode: echotron.HTML, - LinkPreviewOptions: echotron.LinkPreviewOptions{ - IsDisabled: true, - }, - }) + id, err := sendText() if err != nil { log.Error().Err(err).Msg("Failed to send creative preview") return } - if res.Result != nil { - msgID = res.Result.ID - } + msgID = id } // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! e.CreativeMessageID = &msgID e.previousHasMedia = e.MediaFileID != "" - log.Info().Int("creative_msg_id", *e.CreativeMessageID).Bool("has_media", e.MediaFileID != "").Msg("Creative preview sent") + e.previousMediaType = e.MediaType + e.previousMediaFileID = e.MediaFileID } // UpdateCreativePreview обновляет превью креатива @@ -277,17 +266,8 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { currentHasMedia := e.MediaFileID != "" - log.Info(). - Int("creative_msg_id", *e.CreativeMessageID). - Bool("has_media", currentHasMedia). - Bool("previous_has_media", e.previousHasMedia). - Str("media_type", e.MediaType). - Msg("Updating creative preview") - // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения if currentHasMedia != e.previousHasMedia { - log.Info().Msg("Message type changed, recreating both messages") - // Удаляем оба сообщения if e.CreativeMessageID != nil { b.DeleteMessage(b.ChatID, *e.CreativeMessageID) @@ -307,29 +287,84 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { creativeText := e.GetCreativeText() creativeButtons := e.GetCreativeButtons() - keyboard := echotron.InlineKeyboardMarkup{ - InlineKeyboard: creativeButtons, - } - if len(creativeButtons) == 0 { - keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} - } + keyboard := buildCreativeKeyboard(creativeButtons) msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID) + logEditError := func(msg string, err error) { + textPreview := creativeText + runes := []rune(textPreview) + if len(runes) > 200 { + textPreview = string(runes[:200]) + "..." + } + log.Error(). + Err(err). + Int("creative_message_id", *e.CreativeMessageID). + Str("media_type", e.MediaType). + Int("text_len", len([]rune(creativeText))). + Str("text_preview", textPreview). + Msg(msg) + } // Если есть медиа - редактируем caption + updated := false if e.MediaFileID != "" { - _, err := b.EditMessageCaption( - msgID, - &echotron.MessageCaptionOptions{ - Caption: creativeText, - ParseMode: echotron.HTML, - ReplyMarkup: keyboard, - }, - ) - if err != nil { - log.Error().Err(err).Msg("Failed to edit media caption") + mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType + if mediaChanged { + var media echotron.InputMedia + switch e.MediaType { + case "photo": + media = echotron.InputMediaPhoto{ + Type: echotron.MediaTypePhoto, + Media: echotron.NewInputFileID(e.MediaFileID), + Caption: creativeText, + ParseMode: echotron.HTML, + } + case "video": + media = echotron.InputMediaVideo{ + Type: echotron.MediaTypeVideo, + Media: echotron.NewInputFileID(e.MediaFileID), + Caption: creativeText, + ParseMode: echotron.HTML, + } + case "animation": + media = echotron.InputMediaAnimation{ + Type: echotron.MediaTypeAnimation, + Media: echotron.NewInputFileID(e.MediaFileID), + Caption: creativeText, + ParseMode: echotron.HTML, + } + } + + if media != nil { + _, err := b.EditMessageMedia( + msgID, + media, + &echotron.MessageMediaOptions{ + ReplyMarkup: keyboard, + }, + ) + if err != nil { + logEditError("Failed to edit message media", err) + } else { + updated = true + } + } else { + log.Warn().Str("media_type", e.MediaType).Msg("Unsupported media type for edit") + } } else { - e.previousHasMedia = currentHasMedia + _, err := b.EditMessageCaption( + msgID, + &echotron.MessageCaptionOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + if err != nil { + logEditError("Failed to edit media caption", err) + } else { + updated = true + } } } else { // Текстовое сообщение - редактируем текст @@ -345,11 +380,88 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { }, ) if err != nil { - log.Error().Err(err).Msg("Failed to edit message text") + logEditError("Failed to edit message text", err) } else { - e.previousHasMedia = currentHasMedia + updated = true } } + + if updated { + e.previousHasMedia = currentHasMedia + e.previousMediaType = e.MediaType + e.previousMediaFileID = e.MediaFileID + } +} + +func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) { + _, err := b.DeleteMessage(b.ChatID, messageID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } +} + +func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool { + switch { + case message.Photo != nil && len(message.Photo) > 0: + photo := message.Photo[len(message.Photo)-1] + e.MediaType = "photo" + e.MediaFileID = photo.FileID + case message.Video != nil: + e.MediaType = "video" + e.MediaFileID = message.Video.FileID + case message.Animation != nil: + e.MediaType = "animation" + e.MediaFileID = message.Animation.FileID + default: + return false + } + + e.MediaChanged = true + return true +} + +func (e *CreativeEditorFields) AddInviteButton(text string) { + e.Buttons = append(e.Buttons, InlineButton{ + Text: text, + URL: inviteLinkPlaceholder, + }) +} + +func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) { + e.Buttons = append(e.Buttons, InlineButton{ + Text: text, + URL: buttonURLPlaceholder, + }) +} + +func (e *CreativeEditorFields) UpdateLastButtonURL(url string) { + if len(e.Buttons) > 0 { + e.Buttons[len(e.Buttons)-1].URL = url + } +} + +func (e *CreativeEditorFields) ClearInputMode() { + e.InputMode = "" + e.PendingButtonType = "" +} + +func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) { + if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 { + lastButton := e.Buttons[len(e.Buttons)-1] + if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder { + e.Buttons = e.Buttons[:len(e.Buttons)-1] + e.UpdateCreativePreview(b) + } + } + + e.ClearInputMode() +} + +func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool { + if allowHTTP { + return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") + } + return strings.HasPrefix(url, "https://") } // ShowManagementPanel показывает панель управления креативом @@ -358,10 +470,13 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { // cancelCallback - callback для кнопки отмены // extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) { - text := `👆Превью креатива выше — так он будет выглядеть при отправке. + text := msgCreativeManagement -Выберите, что изменить:` + buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...) + e.ShowControlPanel(b, text, buttons) +} +func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { var buttons [][]echotron.InlineKeyboardButton // Блок редактирования контента @@ -369,7 +484,6 @@ func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText Button("✎ Текст", "edit_text"), Button("+ Медиа", "add_media"), )) - // Добавление кнопки сразу под основным блоком buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button"))) // Кнопка удаления медиа (если медиа добавлено) @@ -405,18 +519,19 @@ func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText } // Финальные кнопки + cancelLabel := "← " + cancelText if e.Text != nil { buttons = append(buttons, Row( - Button("← Назад", cancelCallback), - Button(confirmButtonText, confirmCallback), + Button(cancelLabel, cancelCallback), + Button(confirmText, confirmCallback), )) } else { buttons = append(buttons, Row( - Button("← Назад", cancelCallback), + Button(cancelLabel, cancelCallback), )) } - e.ShowControlPanel(b, text, buttons) + return buttons } // ShowControlPanel редактирует панель управления с произвольным текстом и кнопками @@ -428,211 +543,14 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! controlPanelMsgID := b.LastMessageID e.ControlPanelMessageID = &controlPanelMsgID - log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Control panel sent") } else { // Редактируем существующую панель - log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel") - // Временно заменяем LastMessageID на ID панели управления oldLastMessageID := b.LastMessageID b.LastMessageID = *e.ControlPanelMessageID + defer func() { b.LastMessageID = oldLastMessageID }() keyboard := Keyboard(buttons...) b.Edit(text, keyboard) - - // Восстанавливаем оригинальный LastMessageID - b.LastMessageID = oldLastMessageID - log.Info().Msg("Control panel updated") } } - -// GetFormattedText извлекает форматированный текст из сообщения с entities -func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string { - // Определяем откуда брать текст и entities - var text string - var entities []*echotron.MessageEntity - - if message.Text != "" { - text = message.Text - entities = message.Entities - } else if message.Caption != "" { - text = message.Caption - entities = message.CaptionEntities - } - - if text == "" { - return "" - } - - // Если нет entities - возвращаем текст как есть - if len(entities) == 0 { - return text - } - - type tagSpan struct { - open string - close string - start int - end int - len int - } - - // Преобразуем текст в руны для правильной работы с UTF-8 - runes := []rune(text) - - // Сначала находим все url entities, чтобы исключить перекрывающиеся форматирования - urlRanges := make([][2]int, 0) - // Также отмечаем диапазоны, которые нужно полностью исключить из текста (простые url ссылки) - skipRanges := make(map[[2]int]bool) - - for _, entity := range entities { - if entity.Type == "url" || entity.Type == "text_link" { - urlRanges = append(urlRanges, [2]int{entity.Offset, entity.Offset + entity.Length}) - // Для простых url (не text_link) с пригласительными ссылками - пропускаем текст URL - // Создаем пустой , а сам URL не сохраняем - if entity.Type == "url" { - entityText := "" - if entity.Offset+entity.Length <= len(runes) { - entityText = string(runes[entity.Offset : entity.Offset+entity.Length]) - } - if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") { - skipRanges[[2]int{entity.Offset, entity.Offset + entity.Length}] = true - } - } - } - } - - // Проверяем, находится ли entity внутри URL - isInsideURL := func(offset, length int) bool { - for _, urlRange := range urlRanges { - // Если entity полностью внутри URL (но не сам URL) - if offset > urlRange[0] && offset+length <= urlRange[1] { - return true - } - // Если entity пересекается с URL - if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) { - return true - } - } - return false - } - - var spans []tagSpan - for _, entity := range entities { - entityText := "" - if entity.Offset+entity.Length <= len(runes) { - entityText = string(runes[entity.Offset : entity.Offset+entity.Length]) - } - - // Пропускаем форматирование (bold, italic, etc), если оно находится внутри URL - if entity.Type != "url" && entity.Type != "text_link" && isInsideURL(entity.Offset, entity.Length) { - continue - } - - var openTag, closeTag string - switch entity.Type { - case "bold": - openTag, closeTag = "", "" - case "italic": - openTag, closeTag = "", "" - case "underline": - openTag, closeTag = "", "" - case "strikethrough": - openTag, closeTag = "", "" - case "code": - openTag, closeTag = "", "" - case "pre": - openTag, closeTag = "
", "
" - case "url": - // Конвертируем простые URL в HTML-ссылки - // Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег - if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") { - // Пригласительная ссылка - создаем пустой - // Текст ссылки будет пропущен при рендеринге (см. skipRanges) - openTag = `` - closeTag = "" - } else { - // Обычная ссылка - оставляем как есть (ссылка на себя) - openTag = fmt.Sprintf("", entityText) - closeTag = "" - } - case "text_link": - if entity.URL != "" { - // Проверяем, является ли URL пригласительной ссылкой - if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") { - openTag = `` - closeTag = "" - } else { - openTag = fmt.Sprintf("", entity.URL) - closeTag = "" - } - } - default: - _ = entityText - } - - if openTag != "" && closeTag != "" { - spans = append(spans, tagSpan{ - open: openTag, - close: closeTag, - start: entity.Offset, - end: entity.Offset + entity.Length, - len: entity.Length, - }) - } - } - - opens := make(map[int][]tagSpan) - closes := make(map[int][]tagSpan) - for _, span := range spans { - opens[span.start] = append(opens[span.start], span) - closes[span.end] = append(closes[span.end], span) - } - - // Функция для проверки, находится ли позиция в skipRanges - isInSkipRange := func(pos int) bool { - for skipRange := range skipRanges { - if pos >= skipRange[0] && pos < skipRange[1] { - return true - } - } - return false - } - - // Пишем текст один раз, вставляя HTML-теги по позициям - // и экранируя HTML-спецсимволы - var result strings.Builder - for i := 0; i <= len(runes); i++ { - if closing, ok := closes[i]; ok { - sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len }) - for _, span := range closing { - result.WriteString(span.close) - } - } - if opening, ok := opens[i]; ok { - sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len }) - for _, span := range opening { - result.WriteString(span.open) - } - } - if i < len(runes) { - // Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок) - if isInSkipRange(i) { - continue - } - // Экранируем HTML-спецсимволы - switch runes[i] { - case '<': - result.WriteString("<") - case '>': - result.WriteString(">") - case '&': - result.WriteString("&") - default: - result.WriteRune(runes[i]) - } - } - } - - return result.String() -} diff --git a/telegram_bot/screens/creative_editor_ui.go b/telegram_bot/screens/creative_editor_ui.go new file mode 100644 index 0000000..de25801 --- /dev/null +++ b/telegram_bot/screens/creative_editor_ui.go @@ -0,0 +1,101 @@ +package screens + +import ( + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" +) + +const msgEditCreativeText = ` +✎ Введите текст креатива: + +⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала + +Формат ссылки: +https://t.me/+xxx + +Пример: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn + +` +const msgAddButtonPrompt = ` ++ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам + +Ссылка на канал подставится автоматически при создании закупа. + +` +const msgAddMediaPrompt = ` ++ Добавить медиа + +Отправьте фото, видео или GIF + +Это медиа будет отображаться в креативе вместе с текстом + +` +const msgButtonURLPrompt = ` +✧ Введите URL для кнопки: + +URL должен начинаться с http:// или https:// + +Например: +https://example.com +https://t.me/yourchannel + +` +const msgInvalidURL = ` +❌ Неверный формат URL + +URL должен начинаться с http:// или https:// + +Попробуйте ещё раз. + +` + +func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) { + inviteLabel := "Ссылка на канал" + customLabel := "Своя ссылка" + if e.PendingButtonType == "invite" { + inviteLabel = "✓ Ссылка на канал" + } + if e.PendingButtonType == "custom" { + customLabel = "✓ Своя ссылка" + } + + e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{ + Row( + Button(inviteLabel, "button_type_invite"), + Button(customLabel, "button_type_custom"), + ), + Row(Button("« Отмена", "cancel_add_button")), + }) +} + +func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_edit")), + }) +} + +func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_media")), + }) +} + +func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_add_button")), + }) +} + +func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) { + e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{ + Row(Button("« Отмена", "cancel_add_button")), + }) +} diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go index 557f651..2eea943 100644 --- a/telegram_bot/screens/creatives.go +++ b/telegram_bot/screens/creatives.go @@ -150,11 +150,11 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { )) case data == "add_creative": - b.SetState(&AddCreative{ + b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{ ProjectID: s.ProjectID, WorkspaceID: s.WorkspaceID, BackState: s, - }, bot.EditMessage) + }}, bot.EditMessage) case strings.HasPrefix(data, "creative:"): // Открытие деталей креатива @@ -179,3 +179,5 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *Creatives) Exit() {} diff --git a/telegram_bot/screens/login.go b/telegram_bot/screens/login.go index 5bf6837..a398895 100644 --- a/telegram_bot/screens/login.go +++ b/telegram_bot/screens/login.go @@ -38,3 +38,5 @@ func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {} func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {} func (s *Login) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) } + +func (s *Login) Exit() {} diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go index a1243e9..499d35d 100644 --- a/telegram_bot/screens/main_menu.go +++ b/telegram_bot/screens/main_menu.go @@ -49,3 +49,5 @@ func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {} func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) } + +func (s *MainMenu) Exit() {} diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go index ce10a7d..e6eee45 100644 --- a/telegram_bot/screens/my_projects.go +++ b/telegram_bot/screens/my_projects.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "example.com/m/bot" "example.com/m/ui" @@ -18,6 +19,11 @@ type MyProjects struct { WorkspaceID string BackState bot.State OpenPurchases bool + + // Поля для управления фоновой горутиной polling + cancel context.CancelFunc + pollDone chan struct{} + lastProjectCount int } func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -70,6 +76,9 @@ func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { } s.renderProjects(b, mode, jwt) + + // Запускаем фоновую горутину для автообновления экрана + s.startPolling(b, jwt) } func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) { @@ -80,6 +89,9 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) return } + // Сохраняем количество проектов для polling + s.lastProjectCount = page.Total + var text string var buttons [][]echotron.InlineKeyboardButton headerTitle := "Мои проекты" @@ -245,3 +257,100 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *MyProjects) Exit() { + // Останавливаем polling горутину + if s.cancel != nil { + log.Info().Msg("MyProjects: cancelling polling goroutine") + s.cancel() + } + + // Ждем завершения горутины с таймаутом 100ms + // Это не критично - горутина сама завершится при следующем tick + if s.pollDone != nil { + select { + case <-s.pollDone: + log.Info().Msg("MyProjects: polling goroutine finished") + case <-time.After(100 * time.Millisecond): + log.Info().Msg("MyProjects: polling cleanup timeout, goroutine will finish in background") + } + } +} + +func (s *MyProjects) startPolling(b *bot.Bot, jwt string) { + // Останавливаем предыдущую горутину если она есть + if s.cancel != nil { + s.cancel() + if s.pollDone != nil { + <-s.pollDone + } + } + + // Создаем новый контекст для горутины + ctx, cancel := context.WithCancel(context.Background()) + s.cancel = cancel + s.pollDone = make(chan struct{}) + + go s.pollProjects(ctx, b, jwt) +} + +func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) { + defer close(s.pollDone) + + // Адаптивный интервал: первые 30 секунд проверяем часто (1 сек), + // потом переходим на более редкий polling (3 сек) + // Это улучшает UX когда пользователь только добавил проект + intervals := []struct { + duration time.Duration + count int + }{ + {1 * time.Second, 30}, // Первые 30 секунд - каждую секунду + {3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно) + } + + currentIntervalIdx := 0 + tickCount := 0 + ticker := time.NewTicker(intervals[0].duration) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + log.Info().Msg("MyProjects polling: context cancelled, stopping") + return + + case <-ticker.C: + tickCount++ + + // Проверяем, нужно ли переключить интервал + if currentIntervalIdx < len(intervals)-1 { + if intervals[currentIntervalIdx].count > 0 && tickCount >= intervals[currentIntervalIdx].count { + currentIntervalIdx++ + tickCount = 0 + ticker.Reset(intervals[currentIntervalIdx].duration) + log.Info(). + Dur("new_interval", intervals[currentIntervalIdx].duration). + Msg("MyProjects polling: switching to slower interval") + } + } + + // Проверяем количество проектов + page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage) + if err != nil { + log.Error().Err(err).Msg("MyProjects polling: failed to get projects") + continue + } + + // Если количество изменилось - перерендериваем + if page.Total != s.lastProjectCount { + log.Info(). + Int("old_count", s.lastProjectCount). + Int("new_count", page.Total). + Msg("MyProjects polling: project count changed, re-rendering") + + s.lastProjectCount = page.Total + s.renderProjects(b, bot.EditMessage, jwt) + } + } + } +} diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go index f6cb637..23dcabb 100644 --- a/telegram_bot/screens/project_details.go +++ b/telegram_bot/screens/project_details.go @@ -202,3 +202,5 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *ProjectDetails) Exit() {} diff --git a/telegram_bot/screens/purchase_details.go b/telegram_bot/screens/purchase_details.go index f5f648d..b009b6a 100644 --- a/telegram_bot/screens/purchase_details.go +++ b/telegram_bot/screens/purchase_details.go @@ -269,3 +269,5 @@ func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *PurchaseDetails) Exit() {} diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go index bdb8136..791570e 100644 --- a/telegram_bot/screens/purchase_optional_details.go +++ b/telegram_bot/screens/purchase_optional_details.go @@ -347,6 +347,8 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } +func (s *PurchaseOptionalDetails) Exit() {} + func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) { switch key { case "placement_datetime": diff --git a/telegram_bot/screens/purchases.go b/telegram_bot/screens/purchases.go index 0b85c7c..d9f7bef 100644 --- a/telegram_bot/screens/purchases.go +++ b/telegram_bot/screens/purchases.go @@ -145,3 +145,5 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *Purchases) Exit() {} diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go index 84f408b..e1f055b 100644 --- a/telegram_bot/screens/select_channels_for_purchase.go +++ b/telegram_bot/screens/select_channels_for_purchase.go @@ -375,3 +375,5 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) { } } } + +func (s *SelectChannelsForPurchase) Exit() {} diff --git a/telegram_bot/screens/select_workspace.go b/telegram_bot/screens/select_workspace.go index e8a159b..013fe30 100644 --- a/telegram_bot/screens/select_workspace.go +++ b/telegram_bot/screens/select_workspace.go @@ -147,3 +147,5 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *SelectWorkspace) Exit() {} diff --git a/telegram_bot/ui/date_time_picker.go b/telegram_bot/ui/date_time_picker.go index 7e8e7b8..9c4e964 100644 --- a/telegram_bot/ui/date_time_picker.go +++ b/telegram_bot/ui/date_time_picker.go @@ -601,6 +601,8 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) { func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return } +func (s *DateTimePicker) Exit() {} + func parseDateTimeInput( input string, currentDate *time.Time, diff --git a/telegram_bot/ui/message_format.go b/telegram_bot/ui/message_format.go new file mode 100644 index 0000000..eedcdab --- /dev/null +++ b/telegram_bot/ui/message_format.go @@ -0,0 +1,304 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + + "github.com/NicoNex/echotron/v3" +) + +type htmlTag struct { + open string + close string +} + +var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{ + echotron.BoldEntity: {open: "", close: ""}, + echotron.ItalicEntity: {open: "", close: ""}, + echotron.UnderlineEntity: {open: "", close: ""}, + echotron.StrikethroughEntity: {open: "", close: ""}, + echotron.CodeEntity: {open: "", close: ""}, + echotron.PreEntity: {open: "
", close: "
"}, +} + +// FormatMessageHTML converts Telegram entities to HTML and escapes the rest. +// Invite links are normalized into placeholders for previews. +func FormatMessageHTML(message *echotron.Message) string { + if message == nil { + return "" + } + + // Telegram sends formatting as entities, so we rebuild the HTML from offsets. + text := message.Text + entities := message.Entities + if text == "" { + text = message.Caption + entities = message.CaptionEntities + } + + if text == "" { + return "" + } + + if len(entities) == 0 { + return EscapeHTML(text) + } + + type tagSpan struct { + open string + close string + start int + end int + len int + } + + runes := []rune(text) + positions := make([]int, len(runes)+1) + utf16Count := 0 + for i, r := range runes { + positions[i] = utf16Count + if r > 0xFFFF { + utf16Count += 2 + } else { + utf16Count++ + } + } + positions[len(runes)] = utf16Count + + utf16ToRuneIndex := func(utf16Index int) (int, bool) { + i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index }) + if i < len(positions) && positions[i] == utf16Index { + return i, true + } + return 0, false + } + + toRuneRange := func(offset, length int) (int, int, bool) { + start, ok := utf16ToRuneIndex(offset) + if !ok { + return 0, 0, false + } + end, ok := utf16ToRuneIndex(offset + length) + if !ok || end > len(runes) || end < start { + return 0, 0, false + } + return start, end, true + } + + // URL ranges are used to prevent nested tags inside links. + urlRanges := make([][2]int, 0, len(entities)) + + for _, entity := range entities { + if entity == nil { + continue + } + if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity { + continue + } + start, end, ok := toRuneRange(entity.Offset, entity.Length) + if !ok { + continue + } + urlRanges = append(urlRanges, [2]int{start, end}) + if entity.Type == echotron.UrlEntity { + _ = string(runes[start:end]) + } + } + + isInsideURL := func(offset, length int) bool { + for _, urlRange := range urlRanges { + if offset > urlRange[0] && offset+length <= urlRange[1] { + return true + } + if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) { + return true + } + } + return false + } + + var spans []tagSpan + for _, entity := range entities { + if entity == nil { + continue + } + start, end, ok := toRuneRange(entity.Offset, entity.Length) + if !ok { + continue + } + + if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) { + continue + } + + var openTag, closeTag string + if tag, ok := simpleHTMLTags[entity.Type]; ok { + openTag, closeTag = tag.open, tag.close + } else if entity.Type == echotron.UrlEntity { + entityText := string(runes[start:end]) + if isInviteLink(entityText) { + openTag = `` + closeTag = "" + } else { + openTag = fmt.Sprintf("", entityText) + closeTag = "" + } + } else if entity.Type == echotron.TextLinkEntity { + if entity.URL != "" { + if isInviteLink(entity.URL) { + openTag = `` + closeTag = "" + } else { + openTag = fmt.Sprintf("", entity.URL) + closeTag = "" + } + } + } + + if openTag != "" && closeTag != "" { + spans = append(spans, tagSpan{ + open: openTag, + close: closeTag, + start: start, + end: end, + len: end - start, + }) + } + } + + opens := make(map[int][]tagSpan) + closes := make(map[int][]tagSpan) + for _, span := range spans { + opens[span.start] = append(opens[span.start], span) + closes[span.end] = append(closes[span.end], span) + } + + // Build the final text with tags inserted and HTML escaped. + var result strings.Builder + for i := 0; i <= len(runes); i++ { + if closing, ok := closes[i]; ok { + sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len }) + for _, span := range closing { + result.WriteString(span.close) + } + } + if opening, ok := opens[i]; ok { + sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len }) + for _, span := range opening { + result.WriteString(span.open) + } + } + if i < len(runes) { + switch runes[i] { + case '<': + result.WriteString("<") + case '>': + result.WriteString(">") + case '&': + result.WriteString("&") + default: + result.WriteRune(runes[i]) + } + } + } + + return normalizeHTMLTags(result.String()) +} + +// isInviteLink detects Telegram invite links used in creatives. +func isInviteLink(url string) bool { + return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/") +} + +func normalizeHTMLTags(input string) string { + if input == "" { + return input + } + + allowed := map[string]bool{ + "a": true, + "b": true, + "i": true, + "u": true, + "s": true, + "code": true, + "pre": true, + } + + var out strings.Builder + out.Grow(len(input)) + stack := make([]string, 0, 8) + + for i := 0; i < len(input); { + if input[i] != '<' { + out.WriteByte(input[i]) + i++ + continue + } + + end := strings.IndexByte(input[i:], '>') + if end == -1 { + out.WriteByte(input[i]) + i++ + continue + } + + end += i + tag := input[i+1 : end] + if tag == "" { + out.WriteString(input[i : end+1]) + i = end + 1 + continue + } + + isClosing := tag[0] == '/' + tagName := tag + if isClosing { + tagName = tag[1:] + } + if space := strings.IndexByte(tagName, ' '); space != -1 { + tagName = tagName[:space] + } + tagName = strings.TrimSpace(tagName) + + if !allowed[tagName] { + out.WriteString(input[i : end+1]) + i = end + 1 + continue + } + + if isClosing { + if len(stack) > 0 && stack[len(stack)-1] == tagName { + stack = stack[:len(stack)-1] + out.WriteString(input[i : end+1]) + } + } else { + stack = append(stack, tagName) + out.WriteString(input[i : end+1]) + } + + i = end + 1 + } + + for i := len(stack) - 1; i >= 0; i-- { + out.WriteString("") + } + + return out.String() +} + +// EscapeHTML escapes HTML special characters for safe output. +func EscapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode. +func SanitizeHTML(input string) string { + return normalizeHTMLTags(input) +}