diff --git a/migrations/models/0_20260102162851_init.py b/migrations/models/0_20260102162851_init.py
deleted file mode 100644
index 0cc8634..0000000
--- a/migrations/models/0_20260102162851_init.py
+++ /dev/null
@@ -1,335 +0,0 @@
-from tortoise import BaseDBAsyncClient
-
-RUN_IN_TRANSACTION = True
-
-
-async def upgrade(db: BaseDBAsyncClient) -> str:
- return """
- CREATE TABLE IF NOT EXISTS "channel"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "telegram_id" BIGINT NOT NULL UNIQUE,
- "title" VARCHAR(255) NOT NULL,
- "username" VARCHAR(255) NOT NULL UNIQUE,
- "access_hash" BIGINT,
- "pts" INT
- );
- CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id");
- CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username");
- CREATE TABLE IF NOT EXISTS "post"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "message_id" INT NOT NULL,
- "text" TEXT NOT NULL,
- "deleted_from_channel_at" TIMESTAMPTZ,
- "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id")
- );
- CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id");
- CREATE TABLE IF NOT EXISTS "post_views_history"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "views_count" INT NOT NULL,
- "fetched_at" TIMESTAMPTZ NOT NULL,
- "post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at")
- );
- CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at");
- CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id");
- CREATE TABLE IF NOT EXISTS "user"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "telegram_id" BIGINT NOT NULL UNIQUE,
- "username" VARCHAR(255),
- "first_name" VARCHAR(255),
- "last_name" VARCHAR(255)
- );
- CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id");
- CREATE TABLE IF NOT EXISTS "login_token"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "token" VARCHAR(255) NOT NULL UNIQUE,
- "expires_at" TIMESTAMPTZ NOT NULL,
- "used_at" TIMESTAMPTZ,
- "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token");
- CREATE INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id");
- CREATE TABLE IF NOT EXISTS "workspace"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "name" VARCHAR(255) NOT NULL
- );
- CREATE TABLE IF NOT EXISTS "project"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "status" VARCHAR(8) NOT NULL DEFAULT 'active',
- "purchase_invite_type_default" VARCHAR(10) NOT NULL DEFAULT 'approval',
- "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
- "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id")
- );
- CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_id");
- CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id");
- COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived';
- COMMENT ON COLUMN "project"."purchase_invite_type_default" IS 'PUBLIC: public\nAPPROVAL: approval';
- CREATE TABLE IF NOT EXISTS "creative"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "name" VARCHAR(255) NOT NULL,
- "text" TEXT NOT NULL,
- "status" VARCHAR(8) NOT NULL DEFAULT 'active',
- "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
- "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project_id");
- CREATE INDEX IF NOT EXISTS "idx_creative_workspa_2de546" ON "creative" ("workspace_id");
- COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived';
- CREATE TABLE IF NOT EXISTS "purchase"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "status" VARCHAR(8) NOT NULL DEFAULT 'active',
- "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
- "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
- "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS "idx_purchase_creativ_6829cd" ON "purchase" ("creative_id");
- CREATE INDEX IF NOT EXISTS "idx_purchase_project_95b1c2" ON "purchase" ("project_id");
- CREATE INDEX IF NOT EXISTS "idx_purchase_workspa_8784a5" ON "purchase" ("workspace_id");
- CREATE INDEX IF NOT EXISTS "idx_purchase_project_dee224" ON "purchase" ("project_id", "status");
- COMMENT ON COLUMN "purchase"."status" IS 'ACTIVE: active\nARCHIVED: archived';
- CREATE TABLE IF NOT EXISTS "purchase_channel"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "status" VARCHAR(11) NOT NULL DEFAULT 'planned',
- "planned_cost" DOUBLE PRECISION,
- "comment" TEXT,
- "invite_link" VARCHAR(512) NOT NULL,
- "invite_link_type" VARCHAR(8) NOT NULL,
- "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
- "purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_purchase_ch_purchas_f7c86e" UNIQUE ("purchase_id", "channel_id")
- );
- CREATE INDEX IF NOT EXISTS "idx_purchase_ch_channel_2b1264" ON "purchase_channel" ("channel_id");
- CREATE INDEX IF NOT EXISTS "idx_purchase_ch_purchas_1fe84a" ON "purchase_channel" ("purchase_id");
- COMMENT ON COLUMN "purchase_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed';
- COMMENT ON COLUMN "purchase_channel"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';
- CREATE TABLE IF NOT EXISTS "placement"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "wanted_placement_date" TIMESTAMPTZ NOT NULL,
- "cost" DOUBLE PRECISION,
- "comment" TEXT,
- "invite_link" VARCHAR(512) NOT NULL,
- "status" VARCHAR(8) NOT NULL DEFAULT 'active',
- "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
- "placement_channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
- "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL,
- "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
- "purchase_id" UUID REFERENCES "purchase" ("id") ON DELETE CASCADE,
- "purchase_channel_id" UUID REFERENCES "purchase_channel" ("id") ON DELETE CASCADE,
- "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id");
- CREATE INDEX IF NOT EXISTS "idx_placement_placeme_20cf53" ON "placement" ("placement_channel_id");
- CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id");
- CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id");
- CREATE INDEX IF NOT EXISTS "idx_placement_purchas_3e3775" ON "placement" ("purchase_id");
- CREATE INDEX IF NOT EXISTS "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id");
- CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id");
- COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived';
- CREATE TABLE IF NOT EXISTS "subscription"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "invite_link" VARCHAR(512) NOT NULL,
- "status" VARCHAR(12) NOT NULL DEFAULT 'active',
- "unsubscribed_at" TIMESTAMPTZ,
- "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE,
- "subscriber_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_subscriptio_placeme_8fcb8f" UNIQUE ("placement_id", "subscriber_id")
- );
- CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link");
- CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id");
- CREATE INDEX IF NOT EXISTS "idx_subscriptio_subscri_e75729" ON "subscription" ("subscriber_id");
- COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed';
- CREATE TABLE IF NOT EXISTS "workspace_invite"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "status" VARCHAR(8) NOT NULL DEFAULT 'pending',
- "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
- "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
- "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id")
- );
- CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id");
- COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked';
- CREATE TABLE IF NOT EXISTS "workspace_user"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "status" VARCHAR(7) NOT NULL DEFAULT 'active',
- "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
- "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "user_id")
- );
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id");
- COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked';
- CREATE TABLE IF NOT EXISTS "workspace_user_permission"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "permission" VARCHAR(24) NOT NULL,
- "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
- CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
- );
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
- COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
- CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope"
- (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "permission" VARCHAR(24) NOT NULL,
- "channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE,
- "creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE,
- "placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE,
- "project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE,
- "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_id");
- CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id");
- COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
- CREATE TABLE IF NOT EXISTS "aerich"
- (
- "id" SERIAL NOT NULL PRIMARY KEY,
- "version" VARCHAR(255) NOT NULL,
- "app" VARCHAR(100) NOT NULL,
- "content" JSONB NOT NULL
- );"""
-
-
-async def downgrade(db: BaseDBAsyncClient) -> str:
- return """
- """
-
-
-MODELS_STATE = (
- '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'
-)
diff --git a/migrations/models/0_20260107150827_init.py b/migrations/models/0_20260107150827_init.py
new file mode 100644
index 0000000..8acef54
--- /dev/null
+++ b/migrations/models/0_20260107150827_init.py
@@ -0,0 +1,342 @@
+from tortoise import BaseDBAsyncClient
+
+RUN_IN_TRANSACTION = True
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ CREATE TABLE IF NOT EXISTS "channel" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "telegram_id" BIGINT NOT NULL UNIQUE,
+ "title" VARCHAR(255) NOT NULL,
+ "username" VARCHAR(255) NOT NULL UNIQUE,
+ "access_hash" BIGINT,
+ "pts" INT
+);
+CREATE INDEX IF NOT EXISTS "idx_channel_telegra_414c3b" ON "channel" ("telegram_id");
+CREATE INDEX IF NOT EXISTS "idx_channel_usernam_7fd87a" ON "channel" ("username");
+CREATE TABLE IF NOT EXISTS "post" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "message_id" INT NOT NULL,
+ "text" TEXT NOT NULL,
+ "deleted_from_channel_at" TIMESTAMPTZ,
+ "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_post_channel_0a2cba" UNIQUE ("channel_id", "message_id")
+);
+CREATE INDEX IF NOT EXISTS "idx_post_channel_197bed" ON "post" ("channel_id");
+CREATE TABLE IF NOT EXISTS "post_views_history" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "views_count" INT NOT NULL,
+ "fetched_at" TIMESTAMPTZ NOT NULL,
+ "post_id" UUID NOT NULL REFERENCES "post" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_post_views__post_id_c6cc9f" UNIQUE ("post_id", "fetched_at")
+);
+CREATE INDEX IF NOT EXISTS "idx_post_views__fetched_103eb5" ON "post_views_history" ("fetched_at");
+CREATE INDEX IF NOT EXISTS "idx_post_views__post_id_dfb1ca" ON "post_views_history" ("post_id");
+CREATE TABLE IF NOT EXISTS "telegram_user" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "telegram_id" BIGINT NOT NULL UNIQUE,
+ "username" VARCHAR(255),
+ "first_name" VARCHAR(255),
+ "last_name" VARCHAR(255)
+);
+CREATE INDEX IF NOT EXISTS "idx_telegram_us_telegra_3a3100" ON "telegram_user" ("telegram_id");
+CREATE TABLE IF NOT EXISTS "user" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "telegram_user_id" UUID NOT NULL UNIQUE REFERENCES "telegram_user" ("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_user_telegra_444a03" ON "user" ("telegram_user_id");
+CREATE TABLE IF NOT EXISTS "login_token" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "token" VARCHAR(255) NOT NULL UNIQUE,
+ "expires_at" TIMESTAMPTZ NOT NULL,
+ "used_at" TIMESTAMPTZ,
+ "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_login_token_token_2582d2" ON "login_token" ("token");
+CREATE INDEX IF NOT EXISTS "idx_login_token_user_id_c85d7c" ON "login_token" ("user_id");
+CREATE TABLE IF NOT EXISTS "workspace" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "name" VARCHAR(255) NOT NULL
+);
+CREATE TABLE IF NOT EXISTS "project" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "status" VARCHAR(8) NOT NULL DEFAULT 'active',
+ "purchase_invite_type_default" VARCHAR(10) NOT NULL DEFAULT 'approval',
+ "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
+ "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_project_workspa_2f80f2" UNIQUE ("workspace_id", "channel_id")
+);
+CREATE INDEX IF NOT EXISTS "idx_project_channel_ce2466" ON "project" ("channel_id");
+CREATE INDEX IF NOT EXISTS "idx_project_workspa_2354fd" ON "project" ("workspace_id");
+COMMENT ON COLUMN "project"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived';
+COMMENT ON COLUMN "project"."purchase_invite_type_default" IS 'PUBLIC: public\nAPPROVAL: approval';
+CREATE TABLE IF NOT EXISTS "creative" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "name" VARCHAR(255) NOT NULL,
+ "text" TEXT NOT NULL,
+ "media_type" VARCHAR(32),
+ "media_file_id" VARCHAR(512),
+ "media_s3_key" VARCHAR(512),
+ "buttons" JSONB NOT NULL,
+ "status" VARCHAR(8) NOT NULL DEFAULT 'active',
+ "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_creative_project_0766a6" ON "creative" ("project_id");
+COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived';
+CREATE TABLE IF NOT EXISTS "purchase" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "status" VARCHAR(8) NOT NULL DEFAULT 'active',
+ "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,
+ "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
+ "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_purchase_creativ_6829cd" ON "purchase" ("creative_id");
+CREATE INDEX IF NOT EXISTS "idx_purchase_project_95b1c2" ON "purchase" ("project_id");
+CREATE INDEX IF NOT EXISTS "idx_purchase_project_dee224" ON "purchase" ("project_id", "status");
+COMMENT ON COLUMN "purchase"."status" IS 'ACTIVE: active\nARCHIVED: archived';
+COMMENT ON COLUMN "purchase"."cost_type" IS 'FIXED: fixed\nCPM: cpm';
+COMMENT ON COLUMN "purchase"."purchase_type" IS 'SELF_PROMO: self_promo\nSTANDARD: standard';
+CREATE TABLE IF NOT EXISTS "purchase_channel" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "status" VARCHAR(11) NOT NULL DEFAULT 'planned',
+ "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,
+ "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
+ "purchase_id" UUID NOT NULL REFERENCES "purchase" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_purchase_ch_purchas_f7c86e" UNIQUE ("purchase_id", "channel_id")
+);
+CREATE INDEX IF NOT EXISTS "idx_purchase_ch_channel_2b1264" ON "purchase_channel" ("channel_id");
+CREATE INDEX IF NOT EXISTS "idx_purchase_ch_purchas_1fe84a" ON "purchase_channel" ("purchase_id");
+COMMENT ON COLUMN "purchase_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed';
+COMMENT ON COLUMN "purchase_channel"."cost_type" IS 'FIXED: fixed\nCPM: cpm';
+COMMENT ON COLUMN "purchase_channel"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval';
+CREATE TABLE IF NOT EXISTS "placement" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "wanted_placement_date" TIMESTAMPTZ NOT NULL,
+ "cost" DOUBLE PRECISION,
+ "comment" TEXT,
+ "status" VARCHAR(8) NOT NULL DEFAULT 'active',
+ "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE,
+ "post_id" UUID REFERENCES "post" ("id") ON DELETE SET NULL,
+ "project_id" UUID NOT NULL REFERENCES "project" ("id") ON DELETE CASCADE,
+ "purchase_channel_id" UUID NOT NULL REFERENCES "purchase_channel" ("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id");
+CREATE INDEX IF NOT EXISTS "idx_placement_post_id_e4a16e" ON "placement" ("post_id");
+CREATE INDEX IF NOT EXISTS "idx_placement_project_2c6aa4" ON "placement" ("project_id");
+CREATE INDEX IF NOT EXISTS "idx_placement_purchas_61f9e6" ON "placement" ("purchase_channel_id");
+COMMENT ON COLUMN "placement"."status" IS 'ACTIVE: active\nARCHIVED: archived';
+CREATE TABLE IF NOT EXISTS "subscription" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "invite_link" VARCHAR(512) NOT NULL,
+ "status" VARCHAR(12) NOT NULL DEFAULT 'active',
+ "unsubscribed_at" TIMESTAMPTZ,
+ "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE,
+ "telegram_user_id" UUID NOT NULL REFERENCES "telegram_user" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_subscriptio_placeme_75b40b" UNIQUE ("placement_id", "telegram_user_id")
+);
+CREATE INDEX IF NOT EXISTS "idx_subscriptio_invite__f2058d" ON "subscription" ("invite_link");
+CREATE INDEX IF NOT EXISTS "idx_subscriptio_placeme_5decb2" ON "subscription" ("placement_id");
+CREATE INDEX IF NOT EXISTS "idx_subscriptio_telegra_8be9bb" ON "subscription" ("telegram_user_id");
+COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed';
+CREATE TABLE IF NOT EXISTS "workspace_invite" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "status" VARCHAR(8) NOT NULL DEFAULT 'pending',
+ "invited_by_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
+ "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
+ "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_workspace_i_workspa_abd9c4" UNIQUE ("workspace_id", "user_id")
+);
+CREATE INDEX IF NOT EXISTS "idx_workspace_i_invited_3ed9f8" ON "workspace_invite" ("invited_by_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_i_user_id_a96e1e" ON "workspace_invite" ("user_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_i_workspa_71e9d2" ON "workspace_invite" ("workspace_id");
+COMMENT ON COLUMN "workspace_invite"."status" IS 'PENDING: pending\nACCEPTED: accepted\nREVOKED: revoked';
+CREATE TABLE IF NOT EXISTS "workspace_user" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "status" VARCHAR(7) NOT NULL DEFAULT 'active',
+ "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
+ "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_workspace_u_workspa_13c46c" UNIQUE ("workspace_id", "user_id")
+);
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_user_id_246318" ON "workspace_user" ("user_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_a403b0" ON "workspace_user" ("workspace_id");
+COMMENT ON COLUMN "workspace_user"."status" IS 'ACTIVE: active\nINVITED: invited\nBLOCKED: blocked';
+CREATE TABLE IF NOT EXISTS "workspace_user_permission" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "permission" VARCHAR(24) NOT NULL,
+ "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
+ CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
+);
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
+COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
+CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
+ "id" UUID NOT NULL PRIMARY KEY,
+ "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "deleted_at" TIMESTAMPTZ,
+ "permission" VARCHAR(24) NOT NULL,
+ "channel_id" UUID REFERENCES "channel" ("id") ON DELETE CASCADE,
+ "creative_id" UUID REFERENCES "creative" ("id") ON DELETE CASCADE,
+ "placement_id" UUID REFERENCES "placement" ("id") ON DELETE CASCADE,
+ "project_id" UUID REFERENCES "project" ("id") ON DELETE CASCADE,
+ "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_channel_d94b48" ON "workspace_user_permission_scope" ("channel_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_creativ_8da562" ON "workspace_user_permission_scope" ("creative_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_placeme_ea1846" ON "workspace_user_permission_scope" ("placement_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_project_bd7fea" ON "workspace_user_permission_scope" ("project_id");
+CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id");
+COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'ADMIN_FULL: admin_full\nPROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nCREATIVES_READ: creatives_read\nCREATIVES_WRITE: creatives_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nANALYTICS_WITHOUT_CLICKS: analytics_without_clicks\nCHANNELS_READ: channels_read\nCHANNELS_WRITE: channels_write';
+CREATE TABLE IF NOT EXISTS "aerich" (
+ "id" SERIAL NOT NULL PRIMARY KEY,
+ "version" VARCHAR(255) NOT NULL,
+ "app" VARCHAR(100) NOT NULL,
+ "content" JSONB NOT NULL
+);"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ """
+
+
+MODELS_STATE = (
+ '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'
+ 'v0'
+)
diff --git a/migrations/models/1_20260102184542_add_check_constraint.py b/migrations/models/1_20260102184542_add_check_constraint.py
deleted file mode 100644
index f26ab76..0000000
--- a/migrations/models/1_20260102184542_add_check_constraint.py
+++ /dev/null
@@ -1,99 +0,0 @@
-from tortoise import BaseDBAsyncClient
-
-RUN_IN_TRANSACTION = True
-
-
-async def upgrade(db: BaseDBAsyncClient) -> str:
- return """
- -- Add CHECK constraint: exactly one FK must be set
- ALTER TABLE "workspace_user_permission_scope"
- ADD CONSTRAINT "check_exactly_one_scope"
- CHECK (
- (("project_id" IS NOT NULL)::int +
- ("creative_id" IS NOT NULL)::int +
- ("placement_id" IS NOT NULL)::int +
- ("channel_id" IS NOT NULL)::int) = 1
- );
-
- COMMENT ON TABLE "workspace_user_permission_scope"
- IS 'Permission scoped to specific entity (project/creative/placement/channel)'; \
- """
-
-
-async def downgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "workspace_user_permission_scope"
- DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; \
- """
-
-
-MODELS_STATE = (
- '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'
-)
diff --git a/migrations/models/1_20260107150903_add_check_constraint.py b/migrations/models/1_20260107150903_add_check_constraint.py
new file mode 100644
index 0000000..0cb2d87
--- /dev/null
+++ b/migrations/models/1_20260107150903_add_check_constraint.py
@@ -0,0 +1,107 @@
+from tortoise import BaseDBAsyncClient
+
+RUN_IN_TRANSACTION = True
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ -- Add CHECK constraint: exactly one FK must be set
+ ALTER TABLE "workspace_user_permission_scope"
+ ADD CONSTRAINT "check_exactly_one_scope"
+ CHECK (
+ (("project_id" IS NOT NULL)::int +
+ ("creative_id" IS NOT NULL)::int +
+ ("placement_id" IS NOT NULL)::int +
+ ("channel_id" IS NOT NULL)::int) = 1
+ );
+
+ COMMENT ON TABLE "workspace_user_permission_scope"
+ IS 'Permission scoped to specific entity (project/creative/placement/channel)'; \
+ """
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE "workspace_user_permission_scope"
+ DROP CONSTRAINT IF EXISTS "check_exactly_one_scope"; \
+ """
+
+
+MODELS_STATE = (
+ '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'
+ 'v0'
+)
diff --git a/migrations/models/2_20260103113235_update.py b/migrations/models/2_20260103113235_update.py
deleted file mode 100644
index 2bbf382..0000000
--- a/migrations/models/2_20260103113235_update.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from tortoise import BaseDBAsyncClient
-
-RUN_IN_TRANSACTION = True
-
-
-async def upgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "creative" ADD "buttons" JSONB NOT NULL;
- ALTER TABLE "creative" ADD "media_data" BYTEA;
- ALTER TABLE "creative" ADD "media_file_id" VARCHAR(512);
- ALTER TABLE "creative" ADD "media_type" VARCHAR(32);"""
-
-
-async def downgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "creative" DROP COLUMN "buttons";
- ALTER TABLE "creative" DROP COLUMN "media_data";
- ALTER TABLE "creative" DROP COLUMN "media_file_id";
- ALTER TABLE "creative" DROP COLUMN "media_type";"""
-
-
-MODELS_STATE = (
- "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"
- "a4"
-)
diff --git a/migrations/models/3_20260106175850_move_creative_media_to_s3.py b/migrations/models/3_20260106175850_move_creative_media_to_s3.py
deleted file mode 100644
index 3eb8a08..0000000
--- a/migrations/models/3_20260106175850_move_creative_media_to_s3.py
+++ /dev/null
@@ -1,90 +0,0 @@
-from tortoise import BaseDBAsyncClient
-
-RUN_IN_TRANSACTION = True
-
-
-async def upgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "creative" ADD "media_s3_key" VARCHAR(512);
- ALTER TABLE "creative" DROP COLUMN "media_data";"""
-
-
-async def downgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "creative" ADD "media_data" BYTEA;
- ALTER TABLE "creative" DROP COLUMN "media_s3_key";"""
-
-
-MODELS_STATE = (
- "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"
- "XdLO/D8vv/8PAyERnQ=="
-)
diff --git a/migrations/models/4_20260106192017_update.py b/migrations/models/4_20260106192017_update.py
deleted file mode 100644
index dba4085..0000000
--- a/migrations/models/4_20260106192017_update.py
+++ /dev/null
@@ -1,149 +0,0 @@
-from tortoise import BaseDBAsyncClient
-
-RUN_IN_TRANSACTION = True
-
-
-async def upgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "purchase"
- ADD "comment" TEXT;
- ALTER TABLE "purchase"
- ADD "payment_at" TIMESTAMPTZ;
- ALTER TABLE "purchase"
- ADD "format" TEXT;
- ALTER TABLE "purchase"
- ADD "cost_before_bargain" DOUBLE PRECISION;
- ALTER TABLE "purchase"
- ADD "placement_at" TIMESTAMPTZ;
- ALTER TABLE "purchase"
- ADD "cost_type" VARCHAR(8);
- ALTER TABLE "purchase"
- ADD "cost_value" DOUBLE PRECISION;
- ALTER TABLE "purchase"
- ADD "purchase_type" VARCHAR(16);
- ALTER TABLE "purchase_channel"
- ADD "format" TEXT;
- ALTER TABLE "purchase_channel"
- ADD "cost_before_bargain" DOUBLE PRECISION;
- ALTER TABLE "purchase_channel"
- ADD "placement_at" TIMESTAMPTZ;
- ALTER TABLE "purchase_channel"
- ADD "cost_type" VARCHAR(8);
- ALTER TABLE "purchase_channel"
- ADD "cost_value" DOUBLE PRECISION;
- ALTER TABLE "purchase_channel"
- DROP COLUMN "planned_cost";
- COMMENT ON COLUMN "purchase"."cost_type" IS 'FIXED: fixed\nCPM: cpm';
- COMMENT ON COLUMN "purchase"."purchase_type" IS 'SELF_PROMO: self_promo\nSTANDARD: standard';
- COMMENT ON COLUMN "purchase_channel"."cost_type" IS 'FIXED: fixed\nCPM: cpm';"""
-
-
-async def downgrade(db: BaseDBAsyncClient) -> str:
- return """
- ALTER TABLE "purchase"
- DROP COLUMN "comment";
- ALTER TABLE "purchase"
- DROP COLUMN "payment_at";
- ALTER TABLE "purchase"
- DROP COLUMN "format";
- ALTER TABLE "purchase"
- DROP COLUMN "cost_before_bargain";
- ALTER TABLE "purchase"
- DROP COLUMN "placement_at";
- ALTER TABLE "purchase"
- DROP COLUMN "cost_type";
- ALTER TABLE "purchase"
- DROP COLUMN "cost_value";
- ALTER TABLE "purchase"
- DROP COLUMN "purchase_type";
- ALTER TABLE "purchase_channel"
- ADD "planned_cost" DOUBLE PRECISION;
- ALTER TABLE "purchase_channel"
- DROP COLUMN "format";
- ALTER TABLE "purchase_channel"
- DROP COLUMN "cost_before_bargain";
- ALTER TABLE "purchase_channel"
- DROP COLUMN "placement_at";
- ALTER TABLE "purchase_channel"
- DROP COLUMN "cost_type";
- ALTER TABLE "purchase_channel"
- DROP COLUMN "cost_value";"""
-
-
-MODELS_STATE = (
- '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'
- '49ruDVq8qUI6aKVL2pn/5+X3/wGuY8FA'
-)
diff --git a/migrations/models/5_20260107135803_update.py b/migrations/models/5_20260107135803_update.py
deleted file mode 100644
index 2b43fd2..0000000
--- a/migrations/models/5_20260107135803_update.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from tortoise import BaseDBAsyncClient
-
-RUN_IN_TRANSACTION = True
-
-
-async def upgrade(db: BaseDBAsyncClient) -> str:
- return """
- DROP INDEX IF EXISTS "uid_user_telegra_66ffbd";
- ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "uid_subscriptio_placeme_8fcb8f";
- ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "fk_subscrip_user_6b9f4b8e";
- CREATE TABLE IF NOT EXISTS "telegram_user" (
- "id" UUID NOT NULL PRIMARY KEY,
- "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
- "deleted_at" TIMESTAMPTZ,
- "telegram_id" BIGINT NOT NULL UNIQUE,
- "username" VARCHAR(255),
- "first_name" VARCHAR(255),
- "last_name" VARCHAR(255)
-);
-CREATE INDEX IF NOT EXISTS "idx_telegram_us_telegra_3a3100" ON "telegram_user" ("telegram_id");
- INSERT INTO "telegram_user" (
- "id",
- "created_at",
- "updated_at",
- "deleted_at",
- "telegram_id",
- "username",
- "first_name",
- "last_name"
-)
-SELECT
- "id",
- "created_at",
- "updated_at",
- "deleted_at",
- "telegram_id",
- "username",
- "first_name",
- "last_name"
-FROM "user"
-WHERE "telegram_id" IS NOT NULL
-ON CONFLICT ("id") DO NOTHING;
- ALTER TABLE "subscription" RENAME COLUMN "subscriber_id" TO "telegram_user_id";
- ALTER TABLE "user" ADD "telegram_user_id" UUID UNIQUE;
- UPDATE "user" SET "telegram_user_id" = "id";
- ALTER TABLE "user" ALTER COLUMN "telegram_user_id" SET NOT NULL;
- ALTER TABLE "user" DROP COLUMN "first_name";
- ALTER TABLE "user" DROP COLUMN "username";
- ALTER TABLE "user" DROP COLUMN "telegram_id";
- ALTER TABLE "user" DROP COLUMN "last_name";
- ALTER TABLE "subscription" ADD CONSTRAINT "fk_subscrip_telegram_ab72b5bf" FOREIGN KEY ("telegram_user_id") REFERENCES "telegram_user" ("id") ON DELETE CASCADE;
- CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_75b40b" ON "subscription" ("placement_id", "telegram_user_id");
- ALTER TABLE "user" ADD CONSTRAINT "fk_user_telegram_548d951e" FOREIGN KEY ("telegram_user_id") REFERENCES "telegram_user" ("id") ON DELETE CASCADE;"""
-
-
-async def downgrade(db: BaseDBAsyncClient) -> str:
- return """
- DROP INDEX IF EXISTS "uid_subscriptio_placeme_75b40b";
- ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "fk_subscrip_telegram_ab72b5bf";
- ALTER TABLE "user" DROP CONSTRAINT IF EXISTS "fk_user_telegram_548d951e";
- ALTER TABLE "user" ADD "first_name" VARCHAR(255);
- ALTER TABLE "user" ADD "username" VARCHAR(255);
- ALTER TABLE "user" ADD "telegram_id" BIGINT NOT NULL UNIQUE;
- ALTER TABLE "user" ADD "last_name" VARCHAR(255);
- ALTER TABLE "user" DROP COLUMN "telegram_user_id";
- ALTER TABLE "subscription" RENAME COLUMN "telegram_user_id" TO "subscriber_id";
- DROP TABLE IF EXISTS "telegram_user";
- ALTER TABLE "subscription" ADD CONSTRAINT "fk_subscrip_user_6b9f4b8e" FOREIGN KEY ("subscriber_id") REFERENCES "user" ("id") ON DELETE CASCADE;
- CREATE UNIQUE INDEX IF NOT EXISTS "uid_subscriptio_placeme_8fcb8f" ON "subscription" ("placement_id", "subscriber_id");"""
-
-
-MODELS_STATE = (
- "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"
- "1lQhHbSyJe3k/3n5/X8nR5KJ"
-)
diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py
index a875fc9..56436af 100644
--- a/src/adapter/postgres.py
+++ b/src/adapter/postgres.py
@@ -291,11 +291,11 @@ class Postgres(DatabaseBase, Database):
)
async def get_purchase(self, workspace_id: uuid.UUID, purchase_id: uuid.UUID) -> domain.Purchase | None:
- return await domain.Purchase.get_or_none(id=purchase_id, workspace_id=workspace_id)
+ 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(workspace_id=workspace_id, project_id=project_id)
+ await domain.Purchase.filter(project__workspace_id=workspace_id, project_id=project_id)
.prefetch_related('channels', 'channels__channel')
.all()
)
@@ -315,6 +315,13 @@ class Postgres(DatabaseBase, Database):
.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(
@@ -378,9 +385,9 @@ class Postgres(DatabaseBase, Database):
await domain.PurchaseChannel.filter(purchase_id=purchase_id, channel_id=channel_id).delete()
async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
- return await domain.Creative.get_or_none(id=creative_id, workspace_id=workspace_id).prefetch_related(
- 'project', 'project__channel'
- )
+ return await domain.Creative.get_or_none(
+ id=creative_id, project__workspace_id=workspace_id
+ ).prefetch_related('project', 'project__channel')
async def update_creative(self, creative: domain.Creative) -> None:
await creative.save()
@@ -392,7 +399,7 @@ class Postgres(DatabaseBase, Database):
include_archived: bool = False,
allowed_project_ids: set[uuid.UUID] | None = None,
) -> list[domain.Creative]:
- query = domain.Creative.filter(workspace_id=workspace_id)
+ query = domain.Creative.filter(project__workspace_id=workspace_id)
if project_id:
query = query.filter(project_id=project_id)
@@ -410,8 +417,16 @@ class Postgres(DatabaseBase, Database):
await domain.Creative.filter(id=creative_id).delete()
async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
- return await domain.Placement.get_or_none(id=placement_id, workspace_id=workspace_id).prefetch_related(
- 'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel'
+ return await domain.Placement.get_or_none(
+ id=placement_id, project__workspace_id=workspace_id
+ ).prefetch_related(
+ 'project',
+ 'project__channel',
+ 'purchase_channel',
+ 'purchase_channel__channel',
+ 'creative',
+ 'post',
+ 'post__channel',
)
async def create_placement(self, placement: domain.Placement) -> None:
@@ -431,7 +446,7 @@ class Postgres(DatabaseBase, Database):
date_from: datetime.datetime | None = None,
date_to: datetime.datetime | None = None,
) -> list[domain.Placement]:
- query = domain.Placement.filter(workspace_id=workspace_id)
+ query = domain.Placement.filter(project__workspace_id=workspace_id)
if project_id:
query = query.filter(project_id=project_id)
@@ -440,7 +455,7 @@ class Postgres(DatabaseBase, Database):
return []
query = query.filter(project_id__in=list(allowed_project_ids))
if placement_channel_id:
- query = query.filter(placement_channel_id=placement_channel_id)
+ query = query.filter(purchase_channel__channel_id=placement_channel_id)
if creative_id:
query = query.filter(creative_id=creative_id)
@@ -454,7 +469,7 @@ class Postgres(DatabaseBase, Database):
return (
await query.prefetch_related(
- 'project', 'project__channel', 'placement_channel', 'creative', 'post', 'post__channel'
+ 'project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative', 'post', 'post__channel'
)
.order_by('-wanted_placement_date')
.all()
@@ -464,9 +479,9 @@ class Postgres(DatabaseBase, Database):
await domain.Placement.filter(id=placement_id).delete()
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None:
- return await domain.Placement.get_or_none(invite_link=invite_link).prefetch_related(
- 'project', 'project__channel', 'placement_channel', 'creative'
- )
+ return await domain.Placement.get_or_none(
+ purchase_channel__invite_link=invite_link
+ ).prefetch_related('project', 'project__channel', 'purchase_channel', 'purchase_channel__channel', 'creative')
async def create_subscription(self, subscription: domain.Subscription) -> None:
await subscription.save()
diff --git a/src/domain/__init__.py b/src/domain/__init__.py
index 27dff0d..898a1a3 100644
--- a/src/domain/__init__.py
+++ b/src/domain/__init__.py
@@ -41,6 +41,7 @@ __all__ = (
'WorkspaceAccessDenied',
'WorkspaceInviteNotFound',
'WorkspaceInviteAlreadyExists',
+ 'WorkspaceInviteAlreadyProcessed',
'WorkspaceMemberAlreadyExists',
'LoginTokenNotFound',
'LoginTokenExpired',
@@ -84,6 +85,7 @@ from .error import (
UserNotFound,
WorkspaceAccessDenied,
WorkspaceInviteAlreadyExists,
+ WorkspaceInviteAlreadyProcessed,
WorkspaceInviteNotFound,
WorkspaceMemberAlreadyExists,
WorkspaceNotFound,
diff --git a/src/domain/creative.py b/src/domain/creative.py
index 1549d29..aeea534 100644
--- a/src/domain/creative.py
+++ b/src/domain/creative.py
@@ -10,7 +10,6 @@ from .error import CreativeInviteLinkNotFound, CreativeMediaTooLarge, CreativeMu
if TYPE_CHECKING:
from .project import Project
- from .workspace import Workspace
class CreativeStatus(str, enum.Enum):
@@ -27,15 +26,11 @@ class Creative(TimestampedModel):
buttons = fields.JSONField(default=list)
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
- workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
- 'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True
- )
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='creatives', on_delete=fields.CASCADE, index=True
)
if TYPE_CHECKING:
- workspace_id: UUID
project_id: UUID
class Meta:
diff --git a/src/domain/placement.py b/src/domain/placement.py
index d388370..f6b9f5c 100644
--- a/src/domain/placement.py
+++ b/src/domain/placement.py
@@ -7,12 +7,10 @@ from tortoise import fields
from .base import TimestampedModel
if TYPE_CHECKING:
- from .channel import Channel
from .creative import Creative
from .post import Post
from .project import Project
- from .purchase import Purchase, PurchaseChannel
- from .workspace import Workspace
+ from .purchase import PurchaseChannel
class PlacementStatus(str, enum.Enum):
@@ -31,27 +29,17 @@ class Placement(TimestampedModel):
wanted_placement_date = fields.DatetimeField(db_column='placement_date')
cost = fields.FloatField(null=True)
comment = fields.TextField(null=True)
- invite_link = fields.CharField(max_length=512)
status = fields.CharEnumField(PlacementStatus, default=PlacementStatus.ACTIVE)
- workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
- 'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
- )
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
)
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
)
- placement_channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField(
- 'models.Channel', related_name='placements', on_delete=fields.CASCADE, index=True
- )
- purchase: fields.ForeignKeyRelation['Purchase'] | None = fields.ForeignKeyField(
- 'models.Purchase', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
- )
- purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] | None = fields.ForeignKeyField(
- 'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, null=True, index=True
+ purchase_channel: fields.ForeignKeyRelation['PurchaseChannel'] = fields.ForeignKeyField(
+ 'models.PurchaseChannel', related_name='placements', on_delete=fields.CASCADE, index=True
)
post: fields.ForeignKeyRelation['Post'] | None = fields.ForeignKeyField(
@@ -59,13 +47,10 @@ class Placement(TimestampedModel):
)
if TYPE_CHECKING:
- workspace_id: UUID
project_id: UUID
creative_id: UUID
- placement_channel_id: UUID
- purchase_id: UUID | None
post_id: UUID | None
- purchase_channel_id: UUID | None
+ purchase_channel_id: UUID
class Meta:
table = 'placement'
diff --git a/src/domain/purchase.py b/src/domain/purchase.py
index 8bba5c0..238f0b9 100644
--- a/src/domain/purchase.py
+++ b/src/domain/purchase.py
@@ -10,7 +10,6 @@ if TYPE_CHECKING:
from .channel import Channel
from .creative import Creative
from .project import Project
- from .workspace import Workspace
class PurchaseStatus(enum.StrEnum):
@@ -54,9 +53,9 @@ class Purchase(TimestampedModel):
'models.Creative', related_name='purchases', on_delete=fields.CASCADE, index=True
)
- workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
- 'models.Workspace', related_name='purchases', on_delete=fields.CASCADE, index=True
- )
+ if TYPE_CHECKING:
+ project_id: uuid.UUID
+ creative_id: uuid.UUID
class Meta:
table = 'purchase'
diff --git a/src/dto/placement.py b/src/dto/placement.py
index 7ea0250..4fc5e77 100644
--- a/src/dto/placement.py
+++ b/src/dto/placement.py
@@ -47,9 +47,7 @@ class GetPlacementInput(pydantic.BaseModel):
class CreatePlacementInput(pydantic.BaseModel):
- project_id: uuid.UUID
- placement_channel_id: uuid.UUID
- creative_id: uuid.UUID
+ purchase_channel_id: uuid.UUID
placement_date: datetime.datetime
cost: float | None = None
comment: str | None = None
diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py
index 08076bb..b368109 100644
--- a/src/usecase/__init__.py
+++ b/src/usecase/__init__.py
@@ -175,6 +175,8 @@ class Database(typing.Protocol):
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(
@@ -225,6 +227,10 @@ class Database(typing.Protocol):
date_to: datetime.datetime | None = None,
) -> list[domain.Placement]: ...
+ async def create_placement(self, placement: domain.Placement) -> None: ...
+
+ async def update_placement(self, placement: domain.Placement) -> None: ...
+
async def delete_placement(self, placement_id: UUID) -> None: ...
async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ...
@@ -398,7 +404,7 @@ class Usecase:
if purchase:
return purchase
- purchase = domain.Purchase(workspace_id=project.workspace_id, project_id=project.id, creative_id=creative.id)
+ purchase = domain.Purchase(project_id=project.id, creative_id=creative.id)
await self.database.create_purchase(purchase)
return purchase
diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py
index f85afba..7c5dce3 100644
--- a/src/usecase/analytics/get_channel_analytics.py
+++ b/src/usecase/analytics/get_channel_analytics.py
@@ -47,8 +47,9 @@ async def get_channel_analytics(
if input.project_id is None and allowed_project_ids is not None:
channel_map: dict[UUID, domain.Channel] = {}
for placement in placements:
- if placement.placement_channel:
- channel_map[placement.placement_channel_id] = placement.placement_channel
+ purchase_channel = placement.purchase_channel
+ if purchase_channel and purchase_channel.channel:
+ channel_map[purchase_channel.channel_id] = purchase_channel.channel
channels = list(channel_map.values())
@dataclass
@@ -69,7 +70,10 @@ async def get_channel_analytics(
subscriptions_counts = await self.database.count_subscriptions_by_placement_batch(placement_ids)
for p in placements:
- stats = channel_stats.get(p.placement_channel_id)
+ purchase_channel = p.purchase_channel
+ if not purchase_channel:
+ continue
+ stats = channel_stats.get(purchase_channel.channel_id)
if stats is None:
continue
diff --git a/src/usecase/analytics/get_overview_analytics.py b/src/usecase/analytics/get_overview_analytics.py
index 3a6dfa5..d8e1f44 100644
--- a/src/usecase/analytics/get_overview_analytics.py
+++ b/src/usecase/analytics/get_overview_analytics.py
@@ -131,19 +131,21 @@ async def get_overview_analytics(
project_meta: dict[UUID, domain.Project] = {}
for placement in current_placements:
- placement_date = placement.wanted_placement_date.date()
+ placement_day = placement.wanted_placement_date.date()
project_meta[placement.project_id] = placement.project
cost_value = placement.cost or 0.0
- cost_per_day[placement_date] += cost_value
+ cost_per_day[placement_day] += cost_value
project_spending_map[placement.project_id] += cost_value
subs = subs_per_placement_current.get(placement.id, 0)
- channel_id = placement.placement_channel_id
- if channel_id not in channel_stats:
- channel_stats[channel_id] = ChannelAggregate(channel=placement.placement_channel)
- channel_stats[channel_id].total_cost += cost_value
- channel_stats[channel_id].total_subs += subs
+ purchase_channel = placement.purchase_channel
+ if purchase_channel:
+ channel_id = purchase_channel.channel_id
+ if channel_id not in channel_stats:
+ channel_stats[channel_id] = ChannelAggregate(channel=purchase_channel.channel)
+ channel_stats[channel_id].total_cost += cost_value
+ channel_stats[channel_id].total_subs += subs
start_date = input.date_from.date()
end_date = input.date_to.date()
diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py
index 97021f6..28f0713 100644
--- a/src/usecase/analytics/get_placements_analytics.py
+++ b/src/usecase/analytics/get_placements_analytics.py
@@ -57,8 +57,8 @@ async def get_placements_analytics(
id=p.id,
project_id=p.project_id,
project_channel_title=p.project.channel.title,
- placement_channel_id=p.placement_channel_id,
- placement_channel_title=p.placement_channel.title,
+ placement_channel_id=p.purchase_channel.channel_id,
+ placement_channel_title=p.purchase_channel.channel.title,
creative_id=p.creative_id,
creative_name=p.creative.name,
placement_date=p.wanted_placement_date,
diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py
index 0e67b8a..30c2e2e 100644
--- a/src/usecase/channel/attach_channel_to_workspace.py
+++ b/src/usecase/channel/attach_channel_to_workspace.py
@@ -47,4 +47,5 @@ async def attach_channel_to_workspace(
title=channel.title,
username=channel.username,
status=project.status,
+ purchase_invite_type_default=project.purchase_invite_type_default,
)
diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py
index 4efa647..50ffd1f 100644
--- a/src/usecase/creative/create_creative.py
+++ b/src/usecase/creative/create_creative.py
@@ -43,7 +43,6 @@ async def create_creative(
buttons=[button.model_dump() for button in input.buttons],
status=domain.CreativeStatus.ACTIVE,
project_id=project.id,
- workspace_id=workspace_id,
)
await self.database.create_creative(creative)
diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py
deleted file mode 100644
index 69e36d2..0000000
--- a/src/usecase/placement/create_placement.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import logging
-import uuid
-from typing import TYPE_CHECKING
-
-import domain.purchase
-from src import domain, dto
-
-if TYPE_CHECKING:
- from .. import Usecase
-
-log = logging.getLogger(__name__)
-
-
-async def create_placement(
- self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID
-) -> dto.PlacementOutput:
- await self.ensure_workspace_permission(
- workspace_id,
- user_id,
- domain.PermissionKey.PLACEMENTS_WRITE,
- for_project_id=input.project_id,
- )
-
- project = await self.database.get_project(workspace_id, project_id=input.project_id)
- if not project:
- raise domain.ProjectNotFound(input.project_id)
-
- placement_channel = await self.database.get_channel(channel_id=input.placement_channel_id)
- if not placement_channel:
- raise domain.ChannelNotFound(input.placement_channel_id)
-
- creative = await self.database.get_creative(workspace_id, input.creative_id)
- if not creative:
- raise domain.CreativeNotFound(input.creative_id)
- if creative.project_id != project.id:
- raise domain.CreativeNotFound(input.creative_id)
-
- # Берем тип ссылки из настроек проекта
- requires_approval = project.purchase_invite_type_default == domain.purchase.InviteLinkType.APPROVAL
-
- if project.channel.telegram_id is None:
- raise domain.ChannelNotFound(project.channel.id)
-
- invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
- log.info(
- 'Created invite link for project=%s channel_telegram_id=%s requires_approval=%s invite_link=%s',
- project.id,
- project.channel.telegram_id,
- requires_approval,
- invite_link,
- )
-
- placement = domain.Placement(
- project_id=input.project_id,
- placement_channel_id=input.placement_channel_id,
- creative_id=input.creative_id,
- workspace_id=workspace_id,
- placement_date=input.placement_date,
- cost=input.cost,
- comment=input.comment,
- invite_link=invite_link,
- status=domain.PlacementStatus.ACTIVE,
- )
-
- await self.database.create_placement(placement)
-
- return dto.PlacementOutput(
- id=placement.id,
- project_id=placement.project_id,
- project_channel_title=project.channel.title,
- placement_channel_id=placement.placement_channel_id,
- placement_channel_title=placement_channel.title,
- creative_id=placement.creative_id,
- creative_name=creative.name,
- placement_date=placement.wanted_placement_date,
- cost=placement.cost,
- comment=placement.comment,
- ad_post_url=None,
- invite_link_type=project.purchase_invite_type_default,
- invite_link=placement.invite_link,
- status=placement.status,
- subscriptions_count=0,
- created_at=placement.created_at,
- )
diff --git a/src/usecase/placement/delete_placement.py b/src/usecase/placement/delete_placement.py
deleted file mode 100644
index 21735d6..0000000
--- a/src/usecase/placement/delete_placement.py
+++ /dev/null
@@ -1,24 +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 delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None:
- context = await self.ensure_workspace_permission(
- input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE
- )
-
- 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_WRITE, placement.project_id)
-
- await self.database.delete_placement(input.placement_id)
diff --git a/src/usecase/placement/fetch_placement_post_cycle.py b/src/usecase/placement/fetch_placement_post_cycle.py
index 1251642..cf69361 100644
--- a/src/usecase/placement/fetch_placement_post_cycle.py
+++ b/src/usecase/placement/fetch_placement_post_cycle.py
@@ -44,12 +44,8 @@ async def fetch_placement_post_cycle(self: 'Usecase', interval_seconds: int) ->
wanted_placement_date=post.created_at,
cost=None,
comment=None,
- invite_link=purchase_channel.invite_link,
- workspace_id=purchase_channel.purchase.workspace_id,
project_id=purchase_channel.purchase.project_id,
creative_id=purchase_channel.purchase.creative_id,
- placement_channel_id=purchase_channel.channel_id,
- purchase_id=purchase_channel.purchase_id,
purchase_channel_id=purchase_channel.id,
post=post,
status=domain.PlacementStatus.ACTIVE,
diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py
index 6a2499d..9fd10fa 100644
--- a/src/usecase/placement/get_placement.py
+++ b/src/usecase/placement/get_placement.py
@@ -22,6 +22,10 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
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)
@@ -29,17 +33,19 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl
id=placement.id,
project_id=placement.project_id,
project_channel_title=placement.project.channel.title,
- placement_channel_id=placement.placement_channel_id,
- placement_channel_title=placement.placement_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=placement.project.purchase_invite_type_default,
- invite_link=placement.invite_link,
+ invite_link_type=purchase_channel.invite_link_type,
+ invite_link=purchase_channel.invite_link,
status=placement.status,
subscriptions_count=subscriptions_count,
+ purchase_id=purchase_channel.purchase_id,
+ purchase_channel_id=purchase_channel.id,
created_at=placement.created_at,
)
diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py
index 7a9c931..0f1eb1e 100644
--- a/src/usecase/placement/get_placements.py
+++ b/src/usecase/placement/get_placements.py
@@ -1,3 +1,4 @@
+import logging
from typing import TYPE_CHECKING
from src import domain, dto
@@ -5,6 +6,8 @@ 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(
@@ -32,24 +35,30 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> list
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=placement.placement_channel_id,
- placement_channel_title=placement.placement_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=placement.project.purchase_invite_type_default,
- invite_link=placement.invite_link,
+ 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,
)
)
diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py
deleted file mode 100644
index 766bb41..0000000
--- a/src/usecase/placement/update_placement.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import logging
-import uuid
-from typing import TYPE_CHECKING
-
-from src import domain, dto
-
-if TYPE_CHECKING:
- from .. import Usecase
-
-log = logging.getLogger(__name__)
-
-
-async def update_placement(
- self: 'Usecase',
- placement_id: uuid.UUID,
- input: dto.UpdatePlacementInput,
- user_id: uuid.UUID,
- workspace_id: uuid.UUID,
-) -> dto.PlacementOutput:
- context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE)
-
- placement = await self.database.get_placement(workspace_id, placement_id)
- if not placement:
- log.warning('Placement %s not found for user %s', placement_id, user_id)
- raise domain.PlacementNotFound(placement_id)
-
- context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
-
- if input.placement_date is not None:
- placement.wanted_placement_date = input.placement_date
- if input.cost is not None:
- placement.cost = input.cost
- if input.comment is not None:
- placement.comment = input.comment
- if input.status is not None:
- placement.status = input.status
-
- await self.database.update_placement(placement)
-
- ad_post_url = placement.post.url if placement.post else None
-
- 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=placement.placement_channel_id,
- placement_channel_title=placement.placement_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=placement.project.purchase_invite_type_default,
- invite_link=placement.invite_link,
- status=placement.status,
- subscriptions_count=subscriptions_count,
- created_at=placement.created_at,
- )
diff --git a/src/usecase/purchase/create_purchase.py b/src/usecase/purchase/create_purchase.py
index cdb275b..12c52f7 100644
--- a/src/usecase/purchase/create_purchase.py
+++ b/src/usecase/purchase/create_purchase.py
@@ -99,7 +99,6 @@ async def create_purchase(
details = input.details
purchase = domain.Purchase(
- workspace_id=workspace_id,
project_id=project.id,
creative_id=creative.id,
placement_at=details.placement_at if details else None,
diff --git a/telegram_bot/screens/purchase_details.go b/telegram_bot/screens/purchase_details.go
index 8565709..8b5aa3b 100644
--- a/telegram_bot/screens/purchase_details.go
+++ b/telegram_bot/screens/purchase_details.go
@@ -3,7 +3,9 @@ package screens
import (
"context"
"fmt"
+ "strings"
+ "example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
@@ -42,6 +44,8 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
return
}
+ placementsByChannel := s.loadPlacementsByChannel(b, jwt, purchase)
+
// Получаем информацию о креативе
creative, err := b.Backend.GetCreative(
context.Background(),
@@ -54,13 +58,18 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
creative = nil
}
+ totalPlacements := 0
+ for _, items := range placementsByChannel {
+ totalPlacements += len(items)
+ }
+
text := "… › Закупы › Детали\n\nДетали закупа\n\n"
// Информация о креативе
if creative != nil {
- text += fmt.Sprintf("▪ Креатив: %s\n", creative.Name)
+ text += fmt.Sprintf("• Креатив: %s\n", creative.Name)
} else {
- text += fmt.Sprintf("▪ Креатив ID: %s\n", purchase.CreativeID)
+ text += fmt.Sprintf("• Креатив ID: %s\n", purchase.CreativeID)
}
// Статус закупа
@@ -70,7 +79,9 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
statusSymbol = "■"
statusText = "Архивирован"
}
- text += fmt.Sprintf("▪ Статус: %s %s\n\n", statusSymbol, statusText)
+ text += fmt.Sprintf("• Статус: %s %s\n", statusSymbol, statusText)
+ text += fmt.Sprintf("• Каналов: %d\n", len(purchase.Channels))
+ text += fmt.Sprintf("• Размещений: %d\n\n", totalPlacements)
// Информация о каналах
if len(purchase.Channels) == 0 {
@@ -79,20 +90,7 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
text += fmt.Sprintf("Каналы (%d):\n\n", len(purchase.Channels))
for i, ch := range purchase.Channels {
- // Определяем статус
- statusSymbol := "○"
- switch ch.Status {
- case "planned":
- statusSymbol = "○"
- case "approved":
- statusSymbol = "◉"
- case "rejected":
- statusSymbol = "⊗"
- case "in_progress":
- statusSymbol = "◐"
- case "completed":
- statusSymbol = "●"
- }
+ chStatusText := formatPurchaseChannelStatus(ch.Status)
// Название канала
channelName := "Без названия"
@@ -102,16 +100,43 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
channelName = "@" + *ch.Channel.Username
}
- text += fmt.Sprintf("%d. %s %s\n", i+1, statusSymbol, channelName)
+ text += fmt.Sprintf("%d. %s\n", i+1, channelName)
+ text += fmt.Sprintf(" ◦ %s\n", chStatusText)
// Стоимость
if ch.Details != nil && ch.Details.Cost != nil {
- text += fmt.Sprintf(" ▸ Стоимость: %.0f₽\n", ch.Details.Cost.Value)
+ text += fmt.Sprintf(" ◦ Стоимость: %.0f₽\n", ch.Details.Cost.Value)
+ }
+
+ // Дата размещения (план)
+ if ch.Details != nil && ch.Details.PlacementAt != nil && *ch.Details.PlacementAt != "" {
+ text += fmt.Sprintf(" ◦ План: %s\n", *ch.Details.PlacementAt)
}
// Комментарий
if ch.Comment != nil && *ch.Comment != "" {
- text += fmt.Sprintf(" ▸ %s\n", *ch.Comment)
+ text += fmt.Sprintf(" ◦ %s\n", *ch.Comment)
+ }
+
+ // Информация о размещении
+ placements := placementsByChannel[ch.ID]
+ if len(placements) == 0 {
+ text += " ◦ Размещение: нет\n"
+ } else {
+ placement := placements[0]
+ text += fmt.Sprintf(" ◦ Размещение: есть (%d)\n", len(placements))
+ if placement.Status != "" {
+ text += fmt.Sprintf(" ◦ Статус размещения: %s\n", formatPlacementStatus(placement.Status))
+ }
+ if placement.PlacementDate != "" {
+ text += fmt.Sprintf(" ◦ Дата: %s\n", placement.PlacementDate)
+ }
+ if placement.AdPostURL != nil && *placement.AdPostURL != "" {
+ text += fmt.Sprintf(" ◦ Пост: %s\n", *placement.AdPostURL)
+ }
+ if placement.SubscriptionsCount > 0 {
+ text += fmt.Sprintf(" ◦ Подписки: %d\n", placement.SubscriptionsCount)
+ }
}
// Тип invite link
@@ -119,7 +144,7 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
if ch.InviteLinkType == "approval" {
inviteLinkTypeText = "с одобрением"
}
- text += fmt.Sprintf(" ▸ Ссылка: %s\n", inviteLinkTypeText)
+ text += fmt.Sprintf(" ◦ Ссылка: %s\n", inviteLinkTypeText)
text += "\n"
}
@@ -136,6 +161,94 @@ func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt str
b.Render(text, keyboard, mode)
}
+func (s *PurchaseDetails) loadPlacementsByChannel(
+ b *bot.Bot,
+ jwt string,
+ purchase *backend.Purchase,
+) map[string][]backend.Placement {
+ placementsByChannel := make(map[string][]backend.Placement)
+ if purchase == nil || purchase.CreativeID == "" || len(purchase.Channels) == 0 {
+ return placementsByChannel
+ }
+
+ projectID := s.ProjectID
+ creativeID := purchase.CreativeID
+ pageSize := 50
+ maxPages := 5
+ foundChannels := make(map[string]bool)
+
+ for page := 1; page <= maxPages; page++ {
+ resp, err := b.Backend.GetPlacements(
+ context.Background(),
+ jwt,
+ s.WorkspaceID,
+ &projectID,
+ nil,
+ &creativeID,
+ false,
+ page,
+ pageSize,
+ )
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to get placements for purchase details")
+ break
+ }
+
+ for _, placement := range resp.Items {
+ if placement.PurchaseID == nil || *placement.PurchaseID != purchase.ID {
+ continue
+ }
+ if placement.PurchaseChannelID == nil || *placement.PurchaseChannelID == "" {
+ continue
+ }
+ chID := *placement.PurchaseChannelID
+ placementsByChannel[chID] = append(placementsByChannel[chID], placement)
+ foundChannels[chID] = true
+ }
+
+ if resp.Pages <= page || len(foundChannels) == len(purchase.Channels) {
+ break
+ }
+ }
+
+ return placementsByChannel
+}
+
+func formatPurchaseChannelStatus(status string) string {
+ switch status {
+ case "planned":
+ return "Планируется"
+ case "approved":
+ return "Согласовано"
+ case "rejected":
+ return "Отклонено"
+ case "in_progress":
+ return "В работе"
+ case "completed":
+ return "Завершено"
+ default:
+ return "Неизвестно"
+ }
+}
+
+func formatPlacementStatus(status string) string {
+ normalized := strings.TrimSpace(strings.ToLower(status))
+ switch normalized {
+ case "planned":
+ return "Планируется"
+ case "approved":
+ return "Согласовано"
+ case "rejected":
+ return "Отклонено"
+ case "in_progress":
+ return "В работе"
+ case "completed":
+ return "Размещено"
+ default:
+ return status
+ }
+}
+
func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go
index 6d3fff7..f68b95c 100644
--- a/telegram_bot/screens/purchase_optional_details.go
+++ b/telegram_bot/screens/purchase_optional_details.go
@@ -1283,9 +1283,13 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
}
}
- b.SendNew("✅ Закуп создан", bot.Keyboard(
- bot.Row(bot.Button("● Готово", "done")),
- ))
+ b.SetState(&PurchaseDetails{
+ WorkspaceID: s.WorkspaceID,
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ PurchaseID: purchase.ID,
+ BackState: s.BackState,
+ }, bot.NewMessage)
}
func (s *PurchaseOptionalDetails) buildPurchaseDetails(purchaseType *string) *backend.PurchaseDetails {
@@ -1449,5 +1453,15 @@ func (s *PurchaseOptionalDetails) injectInviteLink(text, inviteLink string) stri
return text
}
re := regexp.MustCompile(`([^<]*)`)
- return re.ReplaceAllString(text, fmt.Sprintf(`$1`, inviteLink))
+ return re.ReplaceAllStringFunc(text, func(match string) string {
+ sub := re.FindStringSubmatch(match)
+ if len(sub) < 2 {
+ return match
+ }
+ inner := strings.TrimSpace(sub[1])
+ if inner == "" {
+ return fmt.Sprintf(`%s`, inviteLink, inviteLink)
+ }
+ return fmt.Sprintf(`%s`, inviteLink, sub[1])
+ })
}
diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go
index c50df6a..1b480c8 100644
--- a/telegram_bot/screens/select_channels_for_purchase.go
+++ b/telegram_bot/screens/select_channels_for_purchase.go
@@ -245,22 +245,13 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
return
}
- // Показываем успешное создание
- successText := fmt.Sprintf(`
-… › Создание › Готово
-
-Закуп создан
-
- ▪ ID: %s
- ▪ Креатив: %s
- ▪ Каналов: %d
-
-Закуп создан и готов к работе
-`, purchase.ID, purchase.CreativeID, len(purchase.Channels))
-
- b.Edit(successText, bot.Keyboard(
- bot.Row(bot.Button("● Готово", "done")),
- ))
+ b.SetState(&PurchaseDetails{
+ WorkspaceID: s.WorkspaceID,
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ PurchaseID: purchase.ID,
+ BackState: s.BackState,
+ }, bot.EditMessage)
}
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {