refactor
This commit is contained in:
51
CLAUDE.md
51
CLAUDE.md
@@ -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
|
||||||
|
|||||||
@@ -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="
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
# ...
|
||||||
|
|||||||
@@ -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'),)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 # Фильтр: с какой даты
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
5
src/usecase/placement/__init__.py
Normal file
5
src/usecase/placement/__init__.py
Normal 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']
|
||||||
@@ -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)
|
|
||||||
64
src/usecase/placement/fetch_publication_post_cycle.py
Normal file
64
src/usecase/placement/fetch_publication_post_cycle.py
Normal 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)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
55
src/usecase/placement/get_publication.py
Normal file
55
src/usecase/placement/get_publication.py
Normal 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,
|
||||||
|
)
|
||||||
71
src/usecase/placement/get_publications.py
Normal file
71
src/usecase/placement/get_publications.py
Normal 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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
5
src/usecase/purchase/__init__.py
Normal file
5
src/usecase/purchase/__init__.py
Normal 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']
|
||||||
148
src/usecase/purchase/create_placements.py
Normal file
148
src/usecase/purchase/create_placements.py
Normal 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])
|
||||||
@@ -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)
|
|
||||||
35
src/usecase/purchase/get_placement.py
Normal file
35
src/usecase/purchase/get_placement.py
Normal 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)
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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, "&", "&")
|
|
||||||
s = strings.ReplaceAll(s, "<", "<")
|
|
||||||
s = strings.ReplaceAll(s, ">", ">")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildEditorButtons создает кнопки для панели редактирования креатива
|
|
||||||
func (s *AddCreative) buildEditorButtons(confirmText, cancelCallback string) [][]echotron.InlineKeyboardButton {
|
|
||||||
var buttons [][]echotron.InlineKeyboardButton
|
|
||||||
|
|
||||||
// Кнопки редактирования
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button("✎ Текст", "edit_text"),
|
|
||||||
Button("+ Медиа", "add_media"),
|
|
||||||
))
|
|
||||||
buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button")))
|
|
||||||
|
|
||||||
// Кнопка удаления медиа (если медиа добавлено)
|
|
||||||
if s.MediaFileID != "" {
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button("⌫ Убрать медиа", "delete_media"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки удаления добавленных кнопок
|
|
||||||
if len(s.Buttons) > 0 {
|
|
||||||
var row []echotron.InlineKeyboardButton
|
|
||||||
for i, btn := range s.Buttons {
|
|
||||||
row = append(row, Button(
|
|
||||||
fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text),
|
|
||||||
fmt.Sprintf("delete_button:%d", i),
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(row) == 2 {
|
|
||||||
buttons = append(buttons, row)
|
|
||||||
row = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(row) > 0 {
|
|
||||||
buttons = append(buttons, row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Финальные кнопки
|
|
||||||
if s.Text != nil {
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button("← "+cancelCallback, "cancel"),
|
|
||||||
Button(confirmText, "confirm_create"),
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
buttons = append(buttons, Row(
|
|
||||||
Button("← "+cancelCallback, "cancel"),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return buttons
|
|
||||||
}
|
|
||||||
|
|
||||||
// showCreativeConfirmation показывает превью креатива + панель с предложением добавить
|
|
||||||
func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) {
|
|
||||||
|
|
||||||
// 1. Отправляем/обновляем превью креатива
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("<")
|
|
||||||
case '>':
|
|
||||||
result.WriteString(">")
|
|
||||||
case '&':
|
|
||||||
result.WriteString("&")
|
|
||||||
default:
|
|
||||||
result.WriteRune(runes[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|||||||
101
telegram_bot/screens/creative_editor_ui.go
Normal file
101
telegram_bot/screens/creative_editor_ui.go
Normal 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")),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -375,3 +375,5 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SelectChannelsForPurchase) Exit() {}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
304
telegram_bot/ui/message_format.go
Normal file
304
telegram_bot/ui/message_format.go
Normal 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("<")
|
||||||
|
case '>':
|
||||||
|
result.WriteString(">")
|
||||||
|
case '&':
|
||||||
|
result.WriteString("&")
|
||||||
|
default:
|
||||||
|
result.WriteRune(runes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeHTMLTags(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInviteLink detects Telegram invite links used in creatives.
|
||||||
|
func isInviteLink(url string) bool {
|
||||||
|
return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHTMLTags(input string) string {
|
||||||
|
if input == "" {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := map[string]bool{
|
||||||
|
"a": true,
|
||||||
|
"b": true,
|
||||||
|
"i": true,
|
||||||
|
"u": true,
|
||||||
|
"s": true,
|
||||||
|
"code": true,
|
||||||
|
"pre": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
out.Grow(len(input))
|
||||||
|
stack := make([]string, 0, 8)
|
||||||
|
|
||||||
|
for i := 0; i < len(input); {
|
||||||
|
if input[i] != '<' {
|
||||||
|
out.WriteByte(input[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.IndexByte(input[i:], '>')
|
||||||
|
if end == -1 {
|
||||||
|
out.WriteByte(input[i])
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
end += i
|
||||||
|
tag := input[i+1 : end]
|
||||||
|
if tag == "" {
|
||||||
|
out.WriteString(input[i : end+1])
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
isClosing := tag[0] == '/'
|
||||||
|
tagName := tag
|
||||||
|
if isClosing {
|
||||||
|
tagName = tag[1:]
|
||||||
|
}
|
||||||
|
if space := strings.IndexByte(tagName, ' '); space != -1 {
|
||||||
|
tagName = tagName[:space]
|
||||||
|
}
|
||||||
|
tagName = strings.TrimSpace(tagName)
|
||||||
|
|
||||||
|
if !allowed[tagName] {
|
||||||
|
out.WriteString(input[i : end+1])
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if isClosing {
|
||||||
|
if len(stack) > 0 && stack[len(stack)-1] == tagName {
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
out.WriteString(input[i : end+1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stack = append(stack, tagName)
|
||||||
|
out.WriteString(input[i : end+1])
|
||||||
|
}
|
||||||
|
|
||||||
|
i = end + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(stack) - 1; i >= 0; i-- {
|
||||||
|
out.WriteString("</")
|
||||||
|
out.WriteString(stack[i])
|
||||||
|
out.WriteString(">")
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscapeHTML escapes HTML special characters for safe output.
|
||||||
|
func EscapeHTML(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
|
||||||
|
func SanitizeHTML(input string) string {
|
||||||
|
return normalizeHTMLTags(input)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user