This commit is contained in:
Artem Tsyrulnikov
2026-01-15 22:02:16 +03:00
parent 6ec26c96e3
commit 049024ebe8
72 changed files with 2417 additions and 2054 deletions

View File

@@ -59,20 +59,55 @@ uv run pytest tests/test_file.py::test_function_name
### Database Migrations ### Database Migrations
```bash ```bash
# Create a new migration (auto-generated based on model changes) # 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 # Apply migrations
make migrate-up # or: export $(cat .env | xargs) && aerich upgrade make migrate-up # or: source .env && aerich upgrade
# Rollback one migration # Rollback one migration
make migrate-down # or: export $(cat .env | xargs) && aerich downgrade make migrate-down # or: source .env && 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.
``` ```
**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 ## Architecture
### Layered Architecture ### Layered Architecture

View File

@@ -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="
)

View File

@@ -311,105 +311,6 @@ class Postgres(DatabaseBase, Database):
project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id) project = await domain.Project.get_or_none(channel_id=channel_id, workspace_id=workspace_id)
return project is not None 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: async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
return await domain.Creative.get_or_none( return await domain.Creative.get_or_none(
id=creative_id, project__workspace_id=workspace_id 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: async def delete_creative(self, creative_id: uuid.UUID) -> None:
await domain.Creative.filter(id=creative_id).delete() 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: async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
return await domain.Placement.get_or_none( return await domain.Placement.get_or_none(
id=placement_id, project__workspace_id=workspace_id 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( ).prefetch_related(
'project', 'placement',
'project__channel', 'placement__project',
'purchase_channel', 'placement__project__channel',
'purchase_channel__channel', 'placement__channel',
'creative', 'placement__creative',
'post', 'post',
'post__channel', 'post__channel',
) )
async def create_placement(self, placement: domain.Placement) -> None: async def get_workspace_publications(
await placement.save()
async def update_placement(self, placement: domain.Placement) -> None:
await placement.save()
async def get_workspace_placements(
self, self,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
@@ -471,75 +397,85 @@ class Postgres(DatabaseBase, Database):
allowed_project_ids: set[uuid.UUID] | None = None, allowed_project_ids: set[uuid.UUID] | None = None,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
) -> list[domain.Placement]: ) -> list[domain.Publication]:
query = domain.Placement.filter(project__workspace_id=workspace_id) query = domain.Publication.filter(placement__project__workspace_id=workspace_id)
if project_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: elif allowed_project_ids is not None:
if not allowed_project_ids: if not allowed_project_ids:
return [] 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: 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: if creative_id:
query = query.filter(creative_id=creative_id) query = query.filter(placement__creative_id=creative_id)
if date_from: if date_from:
query = query.filter(wanted_placement_date__gte=date_from) query = query.filter(created_at__gte=date_from)
if date_to: if date_to:
query = query.filter(wanted_placement_date__lte=date_to) query = query.filter(created_at__lte=date_to)
if not include_archived: if not include_archived:
query = query.filter(status=domain.PlacementStatus.ACTIVE) query = query.filter(status=domain.PublicationStatus.ACTIVE)
return ( return (
await query.prefetch_related( await query.prefetch_related(
'project', 'placement',
'project__channel', 'placement__project',
'purchase_channel', 'placement__project__channel',
'purchase_channel__channel', 'placement__channel',
'purchase_channel__purchase', 'placement__creative',
'creative',
'post', 'post',
'post__channel', 'post__channel',
) )
.order_by('-wanted_placement_date') .order_by('-created_at')
.all() .all()
) )
async def delete_placement(self, placement_id: uuid.UUID) -> None: async def create_publication(self, publication: domain.Publication) -> None:
await domain.Placement.filter(id=placement_id).delete() await publication.save()
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: async def update_publication(self, publication: domain.Publication) -> None:
return await domain.Placement.get_or_none( await publication.save()
purchase_channel__invite_link=invite_link
).prefetch_related('project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative')
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: async def create_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save() await subscription.save()
async def get_subscription_by_subscriber_and_placement( async def get_subscription_by_subscriber_and_publication(
self, telegram_user_id: uuid.UUID, placement_id: uuid.UUID self, telegram_user_id: uuid.UUID, publication_id: uuid.UUID
) -> domain.Subscription | None: ) -> domain.Subscription | None:
return await domain.Subscription.get_or_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: async def update_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save() await subscription.save()
async def get_subscriptions_for_placements( async def get_subscriptions_for_publications(
self, self,
placement_ids: list[uuid.UUID], publication_ids: list[uuid.UUID],
*, *,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
) -> list[domain.Subscription]: ) -> list[domain.Subscription]:
if not placement_ids: if not publication_ids:
return [] return []
query = domain.Subscription.filter(placement_id__in=placement_ids) query = domain.Subscription.filter(publication_id__in=publication_ids)
if date_from: if date_from:
query = query.filter(created_at__gte=date_from) query = query.filter(created_at__gte=date_from)
@@ -554,10 +490,10 @@ class Postgres(DatabaseBase, Database):
return ( return (
await domain.Subscription.filter( await domain.Subscription.filter(
telegram_user_id=telegram_user_id, telegram_user_id=telegram_user_id,
placement__project_id=project_id, publication__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE, status=domain.SubscriptionStatus.ACTIVE,
) )
.prefetch_related('placement', 'telegram_user') .prefetch_related('publication', 'telegram_user')
.all() .all()
) )
@@ -567,10 +503,10 @@ class Postgres(DatabaseBase, Database):
return ( return (
await domain.Subscription.filter( await domain.Subscription.filter(
telegram_user_id=telegram_user_id, telegram_user_id=telegram_user_id,
placement__project_id=project_id, publication__placement__project_id=project_id,
status=domain.SubscriptionStatus.ACTIVE, status=domain.SubscriptionStatus.ACTIVE,
) )
.prefetch_related('placement', 'telegram_user') .prefetch_related('publication', 'telegram_user')
.first() .first()
) )
@@ -625,43 +561,43 @@ class Postgres(DatabaseBase, Database):
return results return results
# Count methods # Count methods
async def count_placements_by_creative(self, creative_id: uuid.UUID) -> int: async def count_publications_by_creative(self, creative_id: uuid.UUID) -> int:
return await domain.Placement.filter(creative_id=creative_id).count() return await domain.Publication.filter(placement__creative_id=creative_id).count()
async def count_subscriptions_by_placement(self, placement_id: uuid.UUID) -> int: async def count_subscriptions_by_publication(self, publication_id: uuid.UUID) -> int:
return await domain.Subscription.filter(placement_id=placement_id).count() 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: if not creative_ids:
return {} return {}
from tortoise.functions import Count from tortoise.functions import Count
results = ( results = (
await domain.Placement.filter(creative_id__in=creative_ids) await domain.Publication.filter(placement__creative_id__in=creative_ids)
.group_by('creative_id') .group_by('placement__creative_id')
.annotate(count=Count('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} 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]: async def count_subscriptions_by_publication_batch(self, publication_ids: list[uuid.UUID]) -> dict[uuid.UUID, int]:
if not placement_ids: if not publication_ids:
return {} return {}
from tortoise.functions import Count from tortoise.functions import Count
results = ( results = (
await domain.Subscription.filter(placement_id__in=placement_ids) await domain.Subscription.filter(publication_id__in=publication_ids)
.group_by('placement_id') .group_by('publication_id')
.annotate(count=Count('id')) .annotate(count=Count('id'))
.values('placement_id', 'count') .values('publication_id', 'count')
) )
counts = {row['placement_id']: row['count'] for row in results} counts = {row['publication_id']: row['count'] for row in results}
return {pid: counts.get(pid, 0) for pid in placement_ids} return {pid: counts.get(pid, 0) for pid in publication_ids}
async def has_placements_for_creative(self, creative_id: uuid.UUID) -> bool: async def has_publications_for_creative(self, creative_id: uuid.UUID) -> bool:
return await domain.Placement.filter(creative_id=creative_id).exists() return await domain.Publication.filter(placement__creative_id=creative_id).exists()

View File

@@ -1,8 +1,7 @@
import logging import logging
import uuid
import aioboto3 import aioboto3 # type: ignore[import-untyped]
from botocore.config import Config from botocore.config import Config # type: ignore[import-untyped]
from pydantic import BaseModel from pydantic import BaseModel
from types_aiobotocore_s3.client import S3Client from types_aiobotocore_s3.client import S3Client
@@ -89,7 +88,7 @@ class S3(S3Storage):
config=self.boto_config, config=self.boto_config,
) as client: ) as client:
response = await client.get_object(Bucket=self.config.BUCKET_NAME, Key=key) 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}') log.info(f'Downloaded file from S3: {key}')
return data return data

View File

@@ -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.channels import channels_router
from src.controller.http_v1.creatives import creatives_router from src.controller.http_v1.creatives import creatives_router
from src.controller.http_v1.internal import internal_router from src.controller.http_v1.internal import internal_router
from src.controller.http_v1.placements import placements_router from src.controller.http_v1.placements import publications_router
from src.controller.http_v1.projects import projects_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.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.workspace_members import workspace_members_router
from src.controller.http_v1.workspaces import workspaces_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(internal_router)
api_v1_router.include_router(channels_router) api_v1_router.include_router(channels_router)
api_v1_router.include_router(projects_router) api_v1_router.include_router(projects_router)
api_v1_router.include_router(purchase_router) api_v1_router.include_router(placements_user_router) # User-managed placements
api_v1_router.include_router(creatives_router) api_v1_router.include_router(creatives_router)
api_v1_router.include_router(placements_router) api_v1_router.include_router(publications_router) # System-managed publications
api_v1_router.include_router(views_router) api_v1_router.include_router(views_router)
api_v1_router.include_router(analytics_router) api_v1_router.include_router(analytics_router)
api_v1_router.include_router(workspaces_router) api_v1_router.include_router(workspaces_router)

View File

@@ -1,3 +1,5 @@
from typing import Any
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
@@ -68,7 +70,7 @@ async def attach_channel_to_workspace(input: AttachChannelToWorkspaceRequest) ->
@internal_router.post('/telegram/updates') @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 формат)""" """Обрабатывает системные события от Golang бота (echotron формат)"""
import json import json
import logging 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())) 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""" """Обработка запроса на вступление в канал через invite link"""
import logging 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 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 import logging

View File

@@ -3,24 +3,25 @@ from typing import Annotated
from fastapi import Depends from fastapi import Depends
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload 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('') @publications_router.get('')
async def list_placements( async def list_publications(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
project_id: uuid.UUID | None = None, project_id: uuid.UUID | None = None,
placement_channel_id: uuid.UUID | None = None, placement_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> Page[dto.PlacementOutput]: ) -> dto.GetPublicationsOutput:
input = dto.GetPlacementsInput( """List all publications (system-managed actual posts) in workspace"""
input = dto.GetPublicationsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_id, project_id=project_id,
@@ -29,23 +30,24 @@ async def list_placements(
include_archived=include_archived, include_archived=include_archived,
) )
result = await deps.get_usecase().get_placements(input=input) result = await deps.get_usecase().get_publications(input=input)
return paginate(result) # type: ignore[no-any-return] return result
@placements_router.get('/{placement_id}') @publications_router.get('/{publication_id}')
async def get_placement( async def get_publication(
placement_id: uuid.UUID, publication_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput: ) -> dto.PublicationOutput:
input = dto.GetPlacementInput( """Get single publication by ID"""
placement_id=placement_id, input = dto.GetPublicationInput(
publication_id=publication_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_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.

View File

@@ -3,25 +3,26 @@ from typing import Annotated
from fastapi import Depends from fastapi import Depends
from fastapi.routing import APIRouter from fastapi.routing import APIRouter
from fastapi_pagination import Page, paginate
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload from src.adapter.jwt import JWTPayload
purchase_router = APIRouter( # Placement router - User-managed planned placements
prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase', placements_user_router = APIRouter(
tags=['purchase'], prefix='/workspaces/{workspace_id}/projects/{project_id}/placements',
tags=['placements'],
) )
@purchase_router.post('') @placements_user_router.post('')
async def create_purchase( async def create_placements(
request: dto.CreatePurchaseInput, request: dto.CreatePlacementsInput,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchaseOutput: ) -> dto.GetPlacementsOutput:
return await deps.get_usecase().create_purchase( """Create multiple placements for different channels (bulk creation)"""
return await deps.get_usecase().create_placements(
project_id=project_id, project_id=project_id,
workspace_id=workspace_id, workspace_id=workspace_id,
user_id=current_user.user_id, user_id=current_user.user_id,
@@ -29,32 +30,34 @@ async def create_purchase(
) )
@purchase_router.get('') @placements_user_router.get('')
async def get_purchases( async def get_placements(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> Page[dto.PurchaseOutput]: ) -> dto.GetPlacementsOutput:
input = dto.GetPurchasesInput( """Get all placements for a project"""
input = dto.GetPlacementsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_id, project_id=project_id,
) )
result = await deps.get_usecase().get_purchases(input=input) result = await deps.get_usecase().get_placements(input=input)
return paginate(result) # type: ignore[no-any-return] return result
@purchase_router.get('/{purchase_id}') @placements_user_router.get('/{placement_id}')
async def get_purchase( async def get_placement(
purchase_id: uuid.UUID, placement_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
project_id: uuid.UUID, project_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PurchaseOutput: ) -> dto.PlacementOutput:
input = dto.GetPurchaseInput( """Get single placement by ID"""
input = dto.GetPlacementInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
project_id=project_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)

View File

@@ -21,7 +21,7 @@ async def get_views_history(
to_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None,
) -> Page[dto.PostViewsHistoryOutput]: ) -> Page[dto.PostViewsHistoryOutput]:
input_data = dto.GetViewsHistoryInput( input_data = dto.GetViewsHistoryInput(
placement_id=placement_id, publication_id=placement_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id, workspace_id=workspace_id,
from_date=from_date, from_date=from_date,

View File

@@ -10,6 +10,8 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) 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: 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)

View File

@@ -16,24 +16,24 @@ __all__ = (
'Channel', 'Channel',
'Project', 'Project',
'ProjectStatus', 'ProjectStatus',
'Purchase', 'Placement',
'PurchaseChannel', 'PlacementStatus',
'PurchaseStatus', 'PlacementType',
'PurchaseChannelStatus', 'Publication',
'PublicationStatus',
'Creative', 'Creative',
'replace_invite_link_with_tag', 'replace_invite_link_with_tag',
'validate_media_size', 'validate_media_size',
'MAX_CREATIVE_MEDIA_BYTES', 'MAX_CREATIVE_MEDIA_BYTES',
'Placement',
'Post', 'Post',
'Subscription', 'Subscription',
'PostViewsHistory', 'PostViewsHistory',
'ChannelNotFound', 'ChannelNotFound',
'ProjectNotFound', 'ProjectNotFound',
'CreativeStatus', 'CreativeStatus',
'PlacementStatus',
'SubscriptionStatus', 'SubscriptionStatus',
'InviteLinkType', 'InviteLinkType',
'CostType',
'PostViewsAvailability', 'PostViewsAvailability',
'LoginToken', 'LoginToken',
'UserNotFound', 'UserNotFound',
@@ -48,8 +48,8 @@ __all__ = (
'LoginTokenAlreadyUsed', 'LoginTokenAlreadyUsed',
'ProjectNotFound', 'ProjectNotFound',
'ProjectChannelConflict', 'ProjectChannelConflict',
'PurchaseNotFound', 'PlacementNotFound',
'PurchaseChannelNotFound', 'PublicationNotFound',
'ChannelNotFound', 'ChannelNotFound',
'ChannelAlreadyExists', 'ChannelAlreadyExists',
'ChannelNoAdminRights', 'ChannelNoAdminRights',
@@ -59,12 +59,17 @@ __all__ = (
'CreativeInviteLinkNotFound', 'CreativeInviteLinkNotFound',
'CreativeMultipleInviteLinks', 'CreativeMultipleInviteLinks',
'CreativeMediaTooLarge', 'CreativeMediaTooLarge',
'PlacementNotFound',
'UserByUsernameNotFound', 'UserByUsernameNotFound',
) )
from .channel import Channel 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 ( from .error import (
ChannelAlreadyExists, ChannelAlreadyExists,
ChannelNoAdminRights, ChannelNoAdminRights,
@@ -78,10 +83,9 @@ from .error import (
LoginTokenExpired, LoginTokenExpired,
LoginTokenNotFound, LoginTokenNotFound,
PlacementNotFound, PlacementNotFound,
ProjectNotFound,
ProjectChannelConflict, ProjectChannelConflict,
PurchaseChannelNotFound, ProjectNotFound,
PurchaseNotFound, PublicationNotFound,
TelegramChannelNotFound, TelegramChannelNotFound,
UserByUsernameNotFound, UserByUsernameNotFound,
UserNotFound, UserNotFound,
@@ -93,20 +97,20 @@ from .error import (
WorkspaceNotFound, WorkspaceNotFound,
) )
from .login_token import LoginToken from .login_token import LoginToken
from .placement import Placement, PlacementStatus, PostViewsAvailability from .placement import PostViewsAvailability, Publication, PublicationStatus
from .post import Post from .post import Post
from .post_views_history import PostViewsHistory from .post_views_history import PostViewsHistory
from .project import Project, ProjectStatus from .project import Project, ProjectStatus
from .purchase import ( from .purchase import (
CostType,
InviteLinkType, InviteLinkType,
Purchase, Placement,
PurchaseChannel, PlacementStatus,
PurchaseChannelStatus, PlacementType,
PurchaseStatus,
) )
from .subscription import Subscription, SubscriptionStatus from .subscription import Subscription, SubscriptionStatus
from .user import User
from .telegram_user import TelegramUser from .telegram_user import TelegramUser
from .user import User
from .workspace import ( from .workspace import (
PermissionKey, PermissionKey,
PermissionScopeType, PermissionScopeType,

View File

@@ -47,16 +47,16 @@ def ProjectChannelConflict() -> HTTPException:
return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace') return HTTPException(status.HTTP_409_CONFLICT, 'Project channel already exists in target workspace')
def PurchaseNotFound(purchase_id: uuid.UUID | None = None) -> HTTPException: def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
if purchase_id is None: if placement_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase {purchase_id} 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: def PublicationNotFound(publication_id: uuid.UUID | None = None) -> HTTPException:
if purchase_channel_id is None: if publication_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase channel not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Publication not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase channel {purchase_channel_id} not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Publication {publication_id} not found')
def ChannelAlreadyExists(telegram_id: int) -> HTTPException: def ChannelAlreadyExists(telegram_id: int) -> HTTPException:
@@ -90,16 +90,10 @@ def CreativeMediaTooLarge(max_bytes: int) -> HTTPException:
def CreativeInUse(creative_id: uuid.UUID) -> HTTPException: def CreativeInUse(creative_id: uuid.UUID) -> HTTPException:
return HTTPException( return HTTPException(
status.HTTP_400_BAD_REQUEST, 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: def WorkspaceNotFound(workspace_id: uuid.UUID | None = None) -> HTTPException:
if workspace_id is None: if workspace_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found')

View File

@@ -10,10 +10,12 @@ if TYPE_CHECKING:
from .creative import Creative from .creative import Creative
from .post import Post from .post import Post
from .project import Project from .project import Project
from .purchase import PurchaseChannel from .purchase import Placement
__all__ = ['Publication', 'PublicationStatus', 'PostViewsAvailability']
class PlacementStatus(str, enum.Enum): class PublicationStatus(str, enum.Enum):
ACTIVE = 'active' # Пост найден и отслеживается ACTIVE = 'active' # Пост найден и отслеживается
ARCHIVED = 'archived' # Вручную архивировано ARCHIVED = 'archived' # Вручную архивировано
@@ -25,32 +27,34 @@ class PostViewsAvailability(str, enum.Enum):
MANUAL = 'manual' # Просмотры вводятся вручную MANUAL = 'manual' # Просмотры вводятся вручную
class Placement(TimestampedModel): class Publication(TimestampedModel):
"""Публикация, мониторимая системой (бывший Placement)"""
wanted_placement_date = fields.DatetimeField(db_column='placement_date') wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True) cost = fields.FloatField(null=True)
comment = fields.TextField(null=True) comment = fields.TextField(null=True)
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE) status = fields.CharEnumField(PublicationStatus, default=PublicationStatus.ACTIVE)
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Project', related_name='publications', on_delete=fields.CASCADE, index=True
) )
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Creative', related_name='publications', on_delete=fields.CASCADE, index=True
) )
purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] = fields.ForeignKeyField( placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField(
'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Placement', related_name='publications', on_delete=fields.CASCADE, index=True
) )
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField( post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
'models.Post', related_name='placements', on_delete=fields.SET_NULL, null=True, index=True 'models.Post', related_name='publications', on_delete=fields.SET_NULL, null=True, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
project_id: UUID project_id: UUID
creative_id: UUID creative_id: UUID
post_id: UUID | None post_id: UUID | None
purchase_channel_id: UUID placement_id: UUID
class Meta: class Meta:
table = 'placement' table = 'publication'

View File

@@ -4,8 +4,8 @@ from uuid import UUID
from tortoise import fields from tortoise import fields
from .purchase import InviteLinkType
from .base import TimestampedModel from .base import TimestampedModel
from .purchase import InviteLinkType
if TYPE_CHECKING: if TYPE_CHECKING:
from .channel import Channel from .channel import Channel

View File

@@ -11,10 +11,7 @@ if TYPE_CHECKING:
from .creative import Creative from .creative import Creative
from .project import Project from .project import Project
__all__ = ['Placement', 'PlacementStatus', 'PlacementType', 'InviteLinkType', 'CostType']
class PurchaseStatus(enum.StrEnum):
ACTIVE = 'active'
ARCHIVED = 'archived'
class CostType(enum.StrEnum): class CostType(enum.StrEnum):
@@ -22,7 +19,7 @@ class CostType(enum.StrEnum):
CPM = 'cpm' CPM = 'cpm'
class PurchaseChannelStatus(enum.StrEnum): class PlacementStatus(enum.StrEnum):
PLANNED = 'planned' PLANNED = 'planned'
APPROVED = 'approved' APPROVED = 'approved'
REJECTED = 'rejected' REJECTED = 'rejected'
@@ -30,66 +27,71 @@ class PurchaseChannelStatus(enum.StrEnum):
COMPLETED = 'completed' COMPLETED = 'completed'
class PurchaseType(enum.StrEnum): class PlacementType(enum.StrEnum):
SELF_PROMO = 'self_promo' SELF_PROMO = 'self_promo'
STANDARD = 'standard' 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): class InviteLinkType(enum.StrEnum):
PUBLIC = 'public' # открытая ссылка PUBLIC = 'public' # открытая ссылка
APPROVAL = 'approval' # с одобрением ботом APPROVAL = 'approval' # с одобрением ботом
class PurchaseChannel(TimestampedModel): class Placement(TimestampedModel):
status = fields.CharEnumField(PurchaseChannelStatus, default=PurchaseChannelStatus.PLANNED) """Размещение, управляемое пользователем (бывший PurchaseChannel + Purchase)"""
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.PLANNED)
placement_at = fields.DatetimeField(null=True) placement_at = fields.DatetimeField(null=True)
payment_at = fields.DatetimeField(null=True)
cost_type = fields.CharEnumField(CostType, null=True, max_length=8) cost_type = fields.CharEnumField(CostType, null=True, max_length=8)
cost_value = fields.FloatField(null=True) cost_value = fields.FloatField(null=True)
cost_before_bargain = 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) format = fields.TextField(null=True)
comment = fields.TextField(null=True) comment = fields.TextField(null=True)
invite_link = fields.CharField(max_length=512) invite_link = fields.CharField(max_length=512)
invite_link_type = fields.CharEnumField(InviteLinkType) invite_link_type = fields.CharEnumField(InviteLinkType)
purchase: fields.ForeignKeyRelation['Purchase'] = fields.ForeignKeyField( project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Purchase', related_name='channels', on_delete=fields.CASCADE, index=True '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( 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: if TYPE_CHECKING:
purchase_id: uuid.UUID project_id: uuid.UUID
creative_id: uuid.UUID
channel_id: uuid.UUID channel_id: uuid.UUID
class Meta: class Meta:
table = 'purchase_channel' table = 'placement'
unique_together = (('purchase_id', 'channel_id'),) 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):
# ...

View File

@@ -7,7 +7,7 @@ from tortoise import fields
from .base import TimestampedModel from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .placement import Placement from .placement import Publication
from .telegram_user import TelegramUser from .telegram_user import TelegramUser
@@ -21,17 +21,17 @@ class Subscription(TimestampedModel):
status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE) status = fields.CharEnumField(SubscriptionStatus, default=SubscriptionStatus.ACTIVE)
unsubscribed_at = fields.DatetimeField(null=True) unsubscribed_at = fields.DatetimeField(null=True)
placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( publication: fields.ForeignKeyRelation['Publication'] = fields.ForeignKeyField(
'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True 'models.Publication', related_name='subscriptions', on_delete=fields.CASCADE, index=True
) )
telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField( telegram_user: fields.ForeignKeyRelation['TelegramUser'] = fields.ForeignKeyField(
'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True 'models.TelegramUser', related_name='subscriptions', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
placement_id: UUID publication_id: UUID
telegram_user_id: UUID telegram_user_id: UUID
class Meta: class Meta:
table = 'subscription' table = 'subscription'
unique_together = (('placement_id', 'telegram_user_id'),) unique_together = (('publication_id', 'telegram_user_id'),)

View File

@@ -11,8 +11,8 @@ from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .channel import Channel from .channel import Channel
from .creative import Creative from .creative import Creative
from .placement import Placement
from .project import Project from .project import Project
from .purchase import Placement
from .user import User from .user import User

View File

@@ -17,16 +17,18 @@ __all__ = (
'GetChannelsInput', 'GetChannelsInput',
'GetChannelsOutput', 'GetChannelsOutput',
'AttachChannelToWorkspaceInput', 'AttachChannelToWorkspaceInput',
'PurchaseOutput', 'PlacementOutput',
'PurchaseChannelOutput', 'PlacementDetails',
'PurchaseDetails',
'PurchaseChannelDetails',
'CostInfo', 'CostInfo',
'CreatePurchaseInput', 'CreatePlacementsInput',
'CreatePurchaseChannelInput', 'CreatePlacementChannelInput',
'GetPurchasesInput', 'GetPlacementsInput',
'GetPurchasesOutput', 'GetPlacementsOutput',
'GetPurchaseInput', 'GetPlacementInput',
'PublicationOutput',
'GetPublicationsInput',
'GetPublicationsOutput',
'GetPublicationInput',
'CreativeButton', 'CreativeButton',
'CreativeOutput', 'CreativeOutput',
'GetCreativesInput', 'GetCreativesInput',
@@ -35,10 +37,6 @@ __all__ = (
'CreateCreativeInput', 'CreateCreativeInput',
'UpdateCreativeInput', 'UpdateCreativeInput',
'DeleteCreativeInput', 'DeleteCreativeInput',
'PlacementOutput',
'GetPlacementsInput',
'GetPlacementsOutput',
'GetPlacementInput',
'PostViewsHistoryOutput', 'PostViewsHistoryOutput',
'GetViewsHistoryInput', 'GetViewsHistoryInput',
'GetViewsHistoryOutput', 'GetViewsHistoryOutput',
@@ -133,7 +131,12 @@ from .creative import (
GetCreativesOutput, GetCreativesOutput,
UpdateCreativeInput, UpdateCreativeInput,
) )
from .placement import GetPlacementInput, GetPlacementsInput, GetPlacementsOutput, PlacementOutput from .placement import (
GetPublicationInput,
GetPublicationsInput,
GetPublicationsOutput,
PublicationOutput,
)
from .project import ( from .project import (
ArchiveProjectInput, ArchiveProjectInput,
ChannelBotPermissions, ChannelBotPermissions,
@@ -149,15 +152,13 @@ from .project import (
) )
from .purchase import ( from .purchase import (
CostInfo, CostInfo,
CreatePurchaseChannelInput, CreatePlacementChannelInput,
CreatePurchaseInput, CreatePlacementsInput,
GetPurchaseInput, GetPlacementInput,
GetPurchasesInput, GetPlacementsInput,
GetPurchasesOutput, GetPlacementsOutput,
PurchaseChannelDetails, PlacementDetails,
PurchaseChannelOutput, PlacementOutput,
PurchaseDetails,
PurchaseOutput,
) )
from .user import UserOutput from .user import UserOutput
from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput from .validate_login_token import ValidateLoginTokenInput, ValidateLoginTokenOutput

View File

@@ -6,28 +6,29 @@ import pydantic
from src import domain from src import domain
class PlacementOutput(pydantic.BaseModel): class PublicationOutput(pydantic.BaseModel):
"""Публикация - фактический пост, мониторимый системой"""
id: uuid.UUID id: uuid.UUID
project_id: uuid.UUID project_id: uuid.UUID
project_channel_title: str | None project_channel_title: str | None
placement_channel_id: uuid.UUID placement_channel_id: uuid.UUID # Канал, где размещен пост
placement_channel_title: str | None placement_channel_title: str | None
creative_id: uuid.UUID creative_id: uuid.UUID
creative_name: str creative_name: str
placement_date: datetime.datetime placement_date: datetime.datetime # wanted_placement_date
cost: float | None cost: float | None
comment: str | None comment: str | None
ad_post_url: str | None ad_post_url: str | None
invite_link_type: domain.purchase.InviteLinkType invite_link_type: domain.purchase.InviteLinkType
invite_link: str invite_link: str
status: domain.PlacementStatus status: domain.PublicationStatus
subscriptions_count: int subscriptions_count: int
purchase_id: uuid.UUID | None = None placement_id: uuid.UUID # Ссылка на Placement
purchase_channel_id: uuid.UUID | None = None
created_at: datetime.datetime created_at: datetime.datetime
class GetPlacementsInput(pydantic.BaseModel): class GetPublicationsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
project_id: uuid.UUID | None = None project_id: uuid.UUID | None = None
@@ -36,31 +37,31 @@ class GetPlacementsInput(pydantic.BaseModel):
include_archived: bool = False include_archived: bool = False
class GetPlacementsOutput(pydantic.BaseModel): class GetPublicationsOutput(pydantic.BaseModel):
placements: list[PlacementOutput] publications: list[PublicationOutput]
class GetPlacementInput(pydantic.BaseModel): class GetPublicationInput(pydantic.BaseModel):
placement_id: uuid.UUID publication_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
class CreatePlacementInput(pydantic.BaseModel): class CreatePublicationInput(pydantic.BaseModel):
purchase_channel_id: uuid.UUID placement_id: uuid.UUID
placement_date: datetime.datetime placement_date: datetime.datetime
cost: float | None = None cost: float | None = None
comment: str | None = None comment: str | None = None
class UpdatePlacementInput(pydantic.BaseModel): class UpdatePublicationInput(pydantic.BaseModel):
placement_date: datetime.datetime | None = None placement_date: datetime.datetime | None = None
cost: float | None = None cost: float | None = None
comment: str | None = None comment: str | None = None
status: domain.PlacementStatus | None = None status: domain.PublicationStatus | None = None
class DeletePlacementInput(pydantic.BaseModel): class DeletePublicationInput(pydantic.BaseModel):
placement_id: uuid.UUID publication_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID

View File

@@ -3,7 +3,7 @@ import uuid
import pydantic 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 from .channel import ChannelOutput
@@ -13,66 +13,55 @@ class CostInfo(pydantic.BaseModel):
value: float value: float
class PurchaseDetails(pydantic.BaseModel): class PlacementDetails(pydantic.BaseModel):
placement_at: datetime.datetime | None = None placement_at: datetime.datetime | None = None
payment_at: datetime.datetime | None = None payment_at: datetime.datetime | None = None
cost: CostInfo | None = None cost: CostInfo | None = None
cost_before_bargain: float | None = None cost_before_bargain: float | None = None
purchase_type: PurchaseType | None = None placement_type: PlacementType | None = None
format: str | None = None format: str | None = None
comment: str | None = None comment: str | None = None
class PurchaseChannelDetails(pydantic.BaseModel): class PlacementOutput(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):
id: uuid.UUID id: uuid.UUID
status: PurchaseChannelStatus status: PlacementStatus
comment: str | None = None comment: str | None = None
invite_link: str invite_link: str
invite_link_type: InviteLinkType invite_link_type: InviteLinkType
channel: ChannelOutput channel: ChannelOutput
details: PurchaseChannelDetails | None = None details: PlacementDetails | None = None
class PurchaseOutput(pydantic.BaseModel): class CreatePlacementChannelInput(pydantic.BaseModel):
id: uuid.UUID """Input для создания одного placement (channel + детали)"""
status: PurchaseStatus
creative_id: uuid.UUID
channels: list[PurchaseChannelOutput]
details: PurchaseDetails | None = None
class CreatePurchaseChannelInput(pydantic.BaseModel):
username: str = pydantic.Field(pattern=r'^[^@]') username: str = pydantic.Field(pattern=r'^[^@]')
status: PurchaseChannelStatus | None = None status: PlacementStatus | None = None
comment: str | 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 creative_id: uuid.UUID
channels: list[CreatePurchaseChannelInput] channels: list[CreatePlacementChannelInput]
details: PurchaseDetails | None = None details: PlacementDetails | None = None # Общие детали для всех placements
class GetPurchasesInput(pydantic.BaseModel): class GetPlacementsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
project_id: uuid.UUID project_id: uuid.UUID
class GetPurchasesOutput(pydantic.BaseModel): class GetPlacementsOutput(pydantic.BaseModel):
purchases: list[PurchaseOutput] placements: list[PlacementOutput]
class GetPurchaseInput(pydantic.BaseModel): class GetPlacementInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
project_id: uuid.UUID project_id: uuid.UUID
purchase_id: uuid.UUID placement_id: uuid.UUID

View File

@@ -15,9 +15,9 @@ class PostViewsHistoryOutput(pydantic.BaseModel):
class GetViewsHistoryInput(pydantic.BaseModel): class GetViewsHistoryInput(pydantic.BaseModel):
"""Получить историю просмотров для закупа.""" """Получить историю просмотров для публикации."""
placement_id: uuid.UUID publication_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID workspace_id: uuid.UUID
from_date: datetime.datetime | None = None # Фильтр: с какой даты from_date: datetime.datetime | None = None # Фильтр: с какой даты

View File

@@ -16,7 +16,7 @@ from src.adapter.s3 import S3
from src.adapter.telegram_bot import TelegramBot from src.adapter.telegram_bot import TelegramBot
from src.config import settings from src.config import settings
from src.controller.http_v1 import api_router 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 from src.usecase import Usecase
@@ -24,11 +24,11 @@ from src.usecase import Usecase
async def lifespan(_: FastAPI) -> AsyncGenerator[None]: async def lifespan(_: FastAPI) -> AsyncGenerator[None]:
await postgres.connect() await postgres.connect()
await s3.connect() await s3.connect()
await fetch_placement_post_worker.start() await fetch_publication_post_worker.start()
yield yield
await fetch_placement_post_worker.stop() await fetch_publication_post_worker.stop()
await s3.close() await s3.close()
await postgres.close() await postgres.close()
@@ -51,7 +51,7 @@ usecase = Usecase(
) )
deps.set_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( app = FastAPI(
lifespan=lifespan, lifespan=lifespan,

View File

@@ -26,9 +26,9 @@ from .creative.delete_creative import delete_creative
from .creative.get_creative import get_creative from .creative.get_creative import get_creative
from .creative.get_creatives import get_creatives from .creative.get_creatives import get_creatives
from .creative.update_creative import update_creative from .creative.update_creative import update_creative
from .placement.fetch_placement_post_cycle import fetch_placement_post_cycle from .placement.fetch_publication_post_cycle import fetch_publication_post_cycle
from .placement.get_placement import get_placement from .placement.get_publication import get_publication
from .placement.get_placements import get_placements from .placement.get_publications import get_publications
from .project.archive_project import archive_project from .project.archive_project import archive_project
from .project.delete_project import delete_project from .project.delete_project import delete_project
from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id
@@ -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.tg_add_project import tg_add_project
from .project.update_project_invite_link_type import update_project_invite_link_type from .project.update_project_invite_link_type import update_project_invite_link_type
from .project.update_project_permissions import update_project_permissions from .project.update_project_permissions import update_project_permissions
from .purchase.create_purchase import create_purchase from .purchase.create_placements import create_placements
from .purchase.get_purchase import get_purchase from .purchase.get_placement import get_placement_user
from .purchase.get_purchases import get_purchases from .purchase.get_placements import get_placements
from .subscription.handle_subscription import handle_subscription from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription from .subscription.handle_unsubscription import handle_unsubscription
from .views.get_views_history import get_views_history from .views.get_views_history import get_views_history
@@ -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 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 update_placement_user(self, placement: domain.Placement) -> None: ...
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 get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ... async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ...
@@ -223,9 +198,10 @@ class Database(typing.Protocol):
async def delete_creative(self, creative_id: UUID) -> None: ... 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, self,
workspace_id: UUID, workspace_id: UUID,
project_id: UUID | None = None, project_id: UUID | None = None,
@@ -235,27 +211,28 @@ class Database(typing.Protocol):
allowed_project_ids: set[UUID] | None = None, allowed_project_ids: set[UUID] | None = None,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: 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 create_subscription(self, subscription: domain.Subscription) -> None: ...
async def get_subscription_by_subscriber_and_placement( async def get_subscription_by_subscriber_and_publication(
self, telegram_user_id: UUID, placement_id: UUID self, telegram_user_id: UUID, publication_id: UUID
) -> domain.Subscription | None: ... ) -> domain.Subscription | None: ...
async def update_subscription(self, subscription: 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, self,
placement_ids: list[UUID], publication_ids: list[UUID],
*, *,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None, date_to: datetime.datetime | None = None,
@@ -270,15 +247,17 @@ class Database(typing.Protocol):
) -> domain.Subscription | None: ... ) -> domain.Subscription | None: ...
# Count methods # 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 # Post methods
async def create_post(self, post: domain.Post) -> None: ... async def create_post(self, post: domain.Post) -> None: ...
@@ -409,15 +388,6 @@ class Usecase:
return workspace 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 validate_login_token = validate_login_token
create_telegram_login_token = create_telegram_login_token create_telegram_login_token = create_telegram_login_token
get_jwt_by_telegram_id = get_jwt_by_telegram_id get_jwt_by_telegram_id = get_jwt_by_telegram_id
@@ -434,17 +404,20 @@ class Usecase:
get_channels = get_channels get_channels = get_channels
get_channel = get_channel get_channel = get_channel
attach_channel_to_workspace = attach_channel_to_workspace attach_channel_to_workspace = attach_channel_to_workspace
create_purchase = create_purchase # Placement (user-managed) use cases
get_purchases = get_purchases create_placements = create_placements
get_purchase = get_purchase get_placements = get_placements
get_placement_user = get_placement_user
# Creative use cases
get_creatives = get_creatives get_creatives = get_creatives
get_creative = get_creative get_creative = get_creative
create_creative = create_creative create_creative = create_creative
update_creative = update_creative update_creative = update_creative
delete_creative = delete_creative delete_creative = delete_creative
get_placements = get_placements # Publication (system-managed) use cases
get_placement = get_placement get_publications = get_publications
fetch_placement_post_cycle = fetch_placement_post_cycle get_publication = get_publication
fetch_publication_post_cycle = fetch_publication_post_cycle
handle_subscription = handle_subscription handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription handle_unsubscription = handle_unsubscription
get_views_history = get_views_history get_views_history = get_views_history

View File

@@ -25,32 +25,24 @@ async def get_channel_analytics(
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) raise domain.ProjectNotFound(input.project_id)
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
purchase_channels = await self.database.get_purchase_channels_by_project(project.id)
channels = [pc.channel for pc in purchase_channels]
allowed_project_filter = None allowed_project_filter = None
else: else:
allowed_project_filter = allowed_project_ids 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.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
allowed_project_ids=allowed_project_filter, allowed_project_ids=allowed_project_filter,
) )
if input.project_id is None and allowed_project_ids is not None: # Collect unique channels from placements
channel_map: dict[UUID, domain.Channel] = {} channel_map: dict[UUID, domain.Channel] = {}
for placement in placements: for publication in placements:
purchase_channel = placement.purchase_channel placement = publication.placement
if purchase_channel and purchase_channel.channel: if placement and placement.channel:
channel_map[purchase_channel.channel_id] = purchase_channel.channel channel_map[placement.channel_id] = placement.channel
channels = list(channel_map.values()) channels = list(channel_map.values())
@dataclass @dataclass
class ChannelStats: class ChannelStats:
@@ -67,22 +59,22 @@ async def get_channel_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in placements] 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: for publication in placements:
purchase_channel = p.purchase_channel placement = publication.placement
if not purchase_channel: if not placement:
continue continue
stats = channel_stats.get(purchase_channel.channel_id) stats = channel_stats.get(placement.channel_id)
if stats is None: if stats is None:
continue continue
stats.total_cost += p.cost if p.cost is not None else 0 stats.total_cost += publication.cost if publication.cost is not None else 0
stats.total_subscriptions += subscriptions_counts.get(p.id, 0) stats.total_subscriptions += subscriptions_counts.get(publication.id, 0)
# Get views from batch data # Get views from batch data
if p.post and p.post.id in views_map: if publication.post and publication.post.id in views_map:
views_count = views_map[p.post.id][0] views_count = views_map[publication.post.id][0]
stats.total_views += views_count stats.total_views += views_count
stats.placements_count += 1 stats.placements_count += 1

View File

@@ -33,7 +33,7 @@ async def get_creatives_analytics(
include_archived=False, include_archived=False,
allowed_project_ids=allowed_project_ids, allowed_project_ids=allowed_project_ids,
) )
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_publications(
input.workspace_id, input.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -55,7 +55,7 @@ async def get_creatives_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in placements] 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: for p in placements:
stats = creative_stats.get(p.creative_id) stats = creative_stats.get(p.creative_id)

View File

@@ -55,7 +55,7 @@ async def get_overview_analytics(
period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1) period_length = raw_duration if raw_duration.total_seconds() > 0 else datetime.timedelta(days=1)
previous_period_start = input.date_from - period_length 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, input.workspace_id,
project_id=input.project_id, project_id=input.project_id,
include_archived=False, include_archived=False,
@@ -64,18 +64,18 @@ async def get_overview_analytics(
date_to=input.date_to, date_to=input.date_to,
) )
current_placements: list[domain.Placement] = [] current_placements: list[domain.Publication] = []
previous_placements: list[domain.Placement] = [] previous_placements: list[domain.Publication] = []
for placement in placements: for publication in placements:
placement_date = placement.wanted_placement_date placement_date = publication.wanted_placement_date
if placement_date >= input.date_from and placement_date <= input.date_to: 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: 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] 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 placement_ids, date_from=previous_period_start, date_to=input.date_to
) )
@@ -89,30 +89,30 @@ async def get_overview_analytics(
continue continue
if created_at >= input.date_from and created_at <= input.date_to: 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 subs_per_day_current[created_at.date()] += 1
elif created_at >= previous_period_start and created_at < input.date_from: 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] 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 {} views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
def _aggregate_totals( 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]: ) -> tuple[float, int, int]:
total_cost = 0.0 total_cost = 0.0
total_subscriptions = 0 total_subscriptions = 0
total_views = 0 total_views = 0
for placement in placement_list: for publication in placement_list:
if placement.cost is not None: if publication.cost is not None:
total_cost += placement.cost total_cost += publication.cost
subs = subs_per_placement.get(placement.id, 0) subs = subs_per_placement.get(publication.id, 0)
total_subscriptions += subs total_subscriptions += subs
if placement.post and placement.post.id in views_map: if publication.post and publication.post.id in views_map:
total_views += views_map[placement.post.id][0] total_views += views_map[publication.post.id][0]
return total_cost, total_subscriptions, total_views 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_spending_map: dict[UUID, float] = defaultdict(float)
project_meta: dict[UUID, domain.Project] = {} project_meta: dict[UUID, domain.Project] = {}
for placement in current_placements: for publication in current_placements:
placement_day = placement.wanted_placement_date.date() placement_day = publication.wanted_placement_date.date()
project_meta[placement.project_id] = placement.project 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 cost_per_day[placement_day] += cost_value
project_spending_map[placement.project_id] += cost_value project_spending_map[publication.project_id] += cost_value
subs = subs_per_placement_current.get(placement.id, 0) subs = subs_per_placement_current.get(publication.id, 0)
purchase_channel = placement.purchase_channel placement = publication.placement
if purchase_channel: if placement:
channel_id = purchase_channel.channel_id channel_id = placement.channel_id
if channel_id not in channel_stats: 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_cost += cost_value
channel_stats[channel_id].total_subs += subs channel_stats[channel_id].total_subs += subs

View File

@@ -37,7 +37,7 @@ async def get_placements_analytics(
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
allowed_project_ids = None allowed_project_ids = None
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_publications(
input.workspace_id, input.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -50,15 +50,15 @@ async def get_placements_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in placements] 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 [ return [
dto.PlacementAnalyticsOutput( dto.PlacementAnalyticsOutput(
id=p.id, id=p.id,
project_id=p.project_id, project_id=p.project_id,
project_channel_title=p.project.channel.title, project_channel_title=p.project.channel.title,
placement_channel_id=p.purchase_channel.channel_id, placement_channel_id=p.placement.channel_id,
placement_channel_title=p.purchase_channel.channel.title, placement_channel_title=p.placement.channel.title,
creative_id=p.creative_id, creative_id=p.creative_id,
creative_name=p.creative.name, creative_name=p.creative.name,
placement_date=p.wanted_placement_date, placement_date=p.wanted_placement_date,

View File

@@ -60,20 +60,20 @@ def _format_period_label(dt: datetime.datetime, grouping: dto.DateGrouping) -> s
raise ValueError('Invalid date grouping') 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: match date_grouping:
case dto.DateGroupingType.PLACEMENT_DATE: case dto.DateGroupingType.PLACEMENT_DATE:
return placement.wanted_placement_date return publication.wanted_placement_date
case dto.DateGroupingType.PURCHASE_DATE: case dto.DateGroupingType.PURCHASE_DATE:
if placement.purchase_channel and placement.purchase_channel.purchase: if publication.placement:
return placement.purchase_channel.purchase.created_at return publication.placement.created_at
return placement.wanted_placement_date # Fallback return publication.wanted_placement_date # Fallback
case dto.DateGroupingType.LINK_DATE: case dto.DateGroupingType.LINK_DATE:
if placement.purchase_channel: if publication.placement:
return placement.purchase_channel.created_at return publication.placement.created_at
return placement.wanted_placement_date # Fallback return publication.wanted_placement_date # Fallback
case _: case _:
return placement.wanted_placement_date return publication.wanted_placement_date
@dataclass @dataclass
@@ -96,7 +96,7 @@ def _calculate_metrics(
all_metrics = requested_metrics is None or len(requested_metrics) == 0 all_metrics = requested_metrics is None or len(requested_metrics) == 0
def should_include(metric: dto.ProjectMetrics) -> bool: 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() metrics = dto.ProjectMetricsData()
@@ -183,7 +183,7 @@ async def get_projects_analytics(
else: else:
allowed_project_ids = set(input.project_ids) allowed_project_ids = set(input.project_ids)
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_publications(
input.workspace_id, input.workspace_id,
project_id=None, project_id=None,
include_archived=False, include_archived=False,
@@ -194,28 +194,28 @@ async def get_projects_analytics(
# Фильтрация по датам на основе date_grouping # Фильтрация по датам на основе date_grouping
filtered_placements = [] filtered_placements = []
for placement in placements: for publication in placements:
grouping_date = _get_grouping_date(placement, input.date_grouping) grouping_date = _get_grouping_date(publication, input.date_grouping)
if input.date_from and grouping_date < input.date_from: if input.date_from and grouping_date < input.date_from:
continue continue
if input.date_to and grouping_date > input.date_to: if input.date_to and grouping_date > input.date_to:
continue continue
filtered_placements.append(placement) filtered_placements.append(publication)
# Batch fetch views data # Batch fetch views data
post_ids = [p.post.id for p in filtered_placements if p.post] 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 {} views_map = await self.database.get_latest_views_data_batch(post_ids) if post_ids else {}
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in filtered_placements] publication_ids = [p.id for p in filtered_placements]
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids) subscriptions_counts = await self.database.count_subscriptions_by_publication_batch(publication_ids)
# Группировка по периодам # Группировка по периодам
period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics) period_data: dict[str, PeriodMetrics] = defaultdict(PeriodMetrics)
total_metrics = PeriodMetrics() total_metrics = PeriodMetrics()
for placement in filtered_placements: for publication in filtered_placements:
grouping_date = _get_grouping_date(placement, input.date_grouping) grouping_date = _get_grouping_date(publication, input.date_grouping)
period = _format_period(grouping_date, input.grouping) period = _format_period(grouping_date, input.grouping)
pd = period_data[period] pd = period_data[period]
@@ -223,29 +223,29 @@ async def get_projects_analytics(
pd.purchases_count += 1 pd.purchases_count += 1
total_metrics.purchases_count += 1 total_metrics.purchases_count += 1
cost = placement.cost or 0.0 cost = publication.cost or 0.0
pd.total_cost += cost pd.total_cost += cost
total_metrics.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.total_subscriptions += subs_count
pd.clicks_count += subs_count pd.clicks_count += subs_count
total_metrics.total_subscriptions += subs_count total_metrics.total_subscriptions += subs_count
total_metrics.clicks_count += subs_count total_metrics.clicks_count += subs_count
views_count = 0 views_count = 0
if placement.post and placement.post.id in views_map: if publication.post and publication.post.id in views_map:
views_count = views_map[placement.post.id][0] views_count = views_map[publication.post.id][0]
pd.total_views += views_count pd.total_views += views_count
pd.reach_volume += views_count pd.reach_volume += views_count
total_metrics.total_views += views_count total_metrics.total_views += views_count
total_metrics.reach_volume += views_count total_metrics.reach_volume += views_count
# Расчет скидок # Расчет скидок
purchase_channel = placement.purchase_channel placement = publication.placement
if purchase_channel and purchase_channel.cost_before_bargain and purchase_channel.cost_before_bargain > cost: if placement and placement.cost_before_bargain and placement.cost_before_bargain > cost:
discount = purchase_channel.cost_before_bargain - cost discount = placement.cost_before_bargain - cost
discount_percent = (discount / purchase_channel.cost_before_bargain) * 100 if purchase_channel.cost_before_bargain > 0 else 0.0 discount_percent = (discount / placement.cost_before_bargain) * 100 if placement.cost_before_bargain > 0 else 0.0
pd.total_discounts += discount pd.total_discounts += discount
pd.discount_count += 1 pd.discount_count += 1

View File

@@ -46,7 +46,7 @@ async def get_spending_analytics(
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
allowed_project_ids = None allowed_project_ids = None
placements = await self.database.get_workspace_placements( placements = await self.database.get_workspace_publications(
input.workspace_id, input.workspace_id,
input.project_id, input.project_id,
include_archived=False, include_archived=False,
@@ -76,7 +76,7 @@ async def get_spending_analytics(
# Batch fetch subscriptions counts # Batch fetch subscriptions counts
placement_ids = [p.id for p in filtered] 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) period_data: dict[str, PeriodData] = defaultdict(PeriodData)

View File

@@ -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) context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
has_placements = await self.database.has_placements_for_creative(creative.id) has_publications = await self.database.has_publications_for_creative(creative.id)
if has_placements: if has_publications:
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id) log.warning('Creative %s is used in publications and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(input.creative_id) raise domain.CreativeInUse(input.creative_id)
media_key = creative.media_s3_key media_key = creative.media_s3_key

View File

@@ -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) 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( return dto.CreativeOutput(
id=creative.id, id=creative.id,

View File

@@ -25,7 +25,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> list[d
) )
creative_ids = [c.id for c in creatives] 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 [ return [
dto.CreativeOutput( dto.CreativeOutput(

View File

@@ -69,7 +69,7 @@ async def update_creative(
await self.database.update_creative(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( return dto.CreativeOutput(
id=creative.id, id=creative.id,

View File

@@ -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']

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -1,4 +1,3 @@
import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto

View File

@@ -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') log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
return return
if user.telegram_user is None: 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 return
project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id)

View File

@@ -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']

View File

@@ -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])

View File

@@ -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)

View File

@@ -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)

View File

@@ -2,7 +2,8 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import domain, dto from src import domain, dto
from .create_purchase import _build_purchase_output
from .create_placements import _build_placement_output
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -10,7 +11,8 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) 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( context = await self.ensure_workspace_permission(
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ 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) context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
purchase = await self.database.get_purchase(input.workspace_id, input.purchase_id) placements = await self.database.get_project_placements(input.workspace_id, project.id)
if not purchase or purchase.project_id != project.id: log.debug('Fetched %s placements for project %s', len(placements), project.id)
raise domain.PurchaseNotFound(input.purchase_id)
channels = await self.database.get_purchase_channels(purchase.id) return dto.GetPlacementsOutput(placements=[_build_placement_output(p) for p in placements])
return _build_purchase_output(purchase, channels)

View File

@@ -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

View File

@@ -17,9 +17,13 @@ async def handle_subscription(
first_name: str | None = None, first_name: str | None = None,
last_name: str | None = None, last_name: str | None = None,
) -> None: ) -> None:
placement = await self.database.get_placement_by_invite_link(invite_link) publication = await self.database.get_publication_by_invite_link(invite_link)
if not placement: if not publication:
log.warning('Placement not found for invite_link: %s', invite_link) 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 return
subscriber = await self.database.get_telegram_user(telegram_id=user_telegram_id) 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) await self.database.update_telegram_user(subscriber)
active_subscription = await self.database.get_active_subscription_by_subscriber_and_project( 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: if active_subscription:
# Пользователь уже подписан на канал через другой placement # Пользователь уже подписан на канал через другую публикацию
# Это не должно случиться (Telegram не даст подписаться дважды), # Это не должно случиться (Telegram не даст подписаться дважды),
# но если случилось - логируем и игнорируем # но если случилось - логируем и игнорируем
log.warning( log.warning(
'User %s (telegram_id: %s) already has active subscription to channel %s via placement %s, ' 'User %s (telegram_id: %s) already has active subscription to channel %s via publication %s, '
'ignoring new subscription attempt via placement %s', 'ignoring new subscription attempt via publication %s',
subscriber.id, subscriber.id,
user_telegram_id, user_telegram_id,
placement.project_id, publication.placement.project_id,
active_subscription.placement_id, active_subscription.publication_id,
placement.id, publication.id,
) )
return 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: if existing_sub and existing_sub.status == domain.SubscriptionStatus.UNSUBSCRIBED:
existing_sub.status = domain.SubscriptionStatus.ACTIVE existing_sub.status = domain.SubscriptionStatus.ACTIVE
@@ -65,24 +69,24 @@ async def handle_subscription(
await self.database.update_subscription(existing_sub) await self.database.update_subscription(existing_sub)
log.info( 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, subscriber.id,
user_telegram_id, user_telegram_id,
placement.id, publication.id,
) )
return return
subscription = domain.Subscription( subscription = domain.Subscription(
placement_id=placement.id, publication_id=publication.id,
telegram_user_id=subscriber.id, telegram_user_id=subscriber.id,
invite_link=invite_link, invite_link=invite_link,
) )
await self.database.create_subscription(subscription) await self.database.create_subscription(subscription)
log.info( 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, subscriber.id,
user_telegram_id, user_telegram_id,
placement.id, publication.id,
invite_link, invite_link,
) )

View File

@@ -39,8 +39,8 @@ async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_
await self.database.update_subscription(subscription) await self.database.update_subscription(subscription)
log.info( 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, subscriber.id,
user_telegram_id, user_telegram_id,
subscription.placement_id, subscription.publication_id,
) )

View File

@@ -14,18 +14,22 @@ async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) ->
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
) )
placement = await self.database.get_placement(input.workspace_id, input.placement_id) publication = await self.database.get_publication(input.workspace_id, input.publication_id)
if not placement: if not publication:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) log.warning('Publication %s not found for user %s', input.publication_id, input.user_id)
raise domain.PlacementNotFound(input.placement_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 [] return []
histories = await self.database.get_views_history( histories = await self.database.get_views_history(
placement.post.id, publication.post.id,
from_date=input.from_date, from_date=input.from_date,
to_date=input.to_date, to_date=input.to_date,
) )

View File

@@ -103,6 +103,11 @@ func (b *Bot) SetState(s State, mode RenderMode) {
Str("mode", fmt.Sprintf("%v", mode)). Str("mode", fmt.Sprintf("%v", mode)).
Msg("State transition") Msg("State transition")
// Вызываем Exit() у старого состояния для cleanup (например, отмены горутин)
if b.CurrentState != nil {
b.CurrentState.Exit()
}
b.CurrentState = s b.CurrentState = s
if b.exec != nil { if b.exec != nil {

View File

@@ -14,6 +14,7 @@ type State interface {
HandleCallback(*Bot, *echotron.Update) HandleCallback(*Bot, *echotron.Update)
HandleMessage(*Bot, *echotron.Update) HandleMessage(*Bot, *echotron.Update)
Handle(*Bot, *echotron.Update) Handle(*Bot, *echotron.Update)
Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин)
} }
type Session struct { type Session struct {

View File

@@ -43,3 +43,5 @@ func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) { func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) {
b.SetState(&MainMenu{}, bot.EditMessage) b.SetState(&MainMenu{}, bot.EditMessage)
} }
func (s *AcceptWorkspaceInvite) Exit() {}

View File

@@ -4,25 +4,30 @@ import (
"context" "context"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
"example.com/m/adapter/backend" "example.com/m/adapter/backend"
"example.com/m/bot" "example.com/m/bot"
"example.com/m/ui"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
type AddCreative struct { type AddCreativeCtx struct {
CreativeEditorFields // Встраивание общих полей CreativeEditorFields
ProjectID string ProjectID string
WorkspaceID string WorkspaceID string
BackState bot.State BackState bot.State
WaitingForCreative bool // Ждем пересылки сообщения с креативом
CreativeMessageSent bool // Креатив уже получен и показан
WaitingForTextEdit bool // Ждем редактирования текста креатива
} }
type AddCreativeStart struct{ ctx *AddCreativeCtx }
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
type AddCreativeInput struct{ ctx *AddCreativeCtx }
const msgWaitCreative = ` const msgWaitCreative = `
<b> Добавить креатив</b> <b> Добавить креатив</b>
@@ -31,15 +36,102 @@ const msgWaitCreative = `
• Обрабатываем любое сообщение Telegram • Обрабатываем любое сообщение Telegram
• Отправляйте сразу оформленный пост • Отправляйте сразу оформленный пост
На следующем шаге можно добавить кнопки или изменить контент На следующем шаге можно добавить кнопки или изменить контент
`
const msgConfirmCreativeFormat = `
<b> Добавляем этот креатив?</b>
<b>Название креатива:</b> %s
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>
`
const msgDownloadMediaError = `
<b>❌ Не удалось загрузить медиа</b>
Попробуйте удалить медиа и добавить заново.
`
const msgInviteLinkRequired = `
<b>❌ Ошибка валидации</b>
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример правильного текста:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
`
const msgInviteLinkTooMany = `
<b>❌ Слишком много ссылок</b>
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
У вас их несколько. Удалите лишние.
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
`
const msgCreateError = `
<b>❌ Ошибка создания</b>
Не удалось создать креатив.
Попробуйте ещё раз или вернитесь назад.
`
const msgCreativeCreated = `
<b>✅ Креатив успешно создан!</b>
📄 <b>Название:</b> %s
`
const msgCreativeCreatedButtons = `
🔘 <b>Кнопок добавлено:</b> %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"))) kb := Keyboard(Row(Button("« Отмена", "cancel")))
s.WaitingForCreative = true
b.Render(msgWaitCreative, kb, mode) 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 == "" { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return return
} }
@@ -48,413 +140,175 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch { switch {
case data == "cancel": case data == "cancel":
if s.BackState != nil { if s.ctx.BackState != nil {
b.SetState(s.BackState, bot.EditMessage) b.SetState(s.ctx.BackState, bot.EditMessage)
} }
case data == "edit_text": case data == "edit_text":
// Начинаем редактирование текста s.ctx.InputMode = inputModeText
s.WaitingForTextEdit = true b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
s.ShowControlPanel(b, `<b>✎ Введите текст креатива:</b>
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_edit")),
})
case data == "add_button": case data == "add_button":
// Начинаем процесс добавления кнопки s.ctx.InputMode = inputModeButtonText
s.WaitingForButtonText = true s.ctx.PendingButtonType = "invite"
s.PendingButtonType = "invite" b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
s.ShowControlPanel(b, `<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
Row(
Button("✓ Ссылка на канал", "button_type_invite"),
Button("Своя ссылка", "button_type_custom"),
),
Row(Button("« Отмена", "cancel_add_button")),
})
case data == "add_media": case data == "add_media":
// Начинаем процесс добавления медиа s.ctx.InputMode = inputModeMedia
s.WaitingForMedia = true b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
s.ShowControlPanel(b, `<b> Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_media")),
})
case data == "delete_media": case data == "delete_media":
// Удаляем медиа s.ctx.MediaType = ""
s.MediaType = "" s.ctx.MediaFileID = ""
s.MediaFileID = "" s.ctx.MediaChanged = true
s.MediaChanged = true s.ctx.UpdateCreativePreview(b)
s.UpdateCreativePreview(b) s.Enter(b, bot.EditMessage)
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, `<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]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, `<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]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")
}
case data == "confirm_create": case data == "confirm_create":
// Создаём креатив s.ctx.createCreative(b)
s.createCreative(b)
case strings.HasPrefix(data, "delete_button:"): case strings.HasPrefix(data, "delete_button:"):
// Удаляем кнопку по индексу indexStr := strings.TrimPrefix(data, "delete_button:")
parts := strings.Split(data, ":") index, err := strconv.Atoi(indexStr)
if len(parts) == 2 { if err != nil || index < 0 || index >= len(s.ctx.Buttons) {
var index int return
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")
}
}
}
} }
s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...)
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
default: default:
s.Enter(b, bot.NewMessage) 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 { if u.Message == nil {
return return
} }
// Шаг 1: Ждем пересылки креатива (любое сообщение) switch s.ctx.InputMode {
if s.WaitingForCreative { case inputModeText:
// Извлекаем данные из сообщения if u.Message.Text == "" {
s.extractCreativeFromMessage(u.Message) return
}
// Генерируем название из текста или дефолтное text := ui.FormatMessageHTML(u.Message)
if s.Name == nil { s.ctx.Text = &text
name := s.generateCreativeName() s.ctx.DeleteUserMessage(b, u.Message.ID)
s.Name = &name s.ctx.ClearInputMode()
} b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeButtonText:
// Удаляем сообщение пользователя
_, 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 {
if u.Message.Text == "" { if u.Message.Text == "" {
return return
} }
buttonText := u.Message.Text buttonText := u.Message.Text
s.CurrentButtonText = &buttonText s.ctx.DeleteUserMessage(b, u.Message.ID)
s.WaitingForButtonText = false
// Удаляем сообщение пользователя if s.ctx.PendingButtonType == "custom" {
_, err := b.DeleteMessage(b.ChatID, u.Message.ID) s.ctx.AddCustomButtonPlaceholder(buttonText)
if err != nil { s.ctx.UpdateCreativePreview(b)
log.Error().Err(err).Msg("Failed to delete user message") s.ctx.InputMode = inputModeButtonURL
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
return
} }
if s.PendingButtonType == "custom" { s.ctx.AddInviteButton(buttonText)
s.WaitingForButtonURL = true s.ctx.UpdateCreativePreview(b)
s.Buttons = append(s.Buttons, InlineButton{ s.ctx.PendingButtonType = ""
Text: buttonText, s.ctx.ClearInputMode()
URL: "https://example.com", b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
}) case inputModeButtonURL:
s.UpdateCreativePreview(b)
s.ShowControlPanel(b, `<b>✧ Введите URL для кнопки:</b>
URL должен начинаться с <code>https://</code>
<i>Например:</i>
<code>https://example.com</code>
<code>https://t.me/yourchannel</code>`, [][]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 {
if u.Message.Text == "" { if u.Message.Text == "" {
return return
} }
url := strings.TrimSpace(u.Message.Text)
s.ctx.DeleteUserMessage(b, u.Message.ID)
url := u.Message.Text if !s.ctx.IsValidButtonURL(url, true) {
s.ctx.ShowInvalidButtonURLPanel(b)
// Удаляем сообщение пользователя
_, 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, `<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>https://</code>
Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
return return
} }
if len(s.Buttons) > 0 { s.ctx.UpdateLastButtonURL(url)
s.Buttons[len(s.Buttons)-1].URL = 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.ctx.DeleteUserMessage(b, u.Message.ID)
s.CurrentButtonText = nil s.ctx.UpdateCreativePreview(b)
s.PendingButtonType = "" s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
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
} }
} }
func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {}
// extractCreativeFromMessage извлекает данные креатива из сообщения func (s *AddCreativeInput) Exit() {}
func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) {
// Извлекаем текст с форматированием (GetFormattedText теперь поддерживает и text и caption) func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) {
if message.Text != "" || message.Caption != "" { if message.Text != "" || message.Caption != "" {
text := s.GetFormattedText(message) text := ui.FormatMessageHTML(message)
if text != "" { if text != "" {
s.Text = &text s.Text = &text
} }
} }
// Извлекаем медиа s.SetMediaFromMessage(message)
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
}
// Извлекаем кнопки из ReplyMarkup
if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 { if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 {
for _, row := range message.ReplyMarkup.InlineKeyboard { for _, row := range message.ReplyMarkup.InlineKeyboard {
for _, button := range row { for _, button := range row {
// Берем только кнопки с URL
if button.URL != "" { if button.URL != "" {
s.Buttons = append(s.Buttons, InlineButton{ s.Buttons = append(s.Buttons, InlineButton{
Text: button.Text, Text: button.Text,
@@ -466,10 +320,8 @@ func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) {
} }
} }
// generateCreativeName генерирует название из текста или дефолтное func (s *AddCreativeCtx) generateCreativeName() string {
func (s *AddCreative) generateCreativeName() string {
if s.Text != nil && *s.Text != "" { if s.Text != nil && *s.Text != "" {
// Удаляем ВСЕ HTML теги с помощью regex
re := regexp.MustCompile(`<[^>]*>`) re := regexp.MustCompile(`<[^>]*>`)
cleanText := re.ReplaceAllString(*s.Text, "") cleanText := re.ReplaceAllString(*s.Text, "")
@@ -485,119 +337,28 @@ func (s *AddCreative) generateCreativeName() string {
return "Новый креатив" return "Новый креатив"
} }
// escapeHTML экранирует HTML-символы для безопасного вывода func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) {
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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. Отправляем/обновляем превью креатива
if s.CreativeMessageID == nil { if s.CreativeMessageID == nil {
s.SendCreativePreview(b) s.SendCreativePreview(b)
} else { } else {
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
} }
// 2. Формируем текст панели управления escapedName := ui.EscapeHTML(*s.Name)
// Экранируем HTML-символы в названии для безопасного вывода confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
escapedName := escapeHTML(*s.Name) buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel")
confirmText := fmt.Sprintf(`<b> Добавляем этот креатив?</b>
<b>Название креатива:</b> %s
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>`, escapedName)
// 3. Показываем панель с кнопками редактирования + сохранения
buttons := s.buildEditorButtons("✓ Сохранить", "Отмена")
s.ShowControlPanel(b, confirmText, buttons) s.ShowControlPanel(b, confirmText, buttons)
} }
// sendCreativeAndPanel отправляет превью креатива и панель управления func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
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("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start login", Keyboard())
return
}
var err error var err error
var mediaData []byte var mediaData []byte
if s.MediaFileID != "" { if s.MediaFileID != "" {
mediaData, err = b.DownloadFileBytes(s.MediaFileID) mediaData, err = b.DownloadFileBytes(s.MediaFileID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to download creative media") log.Error().Err(err).Msg("Failed to download creative media")
// Показываем ошибку через панель управления buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")
buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") s.ShowControlPanel(b, msgDownloadMediaError, buttons)
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново.", buttons)
return return
} }
} }
@@ -610,84 +371,41 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
}) })
} }
// Логируем что отправляем creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{
log.Info(). Name: *s.Name,
Str("name", *s.Name). Text: *s.Text,
Str("text", *s.Text). MediaType: s.MediaType,
Int("buttons_count", len(buttons)). MediaFileID: s.MediaFileID,
Interface("buttons", buttons). MediaData: mediaData,
Str("media_type", s.MediaType). Buttons: buttons,
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,
},
)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to create creative") log.Error().Err(err).Msg("Failed to create creative")
// Проверяем, это ошибка валидации инвайт-ссылки
errMsg := err.Error() errMsg := err.Error()
var userMsg string var userMsg string
if strings.Contains(errMsg, "Creative text must contain one invite link") { if strings.Contains(errMsg, "Creative text must contain one invite link") {
userMsg = `<b>❌ Ошибка валидации</b> userMsg = msgInviteLinkRequired
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример правильного текста:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>`
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") { } else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
userMsg = `<b>❌ Слишком много ссылок</b> userMsg = msgInviteLinkTooMany
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
У вас их несколько. Удалите лишние.
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>`
} else { } else {
userMsg = `<b>❌ Ошибка создания</b> userMsg = msgCreateError
Не удалось создать креатив.
Попробуйте ещё раз или вернитесь назад.`
} }
// Показываем ошибку через панель управления, чтобы не ломать порядок сообщений buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")
buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена")
s.ShowControlPanel(b, userMsg, buttons) s.ShowControlPanel(b, userMsg, buttons)
return return
} }
// Успех! Показываем сообщение и возвращаемся successText := fmt.Sprintf(msgCreativeCreated, creative.Name)
successText := `<b>✅ Креатив успешно создан!</b>
📄 <b>Название:</b> ` + creative.Name
if len(s.Buttons) > 0 { if len(s.Buttons) > 0 {
successText += fmt.Sprintf(` successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons))
🔘 <b>Кнопок добавлено:</b> %d`, len(s.Buttons))
} }
b.SendNew(successText, Keyboard()) b.SendNew(successText, Keyboard())
// Возвращаемся на экран креативов
if s.BackState != nil { if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage) b.SetState(s.BackState, bot.NewMessage)
} }

View File

@@ -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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddProject) Exit() {}

View File

@@ -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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Exit() {}

View File

@@ -7,6 +7,7 @@ import (
"example.com/m/adapter/backend" "example.com/m/adapter/backend"
"example.com/m/bot" "example.com/m/bot"
"example.com/m/ui"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -36,7 +37,8 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
// Заполняем поля из загруженного креатива // Заполняем поля из загруженного креатива
s.Name = &creative.Name s.Name = &creative.Name
if creative.Text != "" { if creative.Text != "" {
s.Text = &creative.Text sanitizedText := ui.SanitizeHTML(creative.Text)
s.Text = &sanitizedText
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend") log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
} else { } else {
log.Info().Msg("Creative text is empty from backend") 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.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
} }
s.MediaChanged = false s.MediaChanged = false
s.ClearInputMode()
log.Info(). log.Info().
Str("creative_id", s.CreativeID). Str("creative_id", s.CreativeID).
@@ -77,47 +80,17 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
} }
case data == "edit_text": case data == "edit_text":
s.ShowControlPanel(b, `<b>✎ Введите новый текст креатива:</b> s.InputMode = inputModeText
s.ShowTextEditPanel(b)
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена редактирования", "cancel_edit")),
})
case data == "add_button": case data == "add_button":
s.WaitingForButtonText = true s.InputMode = inputModeButtonText
s.PendingButtonType = "invite" s.PendingButtonType = "invite"
s.ShowControlPanel(b, `<b> Добавить кнопку</b> s.ShowAddButtonPanel(b)
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
Row(
Button("✓ Ссылка на канал", "button_type_invite"),
Button("Своя ссылка", "button_type_custom"),
),
Row(Button("« Отмена", "cancel_add_button")),
})
case data == "add_media": case data == "add_media":
s.WaitingForMedia = true s.InputMode = inputModeMedia
s.ShowControlPanel(b, `<b> Добавить медиа</b> s.ShowMediaPanel(b)
Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_media")),
})
case data == "delete_media": case data == "delete_media":
s.MediaType = "" s.MediaType = ""
@@ -128,57 +101,21 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
case data == "cancel_add_button": case data == "cancel_add_button":
// Отменяем добавление кнопки // Отменяем добавление кнопки
if s.WaitingForButtonURL && len(s.Buttons) > 0 { s.CancelPendingButton(b)
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.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
case data == "button_type_invite": case data == "button_type_invite":
s.PendingButtonType = "invite" s.PendingButtonType = "invite"
s.ShowControlPanel(b, `<b> Добавить кнопку</b> s.InputMode = inputModeButtonText
s.ShowAddButtonPanel(b)
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
Row(
Button("✓ Ссылка на канал", "button_type_invite"),
Button("Своя ссылка", "button_type_custom"),
),
Row(Button("« Отмена", "cancel_add_button")),
})
case data == "button_type_custom": case data == "button_type_custom":
s.PendingButtonType = "custom" s.PendingButtonType = "custom"
s.ShowControlPanel(b, `<b> Добавить кнопку</b> s.InputMode = inputModeButtonText
s.ShowAddButtonPanel(b)
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
Row(
Button("Ссылка на канал", "button_type_invite"),
Button("✓ Своя ссылка", "button_type_custom"),
),
Row(Button("« Отмена", "cancel_add_button")),
})
case data == "cancel_edit", data == "cancel_media": case data == "cancel_edit", data == "cancel_media":
s.WaitingForMedia = false s.ClearInputMode()
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
case data == "confirm_save": case data == "confirm_save":
@@ -225,146 +162,80 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
return return
} }
// Обрабатываем добавление медиа switch s.InputMode {
if s.WaitingForMedia { case inputModeMedia:
handled := false if !s.SetMediaFromMessage(u.Message) {
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)
return return
} }
} s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b)
// Обрабатываем ввод текста кнопки s.ClearInputMode()
if s.WaitingForButtonText && u.Message.Text != "" { s.showManagementPanelWithDelete(b)
buttonText := u.Message.Text return
s.CurrentButtonText = &buttonText case inputModeButtonText:
s.WaitingForButtonText = false if u.Message.Text == "" {
return
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
} }
buttonText := u.Message.Text
s.DeleteUserMessage(b, u.Message.ID)
// Создаём кнопку с placeholder URL // Создаём кнопку с placeholder URL
if s.PendingButtonType == "custom" { if s.PendingButtonType == "custom" {
s.WaitingForButtonURL = true s.AddCustomButtonPlaceholder(buttonText)
s.Buttons = append(s.Buttons, InlineButton{
Text: buttonText,
URL: "https://example.com",
})
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.ShowControlPanel(b, `<b>✧ Введите URL для кнопки:</b> s.InputMode = inputModeButtonURL
s.ShowButtonURLPanel(b)
<i>Например:</i>
<code>https://example.com</code>`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
} else { } else {
s.Buttons = append(s.Buttons, InlineButton{ s.AddInviteButton(buttonText)
Text: buttonText,
URL: inviteLinkPlaceholder,
})
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.PendingButtonType = "" s.PendingButtonType = ""
s.ClearInputMode()
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
} }
return return
} case inputModeButtonURL:
if u.Message.Text == "" {
// Обрабатываем ввод URL кнопки return
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")
} }
url := strings.TrimSpace(u.Message.Text)
s.DeleteUserMessage(b, u.Message.ID)
// Простая валидация URL // Простая валидация URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { if !s.IsValidButtonURL(url, true) {
s.ShowControlPanel(b, `<b>❌ Неверный формат URL</b> s.ShowInvalidButtonURLPanel(b)
URL должен начинаться с <code>http://</code> или <code>https://</code>
Попробуйте ещё раз:`, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
return return
} }
// Обновляем URL последней кнопки // Обновляем URL последней кнопки
if len(s.Buttons) > 0 { s.UpdateLastButtonURL(url)
s.Buttons[len(s.Buttons)-1].URL = url
}
s.WaitingForButtonURL = false s.ClearInputMode()
s.CurrentButtonText = nil
s.PendingButtonType = "" s.PendingButtonType = ""
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
return return
} case inputModeText:
if u.Message.Text == "" {
// Обрабатываем редактирование текста креатива return
if !s.WaitingForButtonText && !s.WaitingForButtonURL && !s.WaitingForMedia && u.Message.Text != "" { }
text := s.GetFormattedText(u.Message) text := ui.FormatMessageHTML(u.Message)
s.Text = &text s.Text = &text
// Удаляем сообщение пользователя s.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")
}
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.ClearInputMode()
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
return return
} }
// Обрабатываем имя креатива (если нет других состояний) // Обрабатываем имя креатива (если нет других состояний)
if u.Message.Text != "" { if u.Message.Text != "" {
text := s.GetFormattedText(u.Message) text := ui.FormatMessageHTML(u.Message)
s.Text = &text s.Text = &text
// Удаляем сообщение пользователя s.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")
}
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
@@ -373,6 +244,8 @@ URL должен начинаться с <code>http://</code> или <code>https
func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *CreativeDetails) Exit() {}
func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) { func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) {
var confirmText, confirmCallback string var confirmText, confirmCallback string

View File

@@ -2,10 +2,10 @@ package screens
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"example.com/m/bot" "example.com/m/bot"
"example.com/m/ui"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -28,12 +28,11 @@ type CreativeEditorFields struct {
MediaFileID string // File ID из Telegram MediaFileID string // File ID из Telegram
MediaChanged bool MediaChanged bool
// Режим ввода
InputMode string
// Состояние для добавления кнопки // Состояние для добавления кнопки
WaitingForButtonText bool PendingButtonType string
WaitingForButtonURL bool
CurrentButtonText *string
WaitingForMedia bool
PendingButtonType string
// ID сообщения с креативом (которое постоянно обновляется) // ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int CreativeMessageID *int
@@ -41,115 +40,108 @@ type CreativeEditorFields struct {
ControlPanelMessageID *int ControlPanelMessageID *int
// Предыдущее состояние для определения изменения типа сообщения // Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool previousHasMedia bool
previousMediaType string
previousMediaFileID string
} }
const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const buttonURLPlaceholder = "https://example.com"
const (
inputModeText = "text"
inputModeButtonText = "button_text"
inputModeButtonURL = "button_url"
inputModeMedia = "media"
)
const msgCreativeDefault = `
<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>
`
const msgCreativeManagement = `
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:
`
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: buttons,
}
if len(buttons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
return keyboard
}
// GetCreativeText возвращает текст для превью креатива // GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string { func (e *CreativeEditorFields) GetCreativeText() string {
var result string if e.Text == nil {
return msgCreativeDefault
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 + `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
// Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
replacementCount := 0
for strings.Contains(text, `<a class="tg-link">`) {
startIdx := strings.Index(text, `<a class="tg-link">`)
if startIdx == -1 {
break
}
endIdx := strings.Index(text[startIdx:], "</a>")
if endIdx == -1 {
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
break
}
endIdx += startIdx
// Извлекаем текст между тегами
linkText := text[startIdx+len(`<a class="tg-link">`) : 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(`<a class="tg-link" href="%s">%s</a>`, 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("</a>"):]
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
} }
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
return result + `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже. text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
if text == "" {
return msgCreativeDefault
}
</i>` // Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
for strings.Contains(text, `<a class="tg-link">`) {
startIdx := strings.Index(text, `<a class="tg-link">`)
if startIdx == -1 {
break
}
endIdx := strings.Index(text[startIdx:], "</a>")
if endIdx == -1 {
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
break
}
endIdx += startIdx
// Извлекаем текст между тегами
linkText := text[startIdx+len(`<a class="tg-link">`) : 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(`<a class="tg-link" href="%s">%s</a>`, inviteLinkPreviewURL, linkText)
} else {
// Пусто - показываем просто текст примера ссылки
replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL)
}
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
}
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
return text
} }
// GetCreativeButtons возвращает кнопки для превью креатива // GetCreativeButtons возвращает кнопки для превью креатива
@@ -178,14 +170,25 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
creativeButtons := e.GetCreativeButtons() creativeButtons := e.GetCreativeButtons()
// Создаем клавиатуру с пустым массивом (не nil!) // Создаем клавиатуру с пустым массивом (не nil!)
keyboard := echotron.InlineKeyboardMarkup{ keyboard := buildCreativeKeyboard(creativeButtons)
InlineKeyboard: creativeButtons,
}
if len(creativeButtons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
var msgID int 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 != "" { if e.MediaFileID != "" {
@@ -228,15 +231,8 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
if err != nil { if err != nil {
log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media") log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media")
// Fallback to text message - используем прямой SendMessage // Fallback to text message - используем прямой SendMessage
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ if id, err := sendText(); err == nil {
ReplyMarkup: keyboard, msgID = id
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err == nil && res.Result != nil {
msgID = res.Result.ID
} }
} else if res.Result != nil { } else if res.Result != nil {
msgID = res.Result.ID msgID = res.Result.ID
@@ -244,26 +240,19 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
} else { } else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew) // Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не затронуть LastMessageID и не затронуть cleanup // чтобы не затронуть LastMessageID и не затронуть cleanup
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ id, err := sendText()
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to send creative preview") log.Error().Err(err).Msg("Failed to send creative preview")
return return
} }
if res.Result != nil { msgID = id
msgID = res.Result.ID
}
} }
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную! // ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID e.CreativeMessageID = &msgID
e.previousHasMedia = e.MediaFileID != "" 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 обновляет превью креатива // UpdateCreativePreview обновляет превью креатива
@@ -277,17 +266,8 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
currentHasMedia := e.MediaFileID != "" 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 { if currentHasMedia != e.previousHasMedia {
log.Info().Msg("Message type changed, recreating both messages")
// Удаляем оба сообщения // Удаляем оба сообщения
if e.CreativeMessageID != nil { if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID) b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
@@ -307,29 +287,84 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
creativeText := e.GetCreativeText() creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons() creativeButtons := e.GetCreativeButtons()
keyboard := echotron.InlineKeyboardMarkup{ keyboard := buildCreativeKeyboard(creativeButtons)
InlineKeyboard: creativeButtons,
}
if len(creativeButtons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID) 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 // Если есть медиа - редактируем caption
updated := false
if e.MediaFileID != "" { if e.MediaFileID != "" {
_, err := b.EditMessageCaption( mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType
msgID, if mediaChanged {
&echotron.MessageCaptionOptions{ var media echotron.InputMedia
Caption: creativeText, switch e.MediaType {
ParseMode: echotron.HTML, case "photo":
ReplyMarkup: keyboard, media = echotron.InputMediaPhoto{
}, Type: echotron.MediaTypePhoto,
) Media: echotron.NewInputFileID(e.MediaFileID),
if err != nil { Caption: creativeText,
log.Error().Err(err).Msg("Failed to edit media caption") 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 { } 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 { } else {
// Текстовое сообщение - редактируем текст // Текстовое сообщение - редактируем текст
@@ -345,11 +380,88 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
}, },
) )
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to edit message text") logEditError("Failed to edit message text", err)
} else { } 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 показывает панель управления креативом // ShowManagementPanel показывает панель управления креативом
@@ -358,10 +470,13 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
// cancelCallback - callback для кнопки отмены // cancelCallback - callback для кнопки отмены
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками // extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) { func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
text := `<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке. 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 var buttons [][]echotron.InlineKeyboardButton
// Блок редактирования контента // Блок редактирования контента
@@ -369,7 +484,6 @@ func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText
Button("✎ Текст", "edit_text"), Button("✎ Текст", "edit_text"),
Button(" Медиа", "add_media"), Button(" Медиа", "add_media"),
)) ))
// Добавление кнопки сразу под основным блоком
buttons = append(buttons, Row(Button(" Добавить кнопку", "add_button"))) 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 { if e.Text != nil {
buttons = append(buttons, Row( buttons = append(buttons, Row(
Button("← Назад", cancelCallback), Button(cancelLabel, cancelCallback),
Button(confirmButtonText, confirmCallback), Button(confirmText, confirmCallback),
)) ))
} else { } else {
buttons = append(buttons, Row( buttons = append(buttons, Row(
Button("← Назад", cancelCallback), Button(cancelLabel, cancelCallback),
)) ))
} }
e.ShowControlPanel(b, text, buttons) return buttons
} }
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками // ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
@@ -428,211 +543,14 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную! // ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
controlPanelMsgID := b.LastMessageID controlPanelMsgID := b.LastMessageID
e.ControlPanelMessageID = &controlPanelMsgID e.ControlPanelMessageID = &controlPanelMsgID
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Control panel sent")
} else { } else {
// Редактируем существующую панель // Редактируем существующую панель
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
// Временно заменяем LastMessageID на ID панели управления // Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID b.LastMessageID = *e.ControlPanelMessageID
defer func() { b.LastMessageID = oldLastMessageID }()
keyboard := Keyboard(buttons...) keyboard := Keyboard(buttons...)
b.Edit(text, keyboard) 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
// Создаем пустой <a class="tg-link"></a>, а сам 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 = "<b>", "</b>"
case "italic":
openTag, closeTag = "<i>", "</i>"
case "underline":
openTag, closeTag = "<u>", "</u>"
case "strikethrough":
openTag, closeTag = "<s>", "</s>"
case "code":
openTag, closeTag = "<code>", "</code>"
case "pre":
openTag, closeTag = "<pre>", "</pre>"
case "url":
// Конвертируем простые URL в HTML-ссылки
// Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
// Пригласительная ссылка - создаем пустой <a class="tg-link"></a>
// Текст ссылки будет пропущен при рендеринге (см. skipRanges)
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
// Обычная ссылка - оставляем как есть (ссылка на себя)
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
}
case "text_link":
if entity.URL != "" {
// Проверяем, является ли URL пригласительной ссылкой
if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
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("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return result.String()
}

View File

@@ -0,0 +1,101 @@
package screens
import (
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
)
const msgEditCreativeText = `
<b>✎ Введите текст креатива:</b>
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
`
const msgAddButtonPrompt = `
<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>
`
const msgAddMediaPrompt = `
<b> Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i>
`
const msgButtonURLPrompt = `
<b>✧ Введите URL для кнопки:</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
<i>Например:</i>
<code>https://example.com</code>
<code>https://t.me/yourchannel</code>
`
const msgInvalidURL = `
<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
Попробуйте ещё раз.
`
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")),
})
}

View File

@@ -150,11 +150,11 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
)) ))
case data == "add_creative": case data == "add_creative":
b.SetState(&AddCreative{ b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
WorkspaceID: s.WorkspaceID, WorkspaceID: s.WorkspaceID,
BackState: s, BackState: s,
}, bot.EditMessage) }}, bot.EditMessage)
case strings.HasPrefix(data, "creative:"): 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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Creatives) Exit() {}

View File

@@ -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) 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) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
func (s *Login) Exit() {}

View File

@@ -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) 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) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
func (s *MainMenu) Exit() {}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"example.com/m/bot" "example.com/m/bot"
"example.com/m/ui" "example.com/m/ui"
@@ -18,6 +19,11 @@ type MyProjects struct {
WorkspaceID string WorkspaceID string
BackState bot.State BackState bot.State
OpenPurchases bool OpenPurchases bool
// Поля для управления фоновой горутиной polling
cancel context.CancelFunc
pollDone chan struct{}
lastProjectCount int
} }
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { 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.renderProjects(b, mode, jwt)
// Запускаем фоновую горутину для автообновления экрана
s.startPolling(b, jwt)
} }
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) { 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 return
} }
// Сохраняем количество проектов для polling
s.lastProjectCount = page.Total
var text string var text string
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
headerTitle := "Мои проекты" 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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *MyProjects) Handle(_ *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)
}
}
}
}

View File

@@ -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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *ProjectDetails) Exit() {}

View File

@@ -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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseDetails) Exit() {}

View File

@@ -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) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseOptionalDetails) Exit() {}
func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) { func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
switch key { switch key {
case "placement_datetime": case "placement_datetime":

View File

@@ -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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Purchases) Exit() {}

View File

@@ -375,3 +375,5 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
} }
} }
} }
func (s *SelectChannelsForPurchase) Exit() {}

View File

@@ -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) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectWorkspace) Exit() {}

View File

@@ -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) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Exit() {}
func parseDateTimeInput( func parseDateTimeInput(
input string, input string,
currentDate *time.Time, currentDate *time.Time,

View File

@@ -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: "<b>", close: "</b>"},
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
echotron.CodeEntity: {open: "<code>", close: "</code>"},
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
}
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
// Invite links are normalized into <a class="tg-link"> 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 = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
}
} else if entity.Type == echotron.TextLinkEntity {
if entity.URL != "" {
if isInviteLink(entity.URL) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
}
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("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
func SanitizeHTML(input string) string {
return normalizeHTMLTags(input)
}