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("")
+ out.WriteString(stack[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)
+}