From f3e09a08eb25d7d717023f156add471dc1c5689b Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sun, 14 Dec 2025 12:21:35 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BE=D0=B1=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/models/0_20251211144442_init.py | 221 ------------- migrations/models/0_20251214122113_init.py | 309 ++++++++++++++++++ ...ange_external_channel_unique_constraint.py | 72 ---- migrations/models/2_20251213220918_update.py | 124 ------- src/adapter/postgres.py | 271 ++++++++------- src/controller/http/__init__.py | 10 +- src/controller/http/analytics.py | 26 +- src/controller/http/channels.py | 18 + src/controller/http/creatives.py | 4 +- src/controller/http/external_channels.py | 92 ------ src/controller/http/placements.py | 8 +- .../http/{target_channels.py => projects.py} | 12 +- src/controller/http/purchase_plans.py | 74 +++++ src/controller/telegram_callback/__init__.py | 2 +- .../telegram_callback/my_chat_member.py | 12 +- ...hannel_commands.py => project_commands.py} | 6 +- src/domain/__init__.py | 58 +++- src/domain/channel.py | 34 ++ src/domain/creative.py | 8 +- src/domain/error.py | 34 +- src/domain/external_channel.py | 30 -- src/domain/placement.py | 16 +- src/domain/project.py | 37 +++ src/domain/purchase_plan.py | 63 ++++ src/domain/subscriber.py | 14 - src/domain/subscription.py | 11 +- src/domain/target_channel.py | 43 --- src/domain/telegram_state.py | 2 +- src/domain/user.py | 2 + src/domain/workspace.py | 70 ++++ src/dto/__init__.py | 70 ++-- src/dto/analytics.py | 24 +- src/dto/channel.py | 21 ++ src/dto/creative.py | 8 +- src/dto/external_channel.py | 49 --- src/dto/placement.py | 16 +- src/dto/{target_channel.py => project.py} | 20 +- src/dto/purchase_plan.py | 38 +++ src/usecase/__init__.py | 141 +++++--- src/usecase/analytics/__init__.py | 11 - ..._analytics.py => get_channel_analytics.py} | 30 +- .../analytics/get_creatives_analytics.py | 12 +- .../analytics/get_placements_analytics.py | 18 +- .../analytics/get_spending_analytics.py | 10 +- src/usecase/channel/get_channels.py | 28 ++ src/usecase/creative/create_creative.py | 14 +- src/usecase/creative/get_creative.py | 4 +- src/usecase/creative/get_creatives.py | 6 +- src/usecase/creative/tg_create_creative_1.py | 15 +- src/usecase/creative/tg_create_creative_2.py | 15 +- src/usecase/creative/tg_create_creative_3.py | 20 +- src/usecase/creative/tg_create_creative_4.py | 16 +- src/usecase/creative/update_creative.py | 4 +- .../create_external_channel.py | 53 --- .../delete_external_channel.py | 20 -- .../external_channel/get_external_channels.py | 34 -- .../update_external_channel.py | 47 --- .../update_external_channel_links.py | 54 --- src/usecase/placement/create_placement.py | 32 +- src/usecase/placement/get_placement.py | 8 +- src/usecase/placement/get_placements.py | 12 +- src/usecase/placement/update_placement.py | 8 +- .../project/disconnect_project_by_tg_id.py | 26 ++ src/usecase/project/get_workspace_projects.py | 27 ++ .../tg_add_project_1.py} | 60 ++-- .../tg_add_project_2.py} | 46 ++- .../update_project_permissions.py} | 36 +- .../attach_channel_to_purchase_plan.py | 67 ++++ .../get_purchase_plan_channels.py | 43 +++ .../remove_purchase_plan_channel.py | 33 ++ .../update_purchase_plan_channel.py | 71 ++++ .../subscription/handle_subscription.py | 28 +- .../subscription/handle_unsubscription.py | 11 +- .../disconnect_target_chan_by_tg_id.py | 26 -- .../target_channel/get_user_target_chans.py | 26 -- 75 files changed, 1624 insertions(+), 1417 deletions(-) delete mode 100644 migrations/models/0_20251211144442_init.py create mode 100644 migrations/models/0_20251214122113_init.py delete mode 100644 migrations/models/1_20251211214049_change_external_channel_unique_constraint.py delete mode 100644 migrations/models/2_20251213220918_update.py create mode 100644 src/controller/http/channels.py delete mode 100644 src/controller/http/external_channels.py rename src/controller/http/{target_channels.py => projects.py} (52%) create mode 100644 src/controller/http/purchase_plans.py rename src/controller/telegram_callback/{target_channel_commands.py => project_commands.py} (82%) create mode 100644 src/domain/channel.py delete mode 100644 src/domain/external_channel.py create mode 100644 src/domain/project.py create mode 100644 src/domain/purchase_plan.py delete mode 100644 src/domain/subscriber.py delete mode 100644 src/domain/target_channel.py create mode 100644 src/dto/channel.py delete mode 100644 src/dto/external_channel.py rename src/dto/{target_channel.py => project.py} (57%) create mode 100644 src/dto/purchase_plan.py delete mode 100644 src/usecase/analytics/__init__.py rename src/usecase/analytics/{get_external_channels_analytics.py => get_channel_analytics.py} (65%) create mode 100644 src/usecase/channel/get_channels.py delete mode 100644 src/usecase/external_channel/create_external_channel.py delete mode 100644 src/usecase/external_channel/delete_external_channel.py delete mode 100644 src/usecase/external_channel/get_external_channels.py delete mode 100644 src/usecase/external_channel/update_external_channel.py delete mode 100644 src/usecase/external_channel/update_external_channel_links.py create mode 100644 src/usecase/project/disconnect_project_by_tg_id.py create mode 100644 src/usecase/project/get_workspace_projects.py rename src/usecase/{target_channel/tg_add_target_chan_1.py => project/tg_add_project_1.py} (73%) rename src/usecase/{target_channel/tg_add_target_chan_2.py => project/tg_add_project_2.py} (65%) rename src/usecase/{target_channel/update_target_chan_permissions.py => project/update_project_permissions.py} (50%) create mode 100644 src/usecase/purchase_plan/attach_channel_to_purchase_plan.py create mode 100644 src/usecase/purchase_plan/get_purchase_plan_channels.py create mode 100644 src/usecase/purchase_plan/remove_purchase_plan_channel.py create mode 100644 src/usecase/purchase_plan/update_purchase_plan_channel.py delete mode 100644 src/usecase/target_channel/disconnect_target_chan_by_tg_id.py delete mode 100644 src/usecase/target_channel/get_user_target_chans.py diff --git a/migrations/models/0_20251211144442_init.py b/migrations/models/0_20251211144442_init.py deleted file mode 100644 index 286bbc5..0000000 --- a/migrations/models/0_20251211144442_init.py +++ /dev/null @@ -1,221 +0,0 @@ -from tortoise import BaseDBAsyncClient - -RUN_IN_TRANSACTION = True - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - CREATE TABLE IF NOT EXISTS "subscriber" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "telegram_id" BIGINT NOT NULL UNIQUE, - "username" VARCHAR(255), - "first_name" VARCHAR(255), - "last_name" VARCHAR(255) -); -CREATE INDEX IF NOT EXISTS "idx_subscriber_telegra_5eef32" ON "subscriber" ("telegram_id"); -CREATE TABLE IF NOT EXISTS "telegram_state" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "telegram_id" BIGINT NOT NULL UNIQUE, - "state" VARCHAR(24) NOT NULL, - "context" JSONB NOT NULL -); -CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id"); -COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text'; -CREATE TABLE IF NOT EXISTS "user" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "telegram_id" BIGINT NOT NULL UNIQUE, - "username" VARCHAR(255) -); -CREATE INDEX IF NOT EXISTS "idx_user_telegra_66ffbd" ON "user" ("telegram_id"); -CREATE TABLE IF NOT EXISTS "external_channel" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "telegram_id" BIGINT NOT NULL UNIQUE, - "title" VARCHAR(255) NOT NULL, - "username" VARCHAR(255), - "description" TEXT, - "subscribers_count" INT, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id"); -CREATE INDEX IF NOT EXISTS "idx_external_ch_usernam_9b9fb4" ON "external_channel" ("username"); -CREATE INDEX IF NOT EXISTS "idx_external_ch_user_id_dd2b1c" ON "external_channel" ("user_id"); -CREATE TABLE IF NOT EXISTS "login_token" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "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 TABLE IF NOT EXISTS "target_channel" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "telegram_id" BIGINT NOT NULL UNIQUE, - "title" VARCHAR(255) NOT NULL, - "username" VARCHAR(255), - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_target_chan_telegra_ab5a54" ON "target_channel" ("telegram_id"); -CREATE INDEX IF NOT EXISTS "idx_target_chan_usernam_a6cf8b" ON "target_channel" ("username"); -CREATE INDEX IF NOT EXISTS "idx_target_chan_user_id_c3bdc7" ON "target_channel" ("user_id"); -COMMENT ON COLUMN "target_channel"."status" IS 'ACTIVE: active\nINACTIVE: inactive\nARCHIVED: archived'; -CREATE TABLE IF NOT EXISTS "creative" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "name" VARCHAR(255) NOT NULL, - "text" TEXT NOT NULL, - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "placements_count" INT NOT NULL DEFAULT 0, - "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_creative_target__8e3b64" ON "creative" ("target_channel_id"); -CREATE INDEX IF NOT EXISTS "idx_creative_user_id_a27cfc" ON "creative" ("user_id"); -COMMENT ON COLUMN "creative"."status" IS 'ACTIVE: active\nARCHIVED: archived'; -CREATE TABLE IF NOT EXISTS "placement" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "placement_date" TIMESTAMPTZ NOT NULL, - "cost" DOUBLE PRECISION, - "comment" TEXT, - "ad_post_url" VARCHAR(512), - "invite_link_type" VARCHAR(8) NOT NULL, - "invite_link" VARCHAR(512) NOT NULL, - "external_channel_message_id" INT, - "status" VARCHAR(8) NOT NULL DEFAULT 'active', - "subscriptions_count" INT NOT NULL DEFAULT 0, - "views_count" INT, - "views_availability" VARCHAR(11) NOT NULL DEFAULT 'unknown', - "last_views_fetch_at" TIMESTAMPTZ, - "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, - "external_channel_id" UUID NOT NULL REFERENCES "external_channel" ("id") ON DELETE CASCADE, - "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE, - "user_id" UUID NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_placement_creativ_32111d" ON "placement" ("creative_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_externa_5b96c7" ON "placement" ("external_channel_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_target__ed9070" ON "placement" ("target_channel_id"); -CREATE INDEX IF NOT EXISTS "idx_placement_user_id_7abaf9" ON "placement" ("user_id"); -COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; -COMMENT ON COLUMN "placement"."status" IS 'PENDING: pending\nACTIVE: active\nDELETED: deleted\nARCHIVED: archived'; -COMMENT ON COLUMN "placement"."views_availability" IS 'UNKNOWN: unknown\nAVAILABLE: available\nUNAVAILABLE: unavailable\nMANUAL: manual'; -CREATE TABLE IF NOT EXISTS "placement_views_history" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "views_count" INT NOT NULL, - "fetched_at" TIMESTAMPTZ NOT NULL, - "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS "idx_placement_v_fetched_4c8f51" ON "placement_views_history" ("fetched_at"); -CREATE INDEX IF NOT EXISTS "idx_placement_v_placeme_89e0f4" ON "placement_views_history" ("placement_id"); -CREATE TABLE IF NOT EXISTS "subscription" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "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 "subscriber" ("id") ON DELETE CASCADE, - "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE -); -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"); -CREATE INDEX IF NOT EXISTS "idx_subscriptio_target__79117e" ON "subscription" ("target_channel_id"); -COMMENT ON COLUMN "subscription"."status" IS 'ACTIVE: active\nUNSUBSCRIBED: unsubscribed'; -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 -); -CREATE TABLE IF NOT EXISTS "target_external_channel" ( - "target_channel_id" UUID NOT NULL REFERENCES "target_channel" ("id") ON DELETE CASCADE, - "external_channel_id" UUID NOT NULL REFERENCES "external_channel" ("id") ON DELETE CASCADE -); -CREATE UNIQUE INDEX IF NOT EXISTS "uidx_target_exte_target__ba70ec" ON "target_external_channel" ("target_channel_id", "external_channel_id");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - """ - - -MODELS_STATE = ( - "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" -) diff --git a/migrations/models/0_20251214122113_init.py b/migrations/models/0_20251214122113_init.py new file mode 100644 index 0000000..4871ae4 --- /dev/null +++ b/migrations/models/0_20251214122113_init.py @@ -0,0 +1,309 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "telegram_state" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT NOT NULL UNIQUE, + "state" VARCHAR(26) NOT NULL, + "context" JSONB NOT NULL +); +CREATE INDEX IF NOT EXISTS "idx_telegram_st_telegra_750503" ON "telegram_state" ("telegram_id"); +COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_WORKSPACE: creative_waiting_workspace\nCREATIVE_WAITING_CHANNEL: creative_waiting_channel\nCREATIVE_WAITING_NAME: creative_waiting_name\nCREATIVE_WAITING_TEXT: creative_waiting_text\nPROJECT_WAITING_WORKSPACE: project_waiting_workspace'; +CREATE TABLE IF NOT EXISTS "user" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "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" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "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 TABLE IF NOT EXISTS "workspace" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL +); +CREATE TABLE IF NOT EXISTS "channel" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "telegram_id" BIGINT UNIQUE, + "username" VARCHAR(255), + "title" VARCHAR(255), + "verification_status" VARCHAR(10) NOT NULL DEFAULT 'unverified', + "workspace_id" UUID NOT NULL REFERENCES "workspace" ("id") ON DELETE CASCADE +); +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 INDEX IF NOT EXISTS "idx_channel_workspa_4a6a23" ON "channel" ("workspace_id"); +COMMENT ON COLUMN "channel"."verification_status" IS 'UNVERIFIED: unverified\nVERIFIED: verified\nFAILED: failed'; +CREATE TABLE IF NOT EXISTS "project" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "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'; +CREATE TABLE IF NOT EXISTS "creative" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "text" TEXT NOT NULL, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "placements_count" INT NOT NULL DEFAULT 0, + "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 "placement" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "placement_date" TIMESTAMPTZ NOT NULL, + "cost" DOUBLE PRECISION, + "comment" TEXT, + "ad_post_url" VARCHAR(512), + "invite_link_type" VARCHAR(8) NOT NULL, + "invite_link" VARCHAR(512) NOT NULL, + "external_channel_message_id" INT, + "status" VARCHAR(8) NOT NULL DEFAULT 'active', + "subscriptions_count" INT NOT NULL DEFAULT 0, + "views_count" INT, + "views_availability" VARCHAR(11) NOT NULL DEFAULT 'unknown', + "last_views_fetch_at" TIMESTAMPTZ, + "creative_id" UUID NOT NULL REFERENCES "creative" ("id") ON DELETE CASCADE, + "placement_channel_id" UUID NOT NULL REFERENCES "channel" ("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_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_project_2c6aa4" ON "placement" ("project_id"); +CREATE INDEX IF NOT EXISTS "idx_placement_workspa_632b20" ON "placement" ("workspace_id"); +COMMENT ON COLUMN "placement"."invite_link_type" IS 'PUBLIC: public\nAPPROVAL: approval'; +COMMENT ON COLUMN "placement"."status" IS 'PENDING: pending\nACTIVE: active\nDELETED: deleted\nARCHIVED: archived'; +COMMENT ON COLUMN "placement"."views_availability" IS 'UNKNOWN: unknown\nAVAILABLE: available\nUNAVAILABLE: unavailable\nMANUAL: manual'; +CREATE TABLE IF NOT EXISTS "placement_views_history" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "views_count" INT NOT NULL, + "fetched_at" TIMESTAMPTZ NOT NULL, + "placement_id" UUID NOT NULL REFERENCES "placement" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_placement_v_fetched_4c8f51" ON "placement_views_history" ("fetched_at"); +CREATE INDEX IF NOT EXISTS "idx_placement_v_placeme_89e0f4" ON "placement_views_history" ("placement_id"); +CREATE TABLE IF NOT EXISTS "purchase_plan" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "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_purchase_pl_project_ceb049" ON "purchase_plan" ("project_id"); +CREATE INDEX IF NOT EXISTS "idx_purchase_pl_workspa_e2459d" ON "purchase_plan" ("workspace_id"); +CREATE INDEX IF NOT EXISTS "idx_purchase_pl_project_a3c246" ON "purchase_plan" ("project_id", "status"); +COMMENT ON COLUMN "purchase_plan"."status" IS 'ACTIVE: active\nARCHIVED: archived'; +CREATE TABLE IF NOT EXISTS "purchase_plan_channel" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "status" VARCHAR(11) NOT NULL DEFAULT 'planned', + "planned_cost" DOUBLE PRECISION, + "comment" TEXT, + "channel_id" UUID NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE, + "purchase_plan_id" UUID NOT NULL REFERENCES "purchase_plan" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_purchase_pl_purchas_e3a717" UNIQUE ("purchase_plan_id", "channel_id") +); +CREATE INDEX IF NOT EXISTS "idx_purchase_pl_channel_4cfc1c" ON "purchase_plan_channel" ("channel_id"); +CREATE INDEX IF NOT EXISTS "idx_purchase_pl_purchas_d20693" ON "purchase_plan_channel" ("purchase_plan_id"); +COMMENT ON COLUMN "purchase_plan_channel"."status" IS 'PLANNED: planned\nAPPROVED: approved\nREJECTED: rejected\nIN_PROGRESS: in_progress\nCOMPLETED: completed'; +CREATE TABLE IF NOT EXISTS "subscription" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "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 +); +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" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "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" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "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\nREMOVED: removed'; +CREATE TABLE IF NOT EXISTS "workspace_user_permission" ( + "id" SERIAL 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(17) 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 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics'; +CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" ( + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMPTZ, + "id" UUID NOT NULL PRIMARY KEY, + "permission" VARCHAR(17) NOT NULL, + "scope_type" VARCHAR(9) NOT NULL, + "scope_id" UUID NOT NULL, + "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_workspace_u_workspa_488ed3" UNIQUE ("workspace_user_id", "permission", "scope_type", "scope_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 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics'; +COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project'; +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/1_20251211214049_change_external_channel_unique_constraint.py b/migrations/models/1_20251211214049_change_external_channel_unique_constraint.py deleted file mode 100644 index 5c0f8ad..0000000 --- a/migrations/models/1_20251211214049_change_external_channel_unique_constraint.py +++ /dev/null @@ -1,72 +0,0 @@ -from tortoise import BaseDBAsyncClient - -RUN_IN_TRANSACTION = True - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX IF EXISTS "uid_external_ch_telegra_13cca5"; - CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_user_id_289e6a" ON "external_channel" ("user_id", "telegram_id"); - CREATE INDEX IF NOT EXISTS "idx_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - DROP INDEX IF EXISTS "idx_external_ch_telegra_13cca5"; - DROP INDEX IF EXISTS "uid_external_ch_user_id_289e6a"; - CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_telegra_13cca5" ON "external_channel" ("telegram_id");""" - - -MODELS_STATE = ( - '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' - '8q//AeAg1wo=' -) diff --git a/migrations/models/2_20251213220918_update.py b/migrations/models/2_20251213220918_update.py deleted file mode 100644 index 5813882..0000000 --- a/migrations/models/2_20251213220918_update.py +++ /dev/null @@ -1,124 +0,0 @@ -from tortoise import BaseDBAsyncClient - -RUN_IN_TRANSACTION = True - - -async def upgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "target_channel" DROP CONSTRAINT IF EXISTS "fk_target_c_user_f3145593"; - ALTER TABLE "placement" DROP CONSTRAINT IF EXISTS "fk_placemen_user_004a0a62"; - DROP INDEX IF EXISTS "uid_external_ch_user_id_289e6a"; - ALTER TABLE "external_channel" DROP CONSTRAINT IF EXISTS "fk_external_user_23b1e9dc"; - ALTER TABLE "creative" DROP CONSTRAINT IF EXISTS "fk_creative_user_7868b532"; - CREATE TABLE IF NOT EXISTS "workspace" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "name" VARCHAR(255) NOT NULL -); - CREATE TABLE IF NOT EXISTS "workspace_user" ( - "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_at" TIMESTAMPTZ, - "id" UUID NOT NULL PRIMARY KEY, - "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"); - ALTER TABLE "creative" RENAME COLUMN "user_id" TO "workspace_id"; - ALTER TABLE "external_channel" RENAME COLUMN "user_id" TO "workspace_id"; - ALTER TABLE "placement" RENAME COLUMN "user_id" TO "workspace_id"; - ALTER TABLE "target_channel" RENAME COLUMN "user_id" TO "workspace_id"; - ALTER TABLE "telegram_state" ALTER COLUMN "state" TYPE VARCHAR(26) USING "state"::VARCHAR(26); - COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_WORKSPACE: creative_waiting_workspace -CREATIVE_WAITING_CHANNEL: creative_waiting_channel -CREATIVE_WAITING_NAME: creative_waiting_name -CREATIVE_WAITING_TEXT: creative_waiting_text'; - ALTER TABLE "creative" ADD CONSTRAINT "fk_creative_workspac_ac3ef81f" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE; - ALTER TABLE "external_channel" ADD CONSTRAINT "fk_external_workspac_5266308f" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE; - ALTER TABLE "placement" ADD CONSTRAINT "fk_placemen_workspac_c3bc4808" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE; - ALTER TABLE "target_channel" ADD CONSTRAINT "fk_target_c_workspac_8a2fd2e3" FOREIGN KEY ("workspace_id") REFERENCES "workspace" ("id") ON DELETE CASCADE;""" - - -async def downgrade(db: BaseDBAsyncClient) -> str: - return """ - ALTER TABLE "external_channel" DROP CONSTRAINT IF EXISTS "fk_external_workspac_5266308f"; - ALTER TABLE "target_channel" DROP CONSTRAINT IF EXISTS "fk_target_c_workspac_8a2fd2e3"; - ALTER TABLE "placement" DROP CONSTRAINT IF EXISTS "fk_placemen_workspac_c3bc4808"; - ALTER TABLE "creative" DROP CONSTRAINT IF EXISTS "fk_creative_workspac_ac3ef81f"; - ALTER TABLE "creative" RENAME COLUMN "workspace_id" TO "user_id"; - ALTER TABLE "placement" RENAME COLUMN "workspace_id" TO "user_id"; - ALTER TABLE "target_channel" RENAME COLUMN "workspace_id" TO "user_id"; - COMMENT ON COLUMN "telegram_state"."state" IS 'CREATIVE_WAITING_CHANNEL: creative_waiting_channel -CREATIVE_WAITING_NAME: creative_waiting_name -CREATIVE_WAITING_TEXT: creative_waiting_text'; - ALTER TABLE "telegram_state" ALTER COLUMN "state" TYPE VARCHAR(24) USING "state"::VARCHAR(24); - ALTER TABLE "external_channel" RENAME COLUMN "workspace_id" TO "user_id"; - DROP TABLE IF EXISTS "workspace_user"; - DROP TABLE IF EXISTS "workspace"; - ALTER TABLE "creative" ADD CONSTRAINT "fk_creative_user_7868b532" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE; - ALTER TABLE "placement" ADD CONSTRAINT "fk_placemen_user_004a0a62" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE; - ALTER TABLE "target_channel" ADD CONSTRAINT "fk_target_c_user_f3145593" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE; - ALTER TABLE "external_channel" ADD CONSTRAINT "fk_external_user_23b1e9dc" FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE; - CREATE UNIQUE INDEX IF NOT EXISTS "uid_external_ch_user_id_289e6a" ON "external_channel" ("user_id", "telegram_id");""" - - -MODELS_STATE = ( - "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" - "W5ItNdeILv1jLdc4tVqpHT/ut/5sP+/g==" -) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index ef77626..b502fd9 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -19,6 +19,9 @@ class Postgres(DatabaseBase): async def create_user(self, user: domain.User) -> None: await user.save() + async def update_user(self, user: domain.User) -> None: + await user.save() + async def create_workspace(self, workspace: domain.Workspace) -> None: await workspace.save() @@ -28,10 +31,12 @@ class Postgres(DatabaseBase): async def delete_workspace(self, workspace_id: uuid.UUID) -> None: await domain.Workspace.filter(id=workspace_id).delete() - async def add_user_to_workspace( - self, workspace_id: uuid.UUID, user_id: uuid.UUID, *, is_owner: bool = False - ) -> None: - await domain.WorkspaceUser.create(workspace_id=workspace_id, user_id=user_id, is_owner=is_owner) + async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: + await domain.WorkspaceUser.create( + workspace_id=workspace_id, + user_id=user_id, + status=domain.WorkspaceUserStatus.ACTIVE, + ) async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]: return ( @@ -71,9 +76,6 @@ class Postgres(DatabaseBase): async def create_login_token(self, login_token: domain.LoginToken) -> None: await login_token.save() - async def create_external_channel(self, channel: domain.ExternalChannel) -> None: - await channel.save() - async def create_creative(self, creative: domain.Creative) -> None: await creative.save() @@ -94,120 +96,165 @@ class Postgres(DatabaseBase): if updated == 0: raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован - async def get_target_channel( - self, workspace_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None - ) -> domain.TargetChannel | None: - if not channel_id and not telegram_id: - raise ValueError('Either channel_id or telegram_id must be provided') - - q = domain.TargetChannel.filter(workspace_id=workspace_id) - if channel_id: - q = q.filter(id=channel_id) - if telegram_id: - q = q.filter(telegram_id=telegram_id) - - return await q.first() - - async def get_target_channel_for_user_by_telegram( - self, user_id: uuid.UUID, telegram_id: int - ) -> domain.TargetChannel | None: - return ( - await domain.TargetChannel.filter(telegram_id=telegram_id, workspace__workspace_users__user_id=user_id) - .prefetch_related('workspace') - .first() - ) - - async def upsert_target_channel(self, channel: domain.TargetChannel) -> None: - existing = await self.get_target_channel(workspace_id=channel.workspace_id, telegram_id=channel.telegram_id) - - if not existing: - await channel.save() - return - - existing.title = channel.title - existing.username = channel.username - existing.workspace_id = channel.workspace_id - existing.status = channel.status - existing.deleted_at = None - existing.updated_at = timezone.now() - await existing.save() - - async def get_workspace_target_channels(self, workspace_id: uuid.UUID) -> list[domain.TargetChannel]: - return await domain.TargetChannel.filter( - workspace_id=workspace_id, status=domain.TargetChannelStatus.ACTIVE - ).all() - - async def update_target_channel(self, channel: domain.TargetChannel) -> None: - await channel.save() - - async def get_external_channel( - self, workspace_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None - ) -> domain.ExternalChannel | None: - query = domain.ExternalChannel.filter(workspace_id=workspace_id) + async def get_channel( + self, + workspace_id: uuid.UUID, + channel_id: uuid.UUID | None = None, + telegram_id: int | None = None, + username: str | None = None, + ) -> domain.Channel | None: + query = domain.Channel.filter(workspace_id=workspace_id) if channel_id: query = query.filter(id=channel_id) if telegram_id: query = query.filter(telegram_id=telegram_id) - + if username: + query = query.filter(username=username) return await query.first() - async def get_external_channels_for_target(self, target_channel_id: uuid.UUID) -> list[domain.ExternalChannel]: - return await domain.ExternalChannel.filter(target_channels__id=target_channel_id).all() + async def get_workspace_channels(self, workspace_id: uuid.UUID) -> list[domain.Channel]: + return await domain.Channel.filter(workspace_id=workspace_id).all() - async def get_workspace_external_channels(self, workspace_id: uuid.UUID) -> list[domain.ExternalChannel]: - return await domain.ExternalChannel.filter(workspace_id=workspace_id).all() - - async def add_external_channel_to_targets( - self, external_channel: domain.ExternalChannel, target_channel_ids: list[uuid.UUID] - ) -> None: - target_channels = await domain.TargetChannel.filter(id__in=target_channel_ids).all() - await external_channel.target_channels.add(*target_channels) - - async def remove_external_channel_from_target( - self, external_channel_id: uuid.UUID, target_channel_id: uuid.UUID - ) -> None: - external_channel = await domain.ExternalChannel.get_or_none(id=external_channel_id) - - if not external_channel: - raise ValueError(f'ExternalChannel {external_channel_id} not found') - - target_channel = await domain.TargetChannel.get_or_none(id=target_channel_id) - if target_channel: - await external_channel.target_channels.remove(target_channel) - - async def delete_external_channel(self, channel_id: uuid.UUID) -> None: - await domain.ExternalChannel.filter(id=channel_id).delete() - - async def update_external_channel(self, channel: domain.ExternalChannel) -> None: + async def create_channel(self, channel: domain.Channel) -> None: await channel.save() + async def update_channel(self, channel: domain.Channel) -> None: + await channel.save() + + async def delete_channel(self, channel_id: uuid.UUID) -> None: + await domain.Channel.filter(id=channel_id).delete() + + async def search_channels(self, username_query: str | None = None) -> list[domain.Channel]: + query = domain.Channel.all() + + if username_query: + # Частичный поиск (case-insensitive) + query = query.filter(username__icontains=username_query) + + return await query.all() + + async def get_project( + self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, channel_id: uuid.UUID | None = None + ) -> domain.Project | None: + query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel') + if project_id: + query = query.filter(id=project_id) + if channel_id: + query = query.filter(channel_id=channel_id) + return await query.first() + + async def get_project_for_user_by_telegram( + self, user_id: uuid.UUID, channel_telegram_id: int + ) -> domain.Project | None: + return ( + await domain.Project.filter( + channel__telegram_id=channel_telegram_id, workspace__workspace_users__user_id=user_id + ) + .prefetch_related('channel') + .first() + ) + + async def get_project_by_channel_telegram(self, channel_telegram_id: int) -> domain.Project | None: + return await domain.Project.filter(channel__telegram_id=channel_telegram_id).prefetch_related('channel').first() + + async def create_project(self, project: domain.Project) -> None: + await project.save() + + async def update_project(self, project: domain.Project) -> None: + await project.save() + + async def get_workspace_projects(self, workspace_id: uuid.UUID) -> list[domain.Project]: + return ( + await domain.Project.filter(workspace_id=workspace_id) + .prefetch_related('channel') + .order_by('created_at') + .all() + ) + + async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None: + return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE) + + async def get_purchase_plan(self, workspace_id: uuid.UUID, plan_id: uuid.UUID) -> domain.PurchasePlan | None: + return await domain.PurchasePlan.get_or_none(id=plan_id, workspace_id=workspace_id) + + async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: + await plan.save() + + async def get_purchase_plan_channels(self, plan_id: uuid.UUID) -> list[domain.PurchasePlanChannel]: + return await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_id).prefetch_related('channel').all() + + async def get_purchase_plan_channel( + self, plan_channel_id: uuid.UUID, plan_id: uuid.UUID + ) -> domain.PurchasePlanChannel | None: + return ( + await domain.PurchasePlanChannel.filter(id=plan_channel_id, purchase_plan_id=plan_id) + .prefetch_related('channel') + .first() + ) + + async def add_channel_to_purchase_plan( + self, + plan_id: uuid.UUID, + channel_id: uuid.UUID, + *, + status: domain.PurchasePlanChannelStatus | None = None, + planned_cost: float | None = None, + comment: str | None = None, + ) -> domain.PurchasePlanChannel: + defaults: dict[str, object | None] = {} + if status is not None: + defaults['status'] = status + if planned_cost is not None: + defaults['planned_cost'] = planned_cost + if comment is not None: + defaults['comment'] = comment + + plan_channel, created = await domain.PurchasePlanChannel.get_or_create( + purchase_plan_id=plan_id, + channel_id=channel_id, + defaults=defaults, + ) + + if not created and defaults: + for field, value in defaults.items(): + setattr(plan_channel, field, value) + await plan_channel.save() + + return plan_channel + + async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: + await plan_channel.save() + + async def remove_channel_from_purchase_plan(self, plan_id: uuid.UUID, channel_id: uuid.UUID) -> None: + await domain.PurchasePlanChannel.filter(purchase_plan_id=plan_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( - 'target_channel' + 'project', 'project__channel' ) async def update_creative(self, creative: domain.Creative) -> None: await creative.save() async def get_workspace_creatives( - self, workspace_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False + self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: query = domain.Creative.filter(workspace_id=workspace_id) - if target_channel_id: - query = query.filter(target_channel_id=target_channel_id) + if project_id: + query = query.filter(project_id=project_id) if not include_archived: query = query.filter(status=domain.CreativeStatus.ACTIVE) - return await query.prefetch_related('target_channel').order_by('-created_at').all() + return await query.prefetch_related('project', 'project__channel').order_by('-created_at').all() async def delete_creative(self, creative_id: uuid.UUID) -> None: 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( - 'target_channel', 'external_channel', 'creative' + 'project', 'project__channel', 'placement_channel', 'creative' ) async def create_placement(self, placement: domain.Placement) -> None: @@ -219,17 +266,17 @@ class Postgres(DatabaseBase): async def get_workspace_placements( self, workspace_id: uuid.UUID, - target_channel_id: uuid.UUID | None = None, - external_channel_id: uuid.UUID | None = None, + project_id: uuid.UUID | None = None, + placement_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, ) -> list[domain.Placement]: query = domain.Placement.filter(workspace_id=workspace_id) - if target_channel_id: - query = query.filter(target_channel_id=target_channel_id) - if external_channel_id: - query = query.filter(external_channel_id=external_channel_id) + if project_id: + query = query.filter(project_id=project_id) + if placement_channel_id: + query = query.filter(placement_channel_id=placement_channel_id) if creative_id: query = query.filter(creative_id=creative_id) @@ -237,7 +284,7 @@ class Postgres(DatabaseBase): query = query.filter(status=domain.PlacementStatus.ACTIVE) return ( - await query.prefetch_related('target_channel', 'external_channel', 'creative') + await query.prefetch_related('project', 'project__channel', 'placement_channel', 'creative') .order_by('-placement_date') .all() ) @@ -247,23 +294,9 @@ class Postgres(DatabaseBase): 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( - 'target_channel', 'external_channel', 'creative' + 'project', 'project__channel', 'placement_channel', 'creative' ) - async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: - return await domain.Subscriber.get_or_none(telegram_id=telegram_id) - - async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None: - existing = await self.get_subscriber(subscriber.telegram_id) - if not existing: - await subscriber.save() - return - - existing.username = subscriber.username - existing.first_name = subscriber.first_name - existing.last_name = subscriber.last_name - await existing.save() - async def create_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() @@ -275,26 +308,26 @@ class Postgres(DatabaseBase): async def update_subscription(self, subscription: domain.Subscription) -> None: await subscription.save() - async def get_active_subscriptions_by_subscriber_and_channel( - self, subscriber_id: uuid.UUID, channel_telegram_id: int + async def get_active_subscriptions_by_subscriber_and_project( + self, subscriber_id: uuid.UUID, project_id: uuid.UUID ) -> list[domain.Subscription]: return ( await domain.Subscription.filter( subscriber_id=subscriber_id, - target_channel__telegram_id=channel_telegram_id, + placement__project_id=project_id, status=domain.SubscriptionStatus.ACTIVE, ) .prefetch_related('placement', 'subscriber') .all() ) - async def get_active_subscription_by_subscriber_and_channel( - self, subscriber_id: uuid.UUID, channel_telegram_id: int + async def get_active_subscription_by_subscriber_and_project( + self, subscriber_id: uuid.UUID, project_id: uuid.UUID ) -> domain.Subscription | None: return ( await domain.Subscription.filter( subscriber_id=subscriber_id, - target_channel__telegram_id=channel_telegram_id, + placement__project_id=project_id, status=domain.SubscriptionStatus.ACTIVE, ) .prefetch_related('placement', 'subscriber') diff --git a/src/controller/http/__init__.py b/src/controller/http/__init__.py index 8f4a205..a8f0a10 100644 --- a/src/controller/http/__init__.py +++ b/src/controller/http/__init__.py @@ -2,10 +2,11 @@ from fastapi import APIRouter from src.controller.http.analytics import analytics_router from src.controller.http.auth import auth_router +from src.controller.http.channels import channels_router from src.controller.http.creatives import creatives_router -from src.controller.http.external_channels import external_channels_router from src.controller.http.placements import placements_router -from src.controller.http.target_channels import target_channels_router +from src.controller.http.projects import projects_router +from src.controller.http.purchase_plans import purchase_plan_channels_router from src.controller.http.views import views_router from src.controller.http.workspaces import workspaces_router @@ -14,8 +15,9 @@ api_router = APIRouter() # API v1 endpoints api_v1_router = APIRouter(prefix='/api/v1') api_v1_router.include_router(auth_router) -api_v1_router.include_router(target_channels_router) -api_v1_router.include_router(external_channels_router) +api_v1_router.include_router(channels_router) +api_v1_router.include_router(projects_router) +api_v1_router.include_router(purchase_plan_channels_router) api_v1_router.include_router(creatives_router) api_v1_router.include_router(placements_router) api_v1_router.include_router(views_router) diff --git a/src/controller/http/analytics.py b/src/controller/http/analytics.py index f462abc..8519039 100644 --- a/src/controller/http/analytics.py +++ b/src/controller/http/analytics.py @@ -15,12 +15,12 @@ analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags async def get_placements_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - target_channel_id: uuid.UUID | None = None, + project_id: uuid.UUID | None = None, ) -> dto.GetPlacementsAnalyticsOutput: input = dto.GetPlacementsAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, - target_channel_id=target_channel_id, + project_id=project_id, ) return await deps.get_usecase().get_placements_analytics(input) @@ -29,35 +29,35 @@ async def get_placements_analytics( async def get_creatives_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - target_channel_id: uuid.UUID | None = None, + project_id: uuid.UUID | None = None, ) -> dto.GetCreativesAnalyticsOutput: input = dto.GetCreativesAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, - target_channel_id=target_channel_id, + project_id=project_id, ) return await deps.get_usecase().get_creatives_analytics(input) -@analytics_router.get('/external-channels') -async def get_external_channels_analytics( +@analytics_router.get('/channels') +async def get_channel_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - target_channel_id: uuid.UUID | None = None, -) -> dto.GetExternalChannelsAnalyticsOutput: - input = dto.GetExternalChannelsAnalyticsInput( + project_id: uuid.UUID | None = None, +) -> dto.GetChannelAnalyticsOutput: + input = dto.GetChannelAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, - target_channel_id=target_channel_id, + project_id=project_id, ) - return await deps.get_usecase().get_external_channels_analytics(input) + return await deps.get_usecase().get_channel_analytics(input) @analytics_router.get('/spending') async def get_spending_analytics( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - target_channel_id: uuid.UUID | None = None, + project_id: uuid.UUID | None = None, date_from: datetime.datetime | None = None, date_to: datetime.datetime | None = None, grouping: dto.DateGrouping = dto.DateGrouping.DAY, @@ -65,7 +65,7 @@ async def get_spending_analytics( input = dto.GetSpendingAnalyticsInput( user_id=current_user.user_id, workspace_id=workspace_id, - target_channel_id=target_channel_id, + project_id=project_id, date_from=date_from, date_to=date_to, grouping=grouping, diff --git a/src/controller/http/channels.py b/src/controller/http/channels.py new file mode 100644 index 0000000..2d8d210 --- /dev/null +++ b/src/controller/http/channels.py @@ -0,0 +1,18 @@ +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +channels_router = APIRouter(prefix='/channels', tags=['channels']) + + +@channels_router.get('') +async def get_channels( + _: Annotated[JWTPayload, Depends(deps.get_current_user)], + username: str | None = None, +) -> dto.GetChannelsOutput: + input = dto.GetChannelsInput(username=username) + return await deps.get_usecase().get_channels(input=input) diff --git a/src/controller/http/creatives.py b/src/controller/http/creatives.py index 366de56..717040c 100644 --- a/src/controller/http/creatives.py +++ b/src/controller/http/creatives.py @@ -14,13 +14,13 @@ creatives_router = APIRouter(prefix='/workspaces/{workspace_id}/creatives', tags async def list_creatives( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - target_channel_id: uuid.UUID | None = None, + project_id: uuid.UUID | None = None, include_archived: bool = False, ) -> dto.GetCreativesOutput: input = dto.GetCreativesInput( user_id=current_user.user_id, workspace_id=workspace_id, - target_channel_id=target_channel_id, + project_id=project_id, include_archived=include_archived, ) diff --git a/src/controller/http/external_channels.py b/src/controller/http/external_channels.py deleted file mode 100644 index 1a2c9cd..0000000 --- a/src/controller/http/external_channels.py +++ /dev/null @@ -1,92 +0,0 @@ -import uuid -from typing import Annotated - -from fastapi import Depends -from fastapi.routing import APIRouter - -from src import deps, dto -from src.adapter.jwt import JWTPayload - -external_channels_router = APIRouter(prefix='/workspaces/{workspace_id}/external_channels', tags=['external_channels']) - - -@external_channels_router.get('/target/{target_channel_id}') -async def get_external_channels( - target_channel_id: uuid.UUID, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.GetExternalChannelsOutput: - """Get all external channels for a specific target channel.""" - input = dto.GetExternalChannelsInput( - target_channel_id=target_channel_id, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - return await deps.get_usecase().get_external_channels(input=input) - - -@external_channels_router.post('') -async def create_external_channel( - request: dto.CreateExternalChannelInput, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.ExternalChannelOutput: - """Create a new external channel and link it to target channels.""" - return await deps.get_usecase().create_external_channel( - input=request, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - -@external_channels_router.patch('/{channel_id}') -async def update_external_channel( - channel_id: uuid.UUID, - request: dto.UpdateExternalChannelInput, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.ExternalChannelOutput: - """Update external channel fields (title, username, description, subscribers_count).""" - return await deps.get_usecase().update_external_channel( - channel_id=channel_id, - input=request, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - -@external_channels_router.patch('/{channel_id}/links') -async def update_external_channel_links( - channel_id: uuid.UUID, - request: dto.UpdateExternalChannelLinksInput, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.ExternalChannelOutput: - """ - Update target channel links for an existing external channel. - - You can add new target channels, remove existing ones, or both in a single request. - """ - return await deps.get_usecase().update_external_channel_links( - channel_id=channel_id, - input=request, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - -@external_channels_router.delete('/{channel_id}') -async def delete_external_channel( - channel_id: uuid.UUID, - workspace_id: uuid.UUID, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> None: - """Delete an external channel.""" - input = dto.DeleteExternalChannelInput( - channel_id=channel_id, - user_id=current_user.user_id, - workspace_id=workspace_id, - ) - - await deps.get_usecase().delete_external_channel(input=input) diff --git a/src/controller/http/placements.py b/src/controller/http/placements.py index 926448a..a83872f 100644 --- a/src/controller/http/placements.py +++ b/src/controller/http/placements.py @@ -14,16 +14,16 @@ placements_router = APIRouter(prefix='/workspaces/{workspace_id}/placements', ta async def list_placements( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], - target_channel_id: uuid.UUID | None = None, - external_channel_id: uuid.UUID | None = None, + project_id: uuid.UUID | None = None, + placement_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, ) -> dto.GetPlacementsOutput: input = dto.GetPlacementsInput( user_id=current_user.user_id, workspace_id=workspace_id, - target_channel_id=target_channel_id, - external_channel_id=external_channel_id, + project_id=project_id, + placement_channel_id=placement_channel_id, creative_id=creative_id, include_archived=include_archived, ) diff --git a/src/controller/http/target_channels.py b/src/controller/http/projects.py similarity index 52% rename from src/controller/http/target_channels.py rename to src/controller/http/projects.py index 3c3f257..9015c40 100644 --- a/src/controller/http/target_channels.py +++ b/src/controller/http/projects.py @@ -7,17 +7,17 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -target_channels_router = APIRouter(prefix='/workspaces/{workspace_id}/target_channels', tags=['target_channels']) +projects_router = APIRouter(prefix='/workspaces/{workspace_id}/projects', tags=['projects']) -@target_channels_router.get('') -async def get_user_target_chans( +@projects_router.get('') +async def get_workspace_projects( workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.GetUserTargetChansOutput: - input = dto.GetUserTargetChansInput( +) -> dto.GetWorkspaceProjectsOutput: + input = dto.GetWorkspaceProjectsInput( user_id=current_user.user_id, workspace_id=workspace_id, ) - return await deps.get_usecase().get_user_target_chans(input=input) + return await deps.get_usecase().get_workspace_projects(input=input) diff --git a/src/controller/http/purchase_plans.py b/src/controller/http/purchase_plans.py new file mode 100644 index 0000000..f981281 --- /dev/null +++ b/src/controller/http/purchase_plans.py @@ -0,0 +1,74 @@ +import uuid +from typing import Annotated + +from fastapi import Depends +from fastapi.routing import APIRouter + +from src import deps, dto +from src.adapter.jwt import JWTPayload + +purchase_plan_channels_router = APIRouter( + prefix='/workspaces/{workspace_id}/projects/{project_id}/purchase-plan/channels', + tags=['purchase plan'], +) + + +@purchase_plan_channels_router.get('') +async def get_purchase_plan_channels( + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.GetPurchasePlanChannelsOutput: + input = dto.GetPurchasePlanChannelsInput( + user_id=current_user.user_id, + workspace_id=workspace_id, + project_id=project_id, + ) + return await deps.get_usecase().get_purchase_plan_channels(input=input) + + +@purchase_plan_channels_router.post('') +async def attach_channel_to_purchase_plan( + request: dto.AttachChannelToPurchasePlanInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PurchasePlanChannelOutput: + return await deps.get_usecase().attach_channel_to_purchase_plan( + project_id=project_id, + workspace_id=workspace_id, + user_id=current_user.user_id, + input=request, + ) + + +@purchase_plan_channels_router.patch('/{channel_id}') +async def update_purchase_plan_channel( + channel_id: uuid.UUID, + request: dto.UpdatePurchasePlanChannelInput, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.PurchasePlanChannelOutput: + return await deps.get_usecase().update_purchase_plan_channel( + channel_id=channel_id, + project_id=project_id, + workspace_id=workspace_id, + user_id=current_user.user_id, + input=request, + ) + + +@purchase_plan_channels_router.delete('/{channel_id}') +async def remove_purchase_plan_channel( + channel_id: uuid.UUID, + workspace_id: uuid.UUID, + project_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + await deps.get_usecase().remove_purchase_plan_channel( + channel_id=channel_id, + project_id=project_id, + workspace_id=workspace_id, + user_id=current_user.user_id, + ) diff --git a/src/controller/telegram_callback/__init__.py b/src/controller/telegram_callback/__init__.py index 2fe7ca8..8facd79 100644 --- a/src/controller/telegram_callback/__init__.py +++ b/src/controller/telegram_callback/__init__.py @@ -8,6 +8,6 @@ from . import ( # noqa: E402, F401 chat_member_updated, creative_commands, my_chat_member, + project_commands, start_with_login, - target_channel_commands, ) diff --git a/src/controller/telegram_callback/my_chat_member.py b/src/controller/telegram_callback/my_chat_member.py index a3189c6..7f4a13d 100644 --- a/src/controller/telegram_callback/my_chat_member.py +++ b/src/controller/telegram_callback/my_chat_member.py @@ -29,12 +29,12 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None: bot_removed = was_member and is_now_not_member if bot_removed: - disconnect_input = dto.DisconnectTargetChanByTgIdInput( + disconnect_input = dto.DisconnectProjectByTgIdInput( telegram_id=event.chat.id, user_telegram_id=event.from_user.id, ) try: - await usecase.disconnect_target_chan_by_tg_id(input=disconnect_input) + await usecase.disconnect_project_by_tg_id(input=disconnect_input) except HTTPException as e: log.error(e) @@ -53,14 +53,14 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None: ) if permissions_changed: - permissions_input = dto.UpdateTargetChanPermissionsInput( + permissions_input = dto.UpdateProjectPermissionsInput( telegram_id=event.chat.id, permissions=bot_permissions, chat_title=event.chat.title or f'Channel {event.chat.id}', user_telegram_id=event.from_user.id, ) try: - await usecase.update_target_chan_permissions(input=permissions_input) + await usecase.update_project_permissions(input=permissions_input) except HTTPException as e: log.error(e) return @@ -71,7 +71,7 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None: bot_added = was_not_member and is_now_member if bot_added: - connect_input = dto.ConnectTargetChanInput( + connect_input = dto.ConnectProjectInput( telegram_id=event.chat.id, title=event.chat.title or f'Channel {event.chat.id}', username=event.chat.username, @@ -79,6 +79,6 @@ async def on_bot_added_to_chat(event: ChatMemberUpdated) -> None: bot_permissions=bot_permissions, ) try: - await usecase.tg_add_target_chan_1(input=connect_input) + await usecase.tg_add_project_1(input=connect_input) except HTTPException as e: log.error(e) diff --git a/src/controller/telegram_callback/target_channel_commands.py b/src/controller/telegram_callback/project_commands.py similarity index 82% rename from src/controller/telegram_callback/target_channel_commands.py rename to src/controller/telegram_callback/project_commands.py index 11a9998..169cf4e 100644 --- a/src/controller/telegram_callback/target_channel_commands.py +++ b/src/controller/telegram_callback/project_commands.py @@ -9,14 +9,14 @@ from src.controller.telegram_callback import telegram_callback_router log = logging.getLogger(__name__) -@telegram_callback_router.callback_query(F.data.startswith('tg_add_target_chan_2:')) -async def callback_target_channel_workspace(callback: CallbackQuery) -> None: +@telegram_callback_router.callback_query(F.data.startswith('tg_add_project_2:')) +async def callback_project_workspace(callback: CallbackQuery) -> None: if not callback.from_user or not callback.message or not callback.data: log.error('Failed to get required data from callback query') return usecase = deps.get_usecase() - await usecase.tg_add_target_chan_2( + await usecase.tg_add_project_2( telegram_id=callback.from_user.id, chat_id=callback.message.chat.id, callback_data=callback.data, diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 039af07..bb96319 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -2,16 +2,29 @@ __all__ = ( 'User', 'Workspace', 'WorkspaceUser', - 'TargetChannel', - 'ExternalChannel', + 'WorkspaceInvite', + 'WorkspaceInviteStatus', + 'WorkspaceUserStatus', + 'WorkspaceUserPermission', + 'WorkspaceUserPermissionScope', + 'PermissionKey', + 'PermissionScopeType', + 'Channel', + 'ChannelVerificationStatus', + 'Project', + 'ProjectStatus', + 'PurchasePlan', + 'PurchasePlanChannel', + 'PurchasePlanStatus', + 'PurchasePlanChannelStatus', 'Creative', 'Placement', 'Subscription', - 'Subscriber', 'PlacementViewsHistory', 'TelegramState', 'TelegramStateEnum', - 'TargetChannelStatus', + 'ChannelNotFound', + 'ProjectNotFound', 'CreativeStatus', 'PlacementStatus', 'SubscriptionStatus', @@ -24,40 +37,57 @@ __all__ = ( 'LoginTokenNotFound', 'LoginTokenExpired', 'LoginTokenAlreadyUsed', - 'TargetChannelNotFound', + 'ProjectNotFound', + 'PurchasePlanNotFound', + 'PurchasePlanChannelNotFound', + 'ChannelNotFound', 'ChannelAlreadyExists', 'ChannelNoAdminRights', - 'ExternalChannelNotFound', - 'ExternalChannelAlreadyExists', 'CreativeNotFound', 'CreativeInUse', 'PlacementNotFound', ) +from .channel import Channel, ChannelVerificationStatus from .creative import Creative, CreativeStatus from .error import ( ChannelAlreadyExists, ChannelNoAdminRights, + ChannelNotFound, CreativeInUse, CreativeNotFound, - ExternalChannelAlreadyExists, - ExternalChannelNotFound, LoginTokenAlreadyUsed, LoginTokenExpired, LoginTokenNotFound, PlacementNotFound, - TargetChannelNotFound, + ProjectNotFound, + PurchasePlanChannelNotFound, + PurchasePlanNotFound, UserNotFound, WorkspaceAccessDenied, WorkspaceNotFound, ) -from .external_channel import ExternalChannel from .login_token import LoginToken from .placement import InviteLinkType, Placement, PlacementStatus, PostViewsAvailability from .placement_views_history import PlacementViewsHistory -from .subscriber import Subscriber +from .project import Project, ProjectStatus +from .purchase_plan import ( + PurchasePlan, + PurchasePlanChannel, + PurchasePlanChannelStatus, + PurchasePlanStatus, +) from .subscription import Subscription, SubscriptionStatus -from .target_channel import TargetChannel, TargetChannelStatus from .telegram_state import TelegramState, TelegramStateEnum from .user import User -from .workspace import Workspace, WorkspaceUser +from .workspace import ( + PermissionKey, + PermissionScopeType, + Workspace, + WorkspaceInvite, + WorkspaceInviteStatus, + WorkspaceUser, + WorkspaceUserPermission, + WorkspaceUserPermissionScope, + WorkspaceUserStatus, +) diff --git a/src/domain/channel.py b/src/domain/channel.py new file mode 100644 index 0000000..a4f4c38 --- /dev/null +++ b/src/domain/channel.py @@ -0,0 +1,34 @@ +import enum +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .workspace import Workspace + + +class ChannelVerificationStatus(str, enum.Enum): + UNVERIFIED = 'unverified' + VERIFIED = 'verified' + FAILED = 'failed' + + +class Channel(TimestampedModel): + id = fields.UUIDField(pk=True) + telegram_id = fields.BigIntField(null=True, unique=True, index=True) + username = fields.CharField(max_length=255, null=True, index=True) + title = fields.CharField(max_length=255, null=True) + verification_status = fields.CharEnumField(ChannelVerificationStatus, default=ChannelVerificationStatus.UNVERIFIED) + + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='channels', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + workspace_id: uuid.UUID + + class Meta: + table = 'channel' diff --git a/src/domain/creative.py b/src/domain/creative.py index 133780d..93eecbb 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -7,7 +7,7 @@ from tortoise import fields from .base import TimestampedModel if TYPE_CHECKING: - from .target_channel import TargetChannel + from .project import Project from .workspace import Workspace @@ -26,13 +26,13 @@ class Creative(TimestampedModel): workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( 'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True ) - target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField( - 'models.TargetChannel', 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 - target_channel_id: UUID + project_id: UUID class Meta: table = 'creative' diff --git a/src/domain/error.py b/src/domain/error.py index a6d059b..393ae60 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -21,10 +21,28 @@ def LoginTokenAlreadyUsed() -> HTTPException: return HTTPException(status.HTTP_400_BAD_REQUEST, 'Login token has already been used') -def TargetChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException: +def ChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException: if channel_id is None: - return HTTPException(status.HTTP_404_NOT_FOUND, 'Target channel not found') - return HTTPException(status.HTTP_404_NOT_FOUND, f'Target channel {channel_id} not found') + return HTTPException(status.HTTP_404_NOT_FOUND, 'Channel not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Channel {channel_id} not found') + + +def ProjectNotFound(project_id: uuid.UUID | None = None) -> HTTPException: + if project_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Project not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Project {project_id} not found') + + +def PurchasePlanNotFound(plan_id: uuid.UUID | None = None) -> HTTPException: + if plan_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan {plan_id} not found') + + +def PurchasePlanChannelNotFound(plan_channel_id: uuid.UUID | None = None) -> HTTPException: + if plan_channel_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Purchase plan channel not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Purchase plan channel {plan_channel_id} not found') def ChannelAlreadyExists(telegram_id: int) -> HTTPException: @@ -37,16 +55,6 @@ def ChannelNoAdminRights() -> HTTPException: ) -def ExternalChannelNotFound(channel_id: uuid.UUID | None = None) -> HTTPException: - if channel_id is None: - return HTTPException(status.HTTP_404_NOT_FOUND, 'External channel not found') - return HTTPException(status.HTTP_404_NOT_FOUND, f'External channel {channel_id} not found') - - -def ExternalChannelAlreadyExists(telegram_id: int) -> HTTPException: - return HTTPException(status.HTTP_409_CONFLICT, f'External channel {telegram_id} already exists') - - def CreativeNotFound(creative_id: uuid.UUID | None = None) -> HTTPException: if creative_id is None: return HTTPException(status.HTTP_404_NOT_FOUND, 'Creative not found') diff --git a/src/domain/external_channel.py b/src/domain/external_channel.py deleted file mode 100644 index eec94ea..0000000 --- a/src/domain/external_channel.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import TYPE_CHECKING -from uuid import UUID - -from tortoise import fields - -from .base import TimestampedModel - -if TYPE_CHECKING: - from .target_channel import TargetChannel - from .workspace import Workspace - - -class ExternalChannel(TimestampedModel): - id = fields.UUIDField(pk=True) - telegram_id = fields.BigIntField(index=True) - title = fields.CharField(max_length=255) - username = fields.CharField(max_length=255, null=True, index=True) - description = fields.TextField(null=True) - subscribers_count = fields.IntField(null=True) - - workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( - 'models.Workspace', related_name='external_channels', on_delete=fields.CASCADE, index=True - ) - - if TYPE_CHECKING: - target_channels: fields.ManyToManyRelation['TargetChannel'] - workspace_id: UUID - - class Meta: - table = 'external_channel' diff --git a/src/domain/placement.py b/src/domain/placement.py index 390cab3..d11b018 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -7,9 +7,9 @@ from tortoise import fields from .base import TimestampedModel if TYPE_CHECKING: + from .channel import Channel from .creative import Creative - from .external_channel import ExternalChannel - from .target_channel import TargetChannel + from .project import Project from .workspace import Workspace @@ -50,11 +50,11 @@ class Placement(TimestampedModel): views_availability = fields.CharEnumField(PostViewsAvailability, default=PostViewsAvailability.UNKNOWN) last_views_fetch_at = fields.DatetimeField(null=True) - target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField( - 'models.TargetChannel', 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 ) - external_channel: fields.ForeignKeyRelation['ExternalChannel'] = fields.ForeignKeyField( - 'models.ExternalChannel', 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 ) creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True @@ -64,8 +64,8 @@ class Placement(TimestampedModel): ) if TYPE_CHECKING: - target_channel_id: UUID - external_channel_id: UUID + project_id: UUID + placement_channel_id: UUID creative_id: UUID workspace_id: UUID diff --git a/src/domain/project.py b/src/domain/project.py new file mode 100644 index 0000000..cc0e420 --- /dev/null +++ b/src/domain/project.py @@ -0,0 +1,37 @@ +import enum +from typing import TYPE_CHECKING +from uuid import UUID + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .channel import Channel + from .workspace import Workspace + + +class ProjectStatus(str, enum.Enum): + ACTIVE = 'active' + INACTIVE = 'inactive' + ARCHIVED = 'archived' + + +class Project(TimestampedModel): + id = fields.UUIDField(pk=True) + status = fields.CharEnumField(ProjectStatus, default=ProjectStatus.ACTIVE) + + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='projects', on_delete=fields.CASCADE, index=True + ) + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='projects', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + workspace_id: UUID + channel_id: UUID + + class Meta: + table = 'project' + unique_together = (('workspace_id', 'channel_id'),) diff --git a/src/domain/purchase_plan.py b/src/domain/purchase_plan.py new file mode 100644 index 0000000..f2e7d5a --- /dev/null +++ b/src/domain/purchase_plan.py @@ -0,0 +1,63 @@ +import enum +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .channel import Channel + from .project import Project + from .workspace import Workspace + + +class PurchasePlanStatus(str, enum.Enum): + ACTIVE = 'active' + ARCHIVED = 'archived' + + +class PurchasePlanChannelStatus(str, enum.Enum): + PLANNED = 'planned' + APPROVED = 'approved' + REJECTED = 'rejected' + IN_PROGRESS = 'in_progress' + COMPLETED = 'completed' + + +class PurchasePlan(TimestampedModel): + id = fields.UUIDField(pk=True) + status = fields.CharEnumField(PurchasePlanStatus, default=PurchasePlanStatus.ACTIVE) + + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='purchase_plans', on_delete=fields.CASCADE, index=True + ) + project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( + 'models.Project', related_name='purchase_plans', on_delete=fields.CASCADE, index=True + ) + + class Meta: + table = 'purchase_plan' + indexes = (('project', 'status'),) + + +class PurchasePlanChannel(TimestampedModel): + id = fields.UUIDField(pk=True) + status = fields.CharEnumField(PurchasePlanChannelStatus, default=PurchasePlanChannelStatus.PLANNED) + planned_cost = fields.FloatField(null=True) + comment = fields.TextField(null=True) + + purchase_plan: fields.ForeignKeyRelation['PurchasePlan'] = fields.ForeignKeyField( + 'models.PurchasePlan', related_name='channels', on_delete=fields.CASCADE, index=True + ) + channel: fields.ForeignKeyRelation['Channel'] = fields.ForeignKeyField( + 'models.Channel', related_name='purchase_plan_channels', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + purchase_plan_id: uuid.UUID + channel_id: uuid.UUID + + class Meta: + table = 'purchase_plan_channel' + unique_together = (('purchase_plan_id', 'channel_id'),) diff --git a/src/domain/subscriber.py b/src/domain/subscriber.py deleted file mode 100644 index 960177f..0000000 --- a/src/domain/subscriber.py +++ /dev/null @@ -1,14 +0,0 @@ -from tortoise import fields - -from .base import TimestampedModel - - -class Subscriber(TimestampedModel): - id = fields.UUIDField(pk=True) - telegram_id = fields.BigIntField(unique=True, index=True) - username = fields.CharField(max_length=255, null=True) - first_name = fields.CharField(max_length=255, null=True) - last_name = fields.CharField(max_length=255, null=True) - - class Meta: - table = 'subscriber' diff --git a/src/domain/subscription.py b/src/domain/subscription.py index a612938..a536ec7 100644 --- a/src/domain/subscription.py +++ b/src/domain/subscription.py @@ -8,8 +8,7 @@ from .base import TimestampedModel if TYPE_CHECKING: from .placement import Placement - from .subscriber import Subscriber - from .target_channel import TargetChannel + from .user import User class SubscriptionStatus(str, enum.Enum): @@ -26,17 +25,13 @@ class Subscription(TimestampedModel): placement: fields.ForeignKeyRelation['Placement'] = fields.ForeignKeyField( 'models.Placement', related_name='subscriptions', on_delete=fields.CASCADE, index=True ) - subscriber: fields.ForeignKeyRelation['Subscriber'] = fields.ForeignKeyField( - 'models.Subscriber', related_name='subscriptions', on_delete=fields.CASCADE, index=True - ) - target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField( - 'models.TargetChannel', related_name='subscriptions', on_delete=fields.CASCADE, index=True + subscriber: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( + 'models.User', related_name='subscriptions', on_delete=fields.CASCADE, index=True ) if TYPE_CHECKING: placement_id: UUID subscriber_id: UUID - target_channel_id: UUID class Meta: table = 'subscription' diff --git a/src/domain/target_channel.py b/src/domain/target_channel.py deleted file mode 100644 index 79a5241..0000000 --- a/src/domain/target_channel.py +++ /dev/null @@ -1,43 +0,0 @@ -import enum -from typing import TYPE_CHECKING -from uuid import UUID - -from tortoise import fields - -from .base import TimestampedModel - -if TYPE_CHECKING: - from .external_channel import ExternalChannel - from .workspace import Workspace - - -class TargetChannelStatus(str, enum.Enum): - ACTIVE = 'active' - INACTIVE = 'inactive' - ARCHIVED = 'archived' - - -class TargetChannel(TimestampedModel): - id = fields.UUIDField(pk=True) - telegram_id = fields.BigIntField(unique=True, index=True) - title = fields.CharField(max_length=255) - username = fields.CharField(max_length=255, null=True, index=True) - status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE) - - workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( - 'models.Workspace', related_name='target_channels', on_delete=fields.CASCADE, index=True - ) - - external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField( - 'models.ExternalChannel', - related_name='target_channels', - through='target_external_channel', - forward_key='external_channel_id', - backward_key='target_channel_id', - ) - - if TYPE_CHECKING: - workspace_id: UUID - - class Meta: - table = 'target_channel' diff --git a/src/domain/telegram_state.py b/src/domain/telegram_state.py index 158bd92..87264ce 100644 --- a/src/domain/telegram_state.py +++ b/src/domain/telegram_state.py @@ -10,7 +10,7 @@ class TelegramStateEnum(str, enum.Enum): CREATIVE_WAITING_CHANNEL = 'creative_waiting_channel' CREATIVE_WAITING_NAME = 'creative_waiting_name' CREATIVE_WAITING_TEXT = 'creative_waiting_text' - TARGET_CHANNEL_WAITING_WORKSPACE = 'target_channel_waiting_workspace' + PROJECT_WAITING_WORKSPACE = 'project_waiting_workspace' class TelegramState(TimestampedModel): diff --git a/src/domain/user.py b/src/domain/user.py index dbf6d1b..10da897 100644 --- a/src/domain/user.py +++ b/src/domain/user.py @@ -7,6 +7,8 @@ class User(TimestampedModel): id = fields.UUIDField(pk=True) telegram_id = fields.BigIntField(unique=True, index=True) username = fields.CharField(max_length=255, null=True) + first_name = fields.CharField(max_length=255, null=True) + last_name = fields.CharField(max_length=255, null=True) class Meta: table = 'user' diff --git a/src/domain/workspace.py b/src/domain/workspace.py index c4a8e60..aee2034 100644 --- a/src/domain/workspace.py +++ b/src/domain/workspace.py @@ -1,5 +1,6 @@ from __future__ import annotations +import enum import uuid from typing import TYPE_CHECKING @@ -19,8 +20,15 @@ class Workspace(TimestampedModel): table = 'workspace' +class WorkspaceUserStatus(str, enum.Enum): + ACTIVE = 'active' + INVITED = 'invited' + REMOVED = 'removed' + + class WorkspaceUser(TimestampedModel): id = fields.UUIDField(pk=True) + status = fields.CharEnumField(WorkspaceUserStatus, default=WorkspaceUserStatus.ACTIVE) workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( 'models.Workspace', related_name='workspace_users', on_delete=fields.CASCADE, index=True @@ -36,3 +44,65 @@ class WorkspaceUser(TimestampedModel): class Meta: table = 'workspace_user' unique_together = (('workspace_id', 'user_id'),) + + +class WorkspaceInviteStatus(str, enum.Enum): + PENDING = 'pending' + ACCEPTED = 'accepted' + REVOKED = 'revoked' + + +class WorkspaceInvite(TimestampedModel): + id = fields.UUIDField(pk=True) + status = fields.CharEnumField(WorkspaceInviteStatus, default=WorkspaceInviteStatus.PENDING) + + workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( + 'models.Workspace', related_name='invites', on_delete=fields.CASCADE, index=True + ) + invited_by: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='sent_invites', on_delete=fields.CASCADE, index=True + ) + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True + ) + + class Meta: + table = 'workspace_invite' + unique_together = (('workspace_id', 'user_id'),) + + +class PermissionKey(str, enum.Enum): + MANAGE_PROJECTS = 'manage_projects' + MANAGE_PLACEMENTS = 'manage_placements' + VIEW_ANALYTICS = 'view_analytics' + + +class PermissionScopeType(str, enum.Enum): + WORKSPACE = 'workspace' + PROJECT = 'project' + + +class WorkspaceUserPermission(TimestampedModel): + workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField( + 'models.WorkspaceUser', related_name='permissions', on_delete=fields.CASCADE, index=True + ) + permission = fields.CharEnumField(PermissionKey) + + class Meta: + table = 'workspace_user_permission' + unique_together = (('workspace_user_id', 'permission'),) + + +class WorkspaceUserPermissionScope(TimestampedModel): + id = fields.UUIDField(pk=True) + permission = fields.CharEnumField(PermissionKey) + scope_type = fields.CharEnumField(PermissionScopeType) + scope_id = fields.UUIDField() + + workspace_user: fields.ForeignKeyRelation[WorkspaceUser] = fields.ForeignKeyField( + 'models.WorkspaceUser', related_name='permission_scopes', on_delete=fields.CASCADE, index=True + ) + + class Meta: + table = 'workspace_user_permission_scope' + unique_together = (('workspace_user_id', 'permission', 'scope_type', 'scope_id'),) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 6730847..0795691 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -1,21 +1,22 @@ __all__ = ( - 'UpdateTargetChanPermissionsInput', - 'GetUserTargetChansInput', - 'GetUserTargetChansOutput', - 'DisconnectTargetChanByTgIdInput', - 'ConnectTargetChanInput', - 'ConnectTargetChanOutput', + 'UpdateProjectPermissionsInput', + 'GetWorkspaceProjectsInput', + 'GetWorkspaceProjectsOutput', + 'DisconnectProjectByTgIdInput', + 'ConnectProjectInput', + 'ProjectOutput', 'ValidateLoginTokenInput', 'ValidateLoginTokenOutput', 'TelegramLoginInput', 'ChannelBotPermissions', - 'ExternalChannelOutput', - 'GetExternalChannelsInput', - 'GetExternalChannelsOutput', - 'CreateExternalChannelInput', - 'DeleteExternalChannelInput', - 'UpdateExternalChannelInput', - 'UpdateExternalChannelLinksInput', + 'ChannelOutput', + 'GetChannelsInput', + 'GetChannelsOutput', + 'PurchasePlanChannelOutput', + 'GetPurchasePlanChannelsInput', + 'GetPurchasePlanChannelsOutput', + 'AttachChannelToPurchasePlanInput', + 'UpdatePurchasePlanChannelInput', 'CreativeOutput', 'GetCreativesInput', 'GetCreativesOutput', @@ -37,14 +38,14 @@ __all__ = ( 'UserOutput', 'DateGrouping', 'PlacementAnalyticsOutput', + 'ChannelAnalyticsOutput', 'CreativeAnalyticsOutput', - 'ExternalChannelAnalyticsOutput', 'GetPlacementsAnalyticsInput', 'GetPlacementsAnalyticsOutput', 'GetCreativesAnalyticsInput', 'GetCreativesAnalyticsOutput', - 'GetExternalChannelsAnalyticsInput', - 'GetExternalChannelsAnalyticsOutput', + 'GetChannelAnalyticsInput', + 'GetChannelAnalyticsOutput', 'SpendingDataPoint', 'GetSpendingAnalyticsInput', 'GetSpendingAnalyticsOutput', @@ -56,13 +57,13 @@ __all__ = ( ) from .analytics import ( + ChannelAnalyticsOutput, CreativeAnalyticsOutput, DateGrouping, - ExternalChannelAnalyticsOutput, + GetChannelAnalyticsInput, + GetChannelAnalyticsOutput, GetCreativesAnalyticsInput, GetCreativesAnalyticsOutput, - GetExternalChannelsAnalyticsInput, - GetExternalChannelsAnalyticsOutput, GetPlacementsAnalyticsInput, GetPlacementsAnalyticsOutput, GetSpendingAnalyticsInput, @@ -70,6 +71,7 @@ from .analytics import ( PlacementAnalyticsOutput, SpendingDataPoint, ) +from .channel import ChannelOutput, GetChannelsInput, GetChannelsOutput from .creative import ( CreateCreativeInput, CreativeOutput, @@ -79,15 +81,6 @@ from .creative import ( GetCreativesOutput, UpdateCreativeInput, ) -from .external_channel import ( - CreateExternalChannelInput, - DeleteExternalChannelInput, - ExternalChannelOutput, - GetExternalChannelsInput, - GetExternalChannelsOutput, - UpdateExternalChannelInput, - UpdateExternalChannelLinksInput, -) from .placement import ( CreatePlacementInput, DeletePlacementInput, @@ -97,14 +90,21 @@ from .placement import ( PlacementOutput, UpdatePlacementInput, ) -from .target_channel import ( +from .project import ( ChannelBotPermissions, - ConnectTargetChanInput, - ConnectTargetChanOutput, - DisconnectTargetChanByTgIdInput, - GetUserTargetChansInput, - GetUserTargetChansOutput, - UpdateTargetChanPermissionsInput, + ConnectProjectInput, + DisconnectProjectByTgIdInput, + GetWorkspaceProjectsInput, + GetWorkspaceProjectsOutput, + ProjectOutput, + UpdateProjectPermissionsInput, +) +from .purchase_plan import ( + AttachChannelToPurchasePlanInput, + GetPurchasePlanChannelsInput, + GetPurchasePlanChannelsOutput, + PurchasePlanChannelOutput, + UpdatePurchasePlanChannelInput, ) from .telegram_login import TelegramLoginInput from .user import UserOutput diff --git a/src/dto/analytics.py b/src/dto/analytics.py index adf70e9..ed3b452 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -15,10 +15,10 @@ class DateGrouping(StrEnum): class PlacementAnalyticsOutput(pydantic.BaseModel): id: uuid.UUID - target_channel_id: uuid.UUID - target_channel_title: str - external_channel_id: uuid.UUID - external_channel_title: str + project_id: uuid.UUID + project_channel_title: str + placement_channel_id: uuid.UUID + placement_channel_title: str creative_id: uuid.UUID creative_name: str placement_date: datetime.datetime @@ -40,7 +40,7 @@ class CreativeAnalyticsOutput(pydantic.BaseModel): avg_cpm: float | None -class ExternalChannelAnalyticsOutput(pydantic.BaseModel): +class ChannelAnalyticsOutput(pydantic.BaseModel): id: uuid.UUID title: str username: str | None @@ -55,7 +55,7 @@ class ExternalChannelAnalyticsOutput(pydantic.BaseModel): class GetPlacementsAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID - target_channel_id: uuid.UUID | None = None + project_id: uuid.UUID | None = None class GetPlacementsAnalyticsOutput(pydantic.BaseModel): @@ -65,21 +65,21 @@ class GetPlacementsAnalyticsOutput(pydantic.BaseModel): class GetCreativesAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID - target_channel_id: uuid.UUID | None = None + project_id: uuid.UUID | None = None class GetCreativesAnalyticsOutput(pydantic.BaseModel): creatives: list[CreativeAnalyticsOutput] -class GetExternalChannelsAnalyticsInput(pydantic.BaseModel): +class GetChannelAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID - target_channel_id: uuid.UUID | None = None + project_id: uuid.UUID | None = None -class GetExternalChannelsAnalyticsOutput(pydantic.BaseModel): - external_channels: list[ExternalChannelAnalyticsOutput] +class GetChannelAnalyticsOutput(pydantic.BaseModel): + channels: list[ChannelAnalyticsOutput] class SpendingDataPoint(pydantic.BaseModel): @@ -94,7 +94,7 @@ class SpendingDataPoint(pydantic.BaseModel): class GetSpendingAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID - target_channel_id: uuid.UUID | None = None + project_id: uuid.UUID | None = None date_from: datetime.datetime | None = None date_to: datetime.datetime | None = None grouping: DateGrouping = DateGrouping.DAY diff --git a/src/dto/channel.py b/src/dto/channel.py new file mode 100644 index 0000000..bf1a866 --- /dev/null +++ b/src/dto/channel.py @@ -0,0 +1,21 @@ +import uuid + +import pydantic + +from src.domain.channel import ChannelVerificationStatus + + +class ChannelOutput(pydantic.BaseModel): + id: uuid.UUID + telegram_id: int | None + title: str | None + username: str | None + verification_status: ChannelVerificationStatus + + +class GetChannelsInput(pydantic.BaseModel): + username: str | None = None + + +class GetChannelsOutput(pydantic.BaseModel): + channels: list[ChannelOutput] diff --git a/src/dto/creative.py b/src/dto/creative.py index 71ea1b9..708a3df 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -10,8 +10,8 @@ class CreativeOutput(pydantic.BaseModel): id: uuid.UUID name: str text: str - target_channel_id: uuid.UUID - target_channel_title: str + project_id: uuid.UUID + project_channel_title: str created_at: datetime.datetime status: domain.CreativeStatus placements_count: int @@ -20,7 +20,7 @@ class CreativeOutput(pydantic.BaseModel): class GetCreativesInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID - target_channel_id: uuid.UUID | None = None + project_id: uuid.UUID | None = None include_archived: bool = False @@ -37,7 +37,7 @@ class GetCreativeInput(pydantic.BaseModel): class CreateCreativeInput(pydantic.BaseModel): name: str text: str - target_channel_id: uuid.UUID + project_id: uuid.UUID class UpdateCreativeInput(pydantic.BaseModel): diff --git a/src/dto/external_channel.py b/src/dto/external_channel.py deleted file mode 100644 index c2d0b8f..0000000 --- a/src/dto/external_channel.py +++ /dev/null @@ -1,49 +0,0 @@ -import uuid - -import pydantic - - -class ExternalChannelOutput(pydantic.BaseModel): - id: uuid.UUID - telegram_id: int - title: str - username: str | None - description: str | None - subscribers_count: int | None - - -class GetExternalChannelsInput(pydantic.BaseModel): - target_channel_id: uuid.UUID - user_id: uuid.UUID - workspace_id: uuid.UUID - - -class GetExternalChannelsOutput(pydantic.BaseModel): - external_channels: list[ExternalChannelOutput] - - -class CreateExternalChannelInput(pydantic.BaseModel): - telegram_id: int - title: str - username: str | None = None - description: str | None = None - subscribers_count: int | None = None - target_channel_ids: list[uuid.UUID] - - -class DeleteExternalChannelInput(pydantic.BaseModel): - channel_id: uuid.UUID - user_id: uuid.UUID - workspace_id: uuid.UUID - - -class UpdateExternalChannelInput(pydantic.BaseModel): - title: str | None = None - username: str | None = None - description: str | None = None - subscribers_count: int | None = None - - -class UpdateExternalChannelLinksInput(pydantic.BaseModel): - add_target_channel_ids: list[uuid.UUID] = [] - remove_target_channel_ids: list[uuid.UUID] = [] diff --git a/src/dto/placement.py b/src/dto/placement.py index abf0d22..4034c10 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -8,10 +8,10 @@ from src import domain class PlacementOutput(pydantic.BaseModel): id: uuid.UUID - target_channel_id: uuid.UUID - target_channel_title: str - external_channel_id: uuid.UUID - external_channel_title: str + project_id: uuid.UUID + project_channel_title: str + placement_channel_id: uuid.UUID + placement_channel_title: str creative_id: uuid.UUID creative_name: str placement_date: datetime.datetime @@ -31,8 +31,8 @@ class PlacementOutput(pydantic.BaseModel): class GetPlacementsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID - target_channel_id: uuid.UUID | None = None - external_channel_id: uuid.UUID | None = None + project_id: uuid.UUID | None = None + placement_channel_id: uuid.UUID | None = None creative_id: uuid.UUID | None = None include_archived: bool = False @@ -48,8 +48,8 @@ class GetPlacementInput(pydantic.BaseModel): class CreatePlacementInput(pydantic.BaseModel): - target_channel_id: uuid.UUID - external_channel_id: uuid.UUID + project_id: uuid.UUID + placement_channel_id: uuid.UUID creative_id: uuid.UUID placement_date: datetime.datetime cost: float | None = None diff --git a/src/dto/target_channel.py b/src/dto/project.py similarity index 57% rename from src/dto/target_channel.py rename to src/dto/project.py index 1574533..8aee31f 100644 --- a/src/dto/target_channel.py +++ b/src/dto/project.py @@ -2,7 +2,7 @@ import uuid import pydantic -from src.domain.target_channel import TargetChannelStatus +from src.domain.project import ProjectStatus class ChannelBotPermissions(pydantic.BaseModel): @@ -17,7 +17,7 @@ class ChannelBotPermissions(pydantic.BaseModel): can_pin_messages: bool | None = None -class ConnectTargetChanInput(pydantic.BaseModel): +class ConnectProjectInput(pydantic.BaseModel): telegram_id: int title: str username: str | None @@ -25,31 +25,29 @@ class ConnectTargetChanInput(pydantic.BaseModel): bot_permissions: ChannelBotPermissions -class ConnectTargetChanOutput(pydantic.BaseModel): +class ProjectOutput(pydantic.BaseModel): id: uuid.UUID telegram_id: int title: str username: str | None - status: TargetChannelStatus - # Deprecated: используйте status. Оставлено для обратной совместимости с фронтендом - is_active: bool + status: ProjectStatus -class GetUserTargetChansInput(pydantic.BaseModel): +class GetWorkspaceProjectsInput(pydantic.BaseModel): user_id: uuid.UUID workspace_id: uuid.UUID -class GetUserTargetChansOutput(pydantic.BaseModel): - target_channels: list[ConnectTargetChanOutput] +class GetWorkspaceProjectsOutput(pydantic.BaseModel): + projects: list[ProjectOutput] -class DisconnectTargetChanByTgIdInput(pydantic.BaseModel): +class DisconnectProjectByTgIdInput(pydantic.BaseModel): telegram_id: int user_telegram_id: int -class UpdateTargetChanPermissionsInput(pydantic.BaseModel): +class UpdateProjectPermissionsInput(pydantic.BaseModel): telegram_id: int permissions: ChannelBotPermissions chat_title: str diff --git a/src/dto/purchase_plan.py b/src/dto/purchase_plan.py new file mode 100644 index 0000000..42da616 --- /dev/null +++ b/src/dto/purchase_plan.py @@ -0,0 +1,38 @@ +import uuid + +import pydantic + +from src.domain.purchase_plan import PurchasePlanChannelStatus + +from .channel import ChannelOutput + + +class PurchasePlanChannelOutput(pydantic.BaseModel): + id: uuid.UUID + status: PurchasePlanChannelStatus + planned_cost: float | None = None + comment: str | None = None + channel: ChannelOutput + + +class GetPurchasePlanChannelsInput(pydantic.BaseModel): + user_id: uuid.UUID + workspace_id: uuid.UUID + project_id: uuid.UUID + + +class GetPurchasePlanChannelsOutput(pydantic.BaseModel): + channels: list[PurchasePlanChannelOutput] + + +class AttachChannelToPurchasePlanInput(pydantic.BaseModel): + username: str + status: PurchasePlanChannelStatus | None = None + planned_cost: float | None = None + comment: str | None = None + + +class UpdatePurchasePlanChannelInput(pydantic.BaseModel): + status: PurchasePlanChannelStatus | None = None + planned_cost: float | None = None + comment: str | None = None diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index a846acf..dc3e073 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -8,14 +8,15 @@ if typing.TYPE_CHECKING: from src import domain +from .analytics.get_channel_analytics import get_channel_analytics from .analytics.get_creatives_analytics import get_creatives_analytics -from .analytics.get_external_channels_analytics import get_external_channels_analytics from .analytics.get_placements_analytics import get_placements_analytics from .analytics.get_spending_analytics import get_spending_analytics from .auth.get_me import get_me from .auth.telegram_login import telegram_login from .auth.telegram_start import telegram_start from .auth.validate_login_token import validate_login_token +from .channel.get_channels import get_channels from .creative.create_creative import create_creative from .creative.delete_creative import delete_creative from .creative.get_creative import get_creative @@ -25,23 +26,22 @@ from .creative.tg_create_creative_2 import tg_create_creative_2 from .creative.tg_create_creative_3 import tg_create_creative_3 from .creative.tg_create_creative_4 import tg_create_creative_4 from .creative.update_creative import update_creative -from .external_channel.create_external_channel import create_external_channel -from .external_channel.delete_external_channel import delete_external_channel -from .external_channel.get_external_channels import get_external_channels -from .external_channel.update_external_channel import update_external_channel -from .external_channel.update_external_channel_links import update_external_channel_links from .placement.create_placement import create_placement from .placement.delete_placement import delete_placement from .placement.get_placement import get_placement from .placement.get_placements import get_placements from .placement.update_placement import update_placement +from .project.disconnect_project_by_tg_id import disconnect_project_by_tg_id +from .project.get_workspace_projects import get_workspace_projects +from .project.tg_add_project_1 import tg_add_project_1 +from .project.tg_add_project_2 import tg_add_project_2 +from .project.update_project_permissions import update_project_permissions +from .purchase_plan.attach_channel_to_purchase_plan import attach_channel_to_purchase_plan +from .purchase_plan.get_purchase_plan_channels import get_purchase_plan_channels +from .purchase_plan.remove_purchase_plan_channel import remove_purchase_plan_channel +from .purchase_plan.update_purchase_plan_channel import update_purchase_plan_channel from .subscription.handle_subscription import handle_subscription from .subscription.handle_unsubscription import handle_unsubscription -from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_chan_by_tg_id -from .target_channel.get_user_target_chans import get_user_target_chans -from .target_channel.tg_add_target_chan_1 import tg_add_target_chan_1 -from .target_channel.tg_add_target_chan_2 import tg_add_target_chan_2 -from .target_channel.update_target_chan_permissions import update_target_chan_permissions from .views.get_views_history import get_views_history from .workspace.create_workspace import create_workspace from .workspace.delete_workspace import delete_workspace @@ -82,46 +82,74 @@ class Database(typing.Protocol): async def mark_token_as_used(self, token: str) -> None: ... - async def get_target_channel( - self, workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None - ) -> domain.TargetChannel | None: ... + async def update_user(self, user: domain.User) -> None: ... - async def get_target_channel_for_user_by_telegram( - self, user_id: UUID, telegram_id: int - ) -> domain.TargetChannel | None: ... + async def get_channel( + self, + workspace_id: UUID, + channel_id: UUID | None = None, + telegram_id: int | None = None, + username: str | None = None, + ) -> domain.Channel | None: ... - async def upsert_target_channel(self, channel: domain.TargetChannel) -> None: ... + async def get_workspace_channels(self, workspace_id: UUID) -> list[domain.Channel]: ... - async def get_workspace_target_channels(self, workspace_id: UUID) -> list[domain.TargetChannel]: ... + async def create_channel(self, channel: domain.Channel) -> None: ... - async def update_target_channel(self, channel: domain.TargetChannel) -> None: ... + async def update_channel(self, channel: domain.Channel) -> None: ... - async def get_external_channel( - self, workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None - ) -> domain.ExternalChannel | None: ... + async def delete_channel(self, channel_id: UUID) -> None: ... - async def create_external_channel(self, channel: domain.ExternalChannel) -> None: ... + async def search_channels(self, username_query: str | None = None) -> list[domain.Channel]: ... - async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ... + async def get_project( + self, workspace_id: UUID, project_id: UUID | None = None, channel_id: UUID | None = None + ) -> domain.Project | None: ... - async def get_workspace_external_channels(self, workspace_id: UUID) -> list[domain.ExternalChannel]: ... + async def get_project_for_user_by_telegram( + self, user_id: UUID, channel_telegram_id: int + ) -> domain.Project | None: ... - async def add_external_channel_to_targets( - self, external_channel: domain.ExternalChannel, target_channel_ids: list[UUID] - ) -> None: ... + async def get_project_by_channel_telegram(self, channel_telegram_id: int) -> domain.Project | None: ... - async def remove_external_channel_from_target(self, external_channel_id: UUID, target_channel_id: UUID) -> None: ... + async def create_project(self, project: domain.Project) -> None: ... - async def delete_external_channel(self, channel_id: UUID) -> None: ... + async def update_project(self, project: domain.Project) -> None: ... - async def update_external_channel(self, channel: domain.ExternalChannel) -> None: ... + async def get_workspace_projects(self, workspace_id: UUID) -> list[domain.Project]: ... + + async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ... + + async def get_purchase_plan(self, workspace_id: UUID, plan_id: UUID) -> domain.PurchasePlan | None: ... + + async def create_purchase_plan(self, plan: domain.PurchasePlan) -> None: ... + + async def get_purchase_plan_channels(self, plan_id: UUID) -> list[domain.PurchasePlanChannel]: ... + + async def get_purchase_plan_channel( + self, plan_channel_id: UUID, plan_id: UUID + ) -> domain.PurchasePlanChannel | None: ... + + async def add_channel_to_purchase_plan( + self, + plan_id: UUID, + channel_id: UUID, + *, + status: domain.PurchasePlanChannelStatus | None = None, + planned_cost: float | None = None, + comment: str | None = None, + ) -> domain.PurchasePlanChannel: ... + + async def update_purchase_plan_channel(self, plan_channel: domain.PurchasePlanChannel) -> None: ... + + async def remove_channel_from_purchase_plan(self, plan_id: UUID, channel_id: UUID) -> None: ... async def get_creative(self, workspace_id: UUID, creative_id: UUID) -> domain.Creative | None: ... async def update_creative(self, creative: domain.Creative) -> None: ... async def get_workspace_creatives( - self, workspace_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False + self, workspace_id: UUID, project_id: UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: ... async def delete_creative(self, creative_id: UUID) -> None: ... @@ -135,8 +163,8 @@ class Database(typing.Protocol): async def get_workspace_placements( self, workspace_id: UUID, - target_channel_id: UUID | None = None, - external_channel_id: UUID | None = None, + project_id: UUID | None = None, + placement_channel_id: UUID | None = None, creative_id: UUID | None = None, include_archived: bool = False, ) -> list[domain.Placement]: ... @@ -145,10 +173,6 @@ class Database(typing.Protocol): async def get_placement_by_invite_link(self, invite_link: str) -> domain.Placement | None: ... - async def get_subscriber(self, telegram_id: int) -> domain.Subscriber | None: ... - - async def upsert_subscriber(self, subscriber: domain.Subscriber) -> None: ... - async def create_subscription(self, subscription: domain.Subscription) -> None: ... async def get_subscription_by_subscriber_and_placement( @@ -157,12 +181,12 @@ class Database(typing.Protocol): async def update_subscription(self, subscription: domain.Subscription) -> None: ... - async def get_active_subscriptions_by_subscriber_and_channel( - self, subscriber_id: UUID, channel_telegram_id: int + async def get_active_subscriptions_by_subscriber_and_project( + self, subscriber_id: UUID, project_id: UUID ) -> list[domain.Subscription]: ... - async def get_active_subscription_by_subscriber_and_channel( - self, subscriber_id: UUID, channel_telegram_id: int + async def get_active_subscription_by_subscriber_and_project( + self, subscriber_id: UUID, project_id: UUID ) -> domain.Subscription | None: ... async def create_placement_views_history(self, history: domain.PlacementViewsHistory) -> None: ... @@ -229,20 +253,29 @@ class Usecase: return workspace + async def ensure_active_purchase_plan(self, project: domain.Project) -> domain.PurchasePlan: + plan = await self.database.get_active_purchase_plan(project.id) + if plan: + return plan + + plan = domain.PurchasePlan(workspace_id=project.workspace_id, project_id=project.id) + await self.database.create_purchase_plan(plan) + return plan + validate_login_token = validate_login_token telegram_login = telegram_login telegram_start = telegram_start get_me = get_me - tg_add_target_chan_1 = tg_add_target_chan_1 - tg_add_target_chan_2 = tg_add_target_chan_2 - get_user_target_chans = get_user_target_chans - disconnect_target_chan_by_tg_id = disconnect_target_chan_by_tg_id - update_target_chan_permissions = update_target_chan_permissions - get_external_channels = get_external_channels - create_external_channel = create_external_channel - delete_external_channel = delete_external_channel - update_external_channel = update_external_channel - update_external_channel_links = update_external_channel_links + tg_add_project_1 = tg_add_project_1 + tg_add_project_2 = tg_add_project_2 + get_workspace_projects = get_workspace_projects + disconnect_project_by_tg_id = disconnect_project_by_tg_id + update_project_permissions = update_project_permissions + get_channels = get_channels + attach_channel_to_purchase_plan = attach_channel_to_purchase_plan + get_purchase_plan_channels = get_purchase_plan_channels + update_purchase_plan_channel = update_purchase_plan_channel + remove_purchase_plan_channel = remove_purchase_plan_channel get_creatives = get_creatives get_creative = get_creative create_creative = create_creative @@ -262,7 +295,7 @@ class Usecase: get_views_history = get_views_history get_placements_analytics = get_placements_analytics get_creatives_analytics = get_creatives_analytics - get_external_channels_analytics = get_external_channels_analytics + get_channel_analytics = get_channel_analytics get_spending_analytics = get_spending_analytics get_workspaces = get_workspaces create_workspace = create_workspace diff --git a/src/usecase/analytics/__init__.py b/src/usecase/analytics/__init__.py deleted file mode 100644 index d795c26..0000000 --- a/src/usecase/analytics/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .get_creatives_analytics import get_creatives_analytics -from .get_external_channels_analytics import get_external_channels_analytics -from .get_placements_analytics import get_placements_analytics -from .get_spending_analytics import get_spending_analytics - -__all__ = ( - 'get_placements_analytics', - 'get_creatives_analytics', - 'get_external_channels_analytics', - 'get_spending_analytics', -) diff --git a/src/usecase/analytics/get_external_channels_analytics.py b/src/usecase/analytics/get_channel_analytics.py similarity index 65% rename from src/usecase/analytics/get_external_channels_analytics.py rename to src/usecase/analytics/get_channel_analytics.py index 29ae1c0..81f2407 100644 --- a/src/usecase/analytics/get_external_channels_analytics.py +++ b/src/usecase/analytics/get_channel_analytics.py @@ -11,21 +11,21 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def get_external_channels_analytics( - self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput -) -> dto.GetExternalChannelsAnalyticsOutput: +async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput: await self.ensure_workspace_access(input.workspace_id, input.user_id) - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) - external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + plan = await self.ensure_active_purchase_plan(project) + plan_channels = await self.database.get_purchase_plan_channels(plan.id) + channels = [pc.channel for pc in plan_channels] else: - external_channels = await self.database.get_workspace_external_channels(input.workspace_id) + channels = await self.database.get_workspace_channels(input.workspace_id) placements = await self.database.get_workspace_placements( - input.workspace_id, input.target_channel_id, include_archived=False + input.workspace_id, input.project_id, include_archived=False ) @dataclass @@ -35,10 +35,10 @@ async def get_external_channels_analytics( total_views: int = 0 placements_count: int = 0 - channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in external_channels} + channel_stats: dict[UUID, ChannelStats] = {ch.id: ChannelStats() for ch in channels} for p in placements: - stats = channel_stats.get(p.external_channel_id) + stats = channel_stats.get(p.placement_channel_id) if stats is None: continue @@ -48,7 +48,7 @@ async def get_external_channels_analytics( stats.placements_count += 1 result = [] - for ch in external_channels: + for ch in channels: stats = channel_stats[ch.id] avg_cpf = ( @@ -61,7 +61,7 @@ async def get_external_channels_analytics( ) result.append( - dto.ExternalChannelAnalyticsOutput( + dto.ChannelAnalyticsOutput( id=ch.id, title=ch.title, username=ch.username, @@ -74,4 +74,4 @@ async def get_external_channels_analytics( ) ) - return dto.GetExternalChannelsAnalyticsOutput(external_channels=result) + return dto.GetChannelAnalyticsOutput(channels=result) diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 99cb5cf..50942bb 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -16,16 +16,16 @@ async def get_creatives_analytics( ) -> dto.GetCreativesAnalyticsOutput: await self.ensure_workspace_access(input.workspace_id, input.user_id) - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) creatives = await self.database.get_workspace_creatives( - input.workspace_id, input.target_channel_id, include_archived=False + input.workspace_id, input.project_id, include_archived=False ) placements = await self.database.get_workspace_placements( - input.workspace_id, input.target_channel_id, include_archived=False + input.workspace_id, input.project_id, include_archived=False ) @dataclass diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 10c3460..0343d38 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -26,22 +26,22 @@ async def get_placements_analytics( ) -> dto.GetPlacementsAnalyticsOutput: await self.ensure_workspace_access(input.workspace_id, input.user_id) - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) placements = await self.database.get_workspace_placements( - input.workspace_id, input.target_channel_id, include_archived=False + input.workspace_id, input.project_id, include_archived=False ) result = [ dto.PlacementAnalyticsOutput( id=p.id, - target_channel_id=p.target_channel_id, - target_channel_title=p.target_channel.title, - external_channel_id=p.external_channel_id, - external_channel_title=p.external_channel.title, + 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, creative_id=p.creative_id, creative_name=p.creative.name, placement_date=p.placement_date, diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index dd14315..a2c421b 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -35,13 +35,13 @@ async def get_spending_analytics( ) -> dto.GetSpendingAnalyticsOutput: await self.ensure_workspace_access(input.workspace_id, input.user_id) - if input.target_channel_id: - target_channel = await self.database.get_target_channel(input.workspace_id, input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + if input.project_id: + project = await self.database.get_project(input.workspace_id, input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) placements = await self.database.get_workspace_placements( - input.workspace_id, input.target_channel_id, include_archived=False + input.workspace_id, input.project_id, include_archived=False ) filtered = [ diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py new file mode 100644 index 0000000..5696c48 --- /dev/null +++ b/src/usecase/channel/get_channels.py @@ -0,0 +1,28 @@ +import logging +from typing import TYPE_CHECKING + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> dto.GetChannelsOutput: + channels = await self.database.search_channels(username_query=input.username) + + log.debug('Found %s channels for username query: %s', len(channels), input.username) + + return dto.GetChannelsOutput( + channels=[ + dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + verification_status=channel.verification_status, + ) + for channel in channels + ] + ) diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 5d2494b..c39cf9c 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -15,16 +15,16 @@ async def create_creative( ) -> dto.CreativeOutput: await self.ensure_workspace_access(workspace_id, user_id) - target_channel = await self.database.get_target_channel(workspace_id, channel_id=input.target_channel_id) - if not target_channel: - log.warning('User %s attempted to create creative for unavailable target %s', user_id, input.target_channel_id) - raise domain.TargetChannelNotFound(input.target_channel_id) + project = await self.database.get_project(workspace_id, project_id=input.project_id) + if not project: + log.warning('User %s attempted to create creative for unavailable project %s', user_id, input.project_id) + raise domain.ProjectNotFound(input.project_id) creative = domain.Creative( name=input.name, text=input.text, status=domain.CreativeStatus.ACTIVE, - target_channel_id=target_channel.id, + project_id=project.id, workspace_id=workspace_id, ) await self.database.create_creative(creative) @@ -33,8 +33,8 @@ async def create_creative( id=creative.id, name=creative.name, text=creative.text, - target_channel_id=target_channel.id, - target_channel_title=target_channel.title, + project_id=project.id, + project_channel_title=project.channel.title, created_at=creative.created_at, status=creative.status, placements_count=creative.placements_count, diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index 597761e..282428a 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -20,8 +20,8 @@ async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.Crea id=creative.id, name=creative.name, text=creative.text, - target_channel_id=creative.target_channel_id, - target_channel_title=creative.target_channel.title, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, created_at=creative.created_at, status=creative.status, placements_count=creative.placements_count, diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index 9649eab..afcc161 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -10,7 +10,7 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.Ge await self.ensure_workspace_access(input.workspace_id, input.user_id) creatives = await self.database.get_workspace_creatives( - input.workspace_id, input.target_channel_id, input.include_archived + input.workspace_id, input.project_id, input.include_archived ) return dto.GetCreativesOutput( @@ -19,8 +19,8 @@ async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.Ge id=creative.id, name=creative.name, text=creative.text, - target_channel_id=creative.target_channel_id, - target_channel_title=creative.target_channel.title, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, created_at=creative.created_at, status=creative.status, placements_count=creative.placements_count, diff --git a/src/usecase/creative/tg_create_creative_1.py b/src/usecase/creative/tg_create_creative_1.py index 89de253..7cb1a19 100644 --- a/src/usecase/creative/tg_create_creative_1.py +++ b/src/usecase/creative/tg_create_creative_1.py @@ -36,18 +36,23 @@ async def tg_create_creative_1(self: 'Usecase', telegram_id: int, chat_id: int) await self.database.clear_telegram_state(telegram_id) return - channels = await self.database.get_workspace_target_channels(workspace.id) - if not channels: + projects = await self.database.get_workspace_projects(workspace.id) + if not projects: await self.telegram_bot.send_message( - '❌ В выбранном рабочем пространстве нет подключенных каналов. Добавьте канал и попробуйте снова.', + '❌ В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.', chat_id, ) await self.database.clear_telegram_state(telegram_id) return buttons = [ - [InlineKeyboardButton(text=channel.title, callback_data=f'tg_create_creative_3:{channel.id}')] - for channel in channels + [ + InlineKeyboardButton( + text=project.channel.title or project.channel.username or 'Unnamed Channel', + callback_data=f'tg_create_creative_3:{project.id}', + ) + ] + for project in projects ] await self.telegram_bot.send_message_with_inline_keyboard( diff --git a/src/usecase/creative/tg_create_creative_2.py b/src/usecase/creative/tg_create_creative_2.py index ae83b23..1e16ab7 100644 --- a/src/usecase/creative/tg_create_creative_2.py +++ b/src/usecase/creative/tg_create_creative_2.py @@ -47,18 +47,23 @@ async def tg_create_creative_2( await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) - channels = await self.database.get_workspace_target_channels(workspace.id) - if not channels: + projects = await self.database.get_workspace_projects(workspace.id) + if not projects: await self.telegram_bot.send_message( - '❌ В выбранном рабочем пространстве нет подключенных каналов. Добавьте канал и попробуйте снова.', + '❌ В выбранном рабочем пространстве нет подключенных проектов. Добавьте канал и попробуйте снова.', chat_id, ) await self.database.clear_telegram_state(telegram_id) return buttons = [ - [InlineKeyboardButton(text=channel.title, callback_data=f'tg_create_creative_3:{channel.id}')] - for channel in channels + [ + InlineKeyboardButton( + text=project.channel.title or project.channel.username or 'Unnamed Channel', + callback_data=f'tg_create_creative_3:{project.id}', + ) + ] + for project in projects ] await self.telegram_bot.send_message_with_inline_keyboard( diff --git a/src/usecase/creative/tg_create_creative_3.py b/src/usecase/creative/tg_create_creative_3.py index 65e6f1c..2b8ce5d 100644 --- a/src/usecase/creative/tg_create_creative_3.py +++ b/src/usecase/creative/tg_create_creative_3.py @@ -22,11 +22,11 @@ async def tg_create_creative_3( log.warning('Invalid callback data format: %s', callback_data) return - channel_id_str = callback_data.split(':', 1)[1] + project_id_str = callback_data.split(':', 1)[1] try: - channel_id = uuid.UUID(channel_id_str) + project_id = uuid.UUID(project_id_str) except ValueError: - log.error('Invalid channel UUID in callback data: %s', channel_id_str) + log.error('Invalid project UUID in callback data: %s', project_id_str) return user = await self.database.get_user(telegram_id=telegram_id) @@ -53,13 +53,13 @@ async def tg_create_creative_3( workspace_id = workspace.id if not workspace_id: - log.error('Workspace ID missing for user %s when selecting channel %s', telegram_id, channel_id) + log.error('Workspace ID missing for user %s when selecting project %s', telegram_id, project_id) await self.telegram_bot.send_message('❌ Ошибка: рабочее пространство не найдено. Начните заново.', chat_id) await self.database.clear_telegram_state(telegram_id) return - channel = await self.database.get_target_channel(workspace_id, channel_id=channel_id) - if not channel: + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: await self.telegram_bot.send_message('❌ Канал не найден или не принадлежит вам.', chat_id) await self.database.clear_telegram_state(telegram_id) return @@ -68,12 +68,14 @@ async def tg_create_creative_3( telegram_id, domain.TelegramStateEnum.CREATIVE_WAITING_NAME, { - 'target_channel_id': str(channel_id), - 'channel_title': channel.title, + 'project_id': str(project_id), + 'channel_title': project.channel.title, 'workspace_id': str(workspace_id), }, ) await self.telegram_bot.edit_message_reply_markup(chat_id, message_id) - await self.telegram_bot.send_message(f'✅ Канал выбран: {channel.title}\n\n📝 Введите название креатива:', chat_id) + await self.telegram_bot.send_message( + f'✅ Канал выбран: {project.channel.title}\n\n📝 Введите название креатива:', chat_id + ) diff --git a/src/usecase/creative/tg_create_creative_4.py b/src/usecase/creative/tg_create_creative_4.py index 7b0cb85..00da8fa 100644 --- a/src/usecase/creative/tg_create_creative_4.py +++ b/src/usecase/creative/tg_create_creative_4.py @@ -38,20 +38,20 @@ async def tg_create_creative_4( ) elif state.state == domain.TelegramStateEnum.CREATIVE_WAITING_TEXT: - target_channel_id_str = state.context.get('target_channel_id') + project_id_str = state.context.get('project_id') creative_name = state.context.get('creative_name') workspace_id_str = state.context.get('workspace_id') - if not target_channel_id_str or not creative_name: + if not project_id_str or not creative_name: log.error('Missing context data for creative creation: %s', state.context) await self.telegram_bot.send_message('❌ Ошибка: данные не найдены. Начните заново.', chat_id) await self.database.clear_telegram_state(telegram_id) return try: - target_channel_id = uuid.UUID(target_channel_id_str) + project_id = uuid.UUID(project_id_str) except ValueError: - log.error('Invalid target_channel_id in context: %s', target_channel_id_str) + log.error('Invalid project_id in context: %s', project_id_str) await self.telegram_bot.send_message('❌ Ошибка: неверный формат данных.', chat_id) await self.database.clear_telegram_state(telegram_id) return @@ -78,8 +78,8 @@ async def tg_create_creative_4( await self.database.clear_telegram_state(telegram_id) return - channel = await self.database.get_target_channel(workspace_id, channel_id=target_channel_id) - if not channel: + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: await self.telegram_bot.send_message('❌ Канал не найден.', chat_id) await self.database.clear_telegram_state(telegram_id) return @@ -92,7 +92,7 @@ async def tg_create_creative_4( text=creative_text, status=domain.CreativeStatus.ACTIVE, workspace_id=workspace_id, - target_channel_id=target_channel_id, + project_id=project_id, ) async with self.database.transaction(): @@ -104,7 +104,7 @@ async def tg_create_creative_4( await self.telegram_bot.send_message( f'✅ Креатив успешно создан!\n\n' f'📝 Название: {creative.name}\n' - f'📢 Канал: {channel.title}\n\n' + f'📢 Канал: {project.channel.title}\n\n' f'💬 Текст:\n{text_preview}', chat_id, ) diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index c1eff7a..19c809d 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -37,8 +37,8 @@ async def update_creative( id=creative.id, name=creative.name, text=creative.text, - target_channel_id=creative.target_channel_id, - target_channel_title=creative.target_channel.title, + project_id=creative.project_id, + project_channel_title=creative.project.channel.title, created_at=creative.created_at, status=creative.status, placements_count=creative.placements_count, diff --git a/src/usecase/external_channel/create_external_channel.py b/src/usecase/external_channel/create_external_channel.py deleted file mode 100644 index 3d3be74..0000000 --- a/src/usecase/external_channel/create_external_channel.py +++ /dev/null @@ -1,53 +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 create_external_channel( - self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID, workspace_id: uuid.UUID -) -> dto.ExternalChannelOutput: - await self.ensure_workspace_access(workspace_id, user_id) - - existing_channel = await self.database.get_external_channel(workspace_id, telegram_id=input.telegram_id) - if existing_channel: - raise domain.ExternalChannelAlreadyExists(input.telegram_id) - - for target_id in input.target_channel_ids: - target_channel = await self.database.get_target_channel(workspace_id, channel_id=target_id) - - if not target_channel: - raise domain.TargetChannelNotFound(target_id) - - async with self.database.transaction(): - channel = domain.ExternalChannel( - telegram_id=input.telegram_id, - title=input.title, - username=input.username, - description=input.description, - subscribers_count=input.subscribers_count, - workspace_id=workspace_id, - ) - await self.database.create_external_channel(channel) - - if input.target_channel_ids: - await self.database.add_external_channel_to_targets(channel, input.target_channel_ids) - - log.info( - 'External channel %s created and linked to %s target channels', input.telegram_id, len(input.target_channel_ids) - ) - - return dto.ExternalChannelOutput( - id=channel.id, - telegram_id=channel.telegram_id, - title=channel.title, - username=channel.username, - description=channel.description, - subscribers_count=channel.subscribers_count, - ) diff --git a/src/usecase/external_channel/delete_external_channel.py b/src/usecase/external_channel/delete_external_channel.py deleted file mode 100644 index 34d9b98..0000000 --- a/src/usecase/external_channel/delete_external_channel.py +++ /dev/null @@ -1,20 +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_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None: - await self.ensure_workspace_access(input.workspace_id, input.user_id) - - channel = await self.database.get_external_channel(input.workspace_id, channel_id=input.channel_id) - if not channel: - raise domain.ExternalChannelNotFound(input.channel_id) - - await self.database.delete_external_channel(input.channel_id) - log.info('User %s deleted external channel %s', input.user_id, input.channel_id) diff --git a/src/usecase/external_channel/get_external_channels.py b/src/usecase/external_channel/get_external_channels.py deleted file mode 100644 index bef908b..0000000 --- a/src/usecase/external_channel/get_external_channels.py +++ /dev/null @@ -1,34 +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_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) - - target_channel = await self.database.get_target_channel(input.workspace_id, channel_id=input.target_channel_id) - if not target_channel: - log.warning('Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id) - raise domain.TargetChannelNotFound(input.target_channel_id) - - channels = await self.database.get_external_channels_for_target(input.target_channel_id) - - return dto.GetExternalChannelsOutput( - external_channels=[ - dto.ExternalChannelOutput( - id=channel.id, - telegram_id=channel.telegram_id, - title=channel.title, - username=channel.username, - description=channel.description, - subscribers_count=channel.subscribers_count, - ) - for channel in channels - ] - ) diff --git a/src/usecase/external_channel/update_external_channel.py b/src/usecase/external_channel/update_external_channel.py deleted file mode 100644 index 464573c..0000000 --- a/src/usecase/external_channel/update_external_channel.py +++ /dev/null @@ -1,47 +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_external_channel( - self: 'Usecase', - channel_id: uuid.UUID, - input: dto.UpdateExternalChannelInput, - user_id: uuid.UUID, - workspace_id: uuid.UUID, -) -> dto.ExternalChannelOutput: - await self.ensure_workspace_access(workspace_id, user_id) - - external_channel = await self.database.get_external_channel(workspace_id, channel_id=channel_id) - if not external_channel: - log.warning('External channel %s not found', channel_id) - raise domain.ExternalChannelNotFound(channel_id) - - if input.title is not None: - external_channel.title = input.title - if input.username is not None: - external_channel.username = input.username - if input.description is not None: - external_channel.description = input.description - if input.subscribers_count is not None: - external_channel.subscribers_count = input.subscribers_count - - await self.database.update_external_channel(external_channel) - - log.info('External channel %s updated', channel_id) - - return dto.ExternalChannelOutput( - id=external_channel.id, - telegram_id=external_channel.telegram_id, - title=external_channel.title, - username=external_channel.username, - description=external_channel.description, - subscribers_count=external_channel.subscribers_count, - ) diff --git a/src/usecase/external_channel/update_external_channel_links.py b/src/usecase/external_channel/update_external_channel_links.py deleted file mode 100644 index 35ab9d4..0000000 --- a/src/usecase/external_channel/update_external_channel_links.py +++ /dev/null @@ -1,54 +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_external_channel_links( - self: 'Usecase', - channel_id: uuid.UUID, - input: dto.UpdateExternalChannelLinksInput, - user_id: uuid.UUID, - workspace_id: uuid.UUID, -) -> dto.ExternalChannelOutput: - await self.ensure_workspace_access(workspace_id, user_id) - - external_channel = await self.database.get_external_channel(workspace_id, channel_id=channel_id) - if not external_channel: - log.warning('External channel %s not found', channel_id) - raise domain.ExternalChannelNotFound(channel_id) - - for target_id in input.add_target_channel_ids: - target_channel = await self.database.get_target_channel(workspace_id, channel_id=target_id) - if not target_channel: - log.warning('User %s attempted to link external channel to target %s they do not own', user_id, target_id) - raise domain.TargetChannelNotFound(target_id) - - async with self.database.transaction(): - if input.add_target_channel_ids: - await self.database.add_external_channel_to_targets(external_channel, input.add_target_channel_ids) - - for target_id in input.remove_target_channel_ids: - await self.database.remove_external_channel_from_target(channel_id, target_id) - - log.info( - 'External channel %s links updated: %s added, %s removed', - channel_id, - len(input.add_target_channel_ids), - len(input.remove_target_channel_ids), - ) - - return dto.ExternalChannelOutput( - id=external_channel.id, - telegram_id=external_channel.telegram_id, - title=external_channel.title, - username=external_channel.username, - description=external_channel.description, - subscribers_count=external_channel.subscribers_count, - ) diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index 1911781..491a71a 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -15,24 +15,30 @@ async def create_placement( ) -> dto.PlacementOutput: await self.ensure_workspace_access(workspace_id, user_id) - target_channel = await self.database.get_target_channel(workspace_id, channel_id=input.target_channel_id) - if not target_channel: - raise domain.TargetChannelNotFound(input.target_channel_id) + project = await self.database.get_project(workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) - external_channel = await self.database.get_external_channel(workspace_id, channel_id=input.external_channel_id) - if not external_channel: - raise domain.ExternalChannelNotFound(input.external_channel_id) + placement_channel = await self.database.get_channel(workspace_id, 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 = input.invite_link_type == domain.InviteLinkType.APPROVAL - invite_link = await self.telegram_bot.create_chat_invite_link(target_channel.telegram_id, requires_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) placement = domain.Placement( - target_channel_id=input.target_channel_id, - external_channel_id=input.external_channel_id, + 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, @@ -51,10 +57,10 @@ async def create_placement( return dto.PlacementOutput( id=placement.id, - target_channel_id=placement.target_channel_id, - target_channel_title=target_channel.title, - external_channel_id=placement.external_channel_id, - external_channel_title=external_channel.title, + 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.placement_date, diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py index 006094b..24db7fc 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -19,10 +19,10 @@ async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.Pl return dto.PlacementOutput( id=placement.id, - target_channel_id=placement.target_channel_id, - target_channel_title=placement.target_channel.title, - external_channel_id=placement.external_channel_id, - external_channel_title=placement.external_channel.title, + 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.placement_date, diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py index 795ffbd..48b38bb 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -11,8 +11,8 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. placements = await self.database.get_workspace_placements( input.workspace_id, - target_channel_id=input.target_channel_id, - external_channel_id=input.external_channel_id, + project_id=input.project_id, + placement_channel_id=input.placement_channel_id, creative_id=input.creative_id, include_archived=input.include_archived, ) @@ -21,10 +21,10 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. placements=[ dto.PlacementOutput( id=placement.id, - target_channel_id=placement.target_channel_id, - target_channel_title=placement.target_channel.title, - external_channel_id=placement.external_channel_id, - external_channel_title=placement.external_channel.title, + 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.placement_date, diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index 3cb00e1..0a8b41e 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -37,10 +37,10 @@ async def update_placement( return dto.PlacementOutput( id=placement.id, - target_channel_id=placement.target_channel_id, - target_channel_title=placement.target_channel.title, - external_channel_id=placement.external_channel_id, - external_channel_title=placement.external_channel.title, + 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.placement_date, diff --git a/src/usecase/project/disconnect_project_by_tg_id.py b/src/usecase/project/disconnect_project_by_tg_id.py new file mode 100644 index 0000000..16dbc1d --- /dev/null +++ b/src/usecase/project/disconnect_project_by_tg_id.py @@ -0,0 +1,26 @@ +import logging +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def disconnect_project_by_tg_id(self: 'Usecase', input: dto.DisconnectProjectByTgIdInput) -> None: + user = await self.database.get_user(telegram_id=input.user_telegram_id) + if not user: + log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') + return + + project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) + if not project: + log.warning(f'Project channel {input.telegram_id} not found') + raise domain.ProjectNotFound() + + project.status = domain.ProjectStatus.ARCHIVED + await self.database.update_project(project) + + log.info('Project %s archived for channel %s', project.id, input.telegram_id) diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py new file mode 100644 index 0000000..2d0abc8 --- /dev/null +++ b/src/usecase/project/get_workspace_projects.py @@ -0,0 +1,27 @@ +from typing import TYPE_CHECKING + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspace_projects( + self: 'Usecase', input: dto.GetWorkspaceProjectsInput +) -> dto.GetWorkspaceProjectsOutput: + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + projects = await self.database.get_workspace_projects(input.workspace_id) + + return dto.GetWorkspaceProjectsOutput( + projects=[ + dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + ) + for project in projects + ] + ) diff --git a/src/usecase/target_channel/tg_add_target_chan_1.py b/src/usecase/project/tg_add_project_1.py similarity index 73% rename from src/usecase/target_channel/tg_add_target_chan_1.py rename to src/usecase/project/tg_add_project_1.py index 6d7ad95..8daf424 100644 --- a/src/usecase/target_channel/tg_add_target_chan_1.py +++ b/src/usecase/project/tg_add_project_1.py @@ -11,9 +11,7 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def tg_add_target_chan_1( - self: 'Usecase', input: dto.ConnectTargetChanInput -) -> dto.ConnectTargetChanOutput | None: +async def tg_add_project_1(self: 'Usecase', input: dto.ConnectProjectInput) -> dto.ProjectOutput | None: permissions = input.bot_permissions if not permissions.is_admin: @@ -71,17 +69,37 @@ async def tg_add_target_chan_1( if len(workspaces) == 1: workspace = workspaces[0] - channel = domain.TargetChannel( - telegram_id=input.telegram_id, - title=input.title, - username=input.username, - workspace_id=workspace.id, - status=domain.TargetChannelStatus.ACTIVE, - ) - await self.database.upsert_target_channel(channel) + channel = await self.database.get_channel(workspace.id, telegram_id=input.telegram_id) + if channel: + channel.title = input.title + channel.username = input.username + await self.database.update_channel(channel) + else: + channel = domain.Channel( + telegram_id=input.telegram_id, + title=input.title, + username=input.username, + workspace_id=workspace.id, + ) + await self.database.create_channel(channel) + + project = await self.database.get_project(workspace.id, channel_id=channel.id) + if project: + project.status = domain.ProjectStatus.ACTIVE + await self.database.update_project(project) + else: + project = domain.Project( + workspace_id=workspace.id, + channel_id=channel.id, + status=domain.ProjectStatus.ACTIVE, + ) + await self.database.create_project(project) + + await self.ensure_active_purchase_plan(project) + log.info( - 'Channel %s connected/updated successfully in workspace %s by user %s', + 'Project for channel %s connected/updated successfully in workspace %s by user %s', input.telegram_id, workspace.id, input.user_telegram_id, @@ -91,17 +109,16 @@ async def tg_add_target_chan_1( f'✅ Канал "{input.title}" добавлен в рабочее пространство "{workspace.name}".', input.user_telegram_id ) - return dto.ConnectTargetChanOutput( - id=channel.id, + return dto.ProjectOutput( + id=project.id, telegram_id=channel.telegram_id, title=channel.title, username=channel.username, - status=channel.status, - is_active=channel.status == domain.TargetChannelStatus.ACTIVE, + status=project.status, ) current_state = await self.database.get_telegram_state(user.telegram_id) - if current_state and current_state.state == domain.TelegramStateEnum.TARGET_CHANNEL_WAITING_WORKSPACE: + if current_state and current_state.state == domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE: await self.telegram_bot.send_message( '⚠️ Сначала завершите выбор рабочего пространства для предыдущего канала.', user.telegram_id ) @@ -122,20 +139,17 @@ async def tg_add_target_chan_1( await self.database.set_telegram_state( user.telegram_id, - domain.TelegramStateEnum.TARGET_CHANNEL_WAITING_WORKSPACE, + domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE, context, ) buttons = [ - [InlineKeyboardButton(text=workspace.name, callback_data=f'tg_add_target_chan_2:{workspace.id}')] + [InlineKeyboardButton(text=workspace.name, callback_data=f'tg_add_project_2:{workspace.id}')] for workspace in workspaces ] await self.telegram_bot.send_message_with_inline_keyboard( - ( - '✨ Подключение канала\n\n' - f'Выберите рабочее пространство, в которое добавить канал "{input.title}":' - ), + (f'✨ Подключение канала\n\nВыберите рабочее пространство, в которое добавить канал "{input.title}":'), user.telegram_id, buttons, ) diff --git a/src/usecase/target_channel/tg_add_target_chan_2.py b/src/usecase/project/tg_add_project_2.py similarity index 65% rename from src/usecase/target_channel/tg_add_target_chan_2.py rename to src/usecase/project/tg_add_project_2.py index 4d8d2c4..24cc2a4 100644 --- a/src/usecase/target_channel/tg_add_target_chan_2.py +++ b/src/usecase/project/tg_add_project_2.py @@ -10,12 +10,12 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def tg_add_target_chan_2( +async def tg_add_project_2( self: 'Usecase', telegram_id: int, chat_id: int, callback_data: str, message_id: int ) -> None: state = await self.database.get_telegram_state(telegram_id) - if not state or state.state != domain.TelegramStateEnum.TARGET_CHANNEL_WAITING_WORKSPACE: - log.debug('User %s has no active target channel workspace selection flow', telegram_id) + if not state or state.state != domain.TelegramStateEnum.PROJECT_WAITING_WORKSPACE: + log.debug('User %s has no active project workspace selection flow', telegram_id) return context = state.context or {} @@ -28,8 +28,8 @@ async def tg_add_target_chan_2( except ValueError: previous_state = None - if not callback_data.startswith('tg_add_target_chan_2:'): - log.warning('Invalid target channel workspace callback data: %s', callback_data) + if not callback_data.startswith('tg_add_project_2:'): + log.warning('Invalid project workspace callback data: %s', callback_data) return workspace_id_str = callback_data.split(':', 1)[1] @@ -44,9 +44,9 @@ async def tg_add_target_chan_2( await self.database.clear_telegram_state(telegram_id) return - channel_data = state.context.get('channel_data') if state.context else None + channel_data = context.get('channel_data') if not channel_data: - log.error('Missing channel data for user %s in target channel flow', telegram_id) + log.error('Missing channel data for user %s in project flow', telegram_id) await self.telegram_bot.send_message('❌ Не удалось подключить канал. Попробуйте снова.', chat_id) if previous_state: await self.database.set_telegram_state(telegram_id, previous_state, previous_context) @@ -65,15 +65,31 @@ async def tg_add_target_chan_2( await self.telegram_bot.send_message('❌ У вас нет доступа к выбранному рабочему пространству.', chat_id) return - channel = domain.TargetChannel( - telegram_id=int(channel_data['telegram_id']), - title=str(channel_data['title']), - username=channel_data.get('username'), - workspace_id=workspace.id, - status=domain.TargetChannelStatus.ACTIVE, - ) + telegram_channel_id = int(channel_data['telegram_id']) + channel = await self.database.get_channel(workspace.id, telegram_id=telegram_channel_id) + if channel: + channel.title = str(channel_data['title']) + channel.username = channel_data.get('username') + await self.database.update_channel(channel) + else: + channel = domain.Channel( + telegram_id=telegram_channel_id, + title=str(channel_data['title']), + username=channel_data.get('username'), + workspace_id=workspace.id, + ) + await self.database.create_channel(channel) + + project = await self.database.get_project(workspace.id, channel_id=channel.id) + if project: + project.status = domain.ProjectStatus.ACTIVE + await self.database.update_project(project) + else: + project = domain.Project(workspace_id=workspace.id, channel_id=channel.id, status=domain.ProjectStatus.ACTIVE) + await self.database.create_project(project) + + await self.ensure_active_purchase_plan(project) - await self.database.upsert_target_channel(channel) log.info( 'Channel %s connected/updated successfully in workspace %s by user %s', channel.telegram_id, diff --git a/src/usecase/target_channel/update_target_chan_permissions.py b/src/usecase/project/update_project_permissions.py similarity index 50% rename from src/usecase/target_channel/update_target_chan_permissions.py rename to src/usecase/project/update_project_permissions.py index dc19472..7ae4028 100644 --- a/src/usecase/target_channel/update_target_chan_permissions.py +++ b/src/usecase/project/update_project_permissions.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTargetChanPermissionsInput) -> None: +async def update_project_permissions(self: 'Usecase', input: dto.UpdateProjectPermissionsInput) -> None: missing_permissions = [] if not input.permissions.can_invite_users: missing_permissions.append('Создание инвайт-ссылок') @@ -22,31 +22,37 @@ async def update_target_chan_permissions(self: 'Usecase', input: dto.UpdateTarge log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions') return - channel = await self.database.get_target_channel_for_user_by_telegram(user.id, input.telegram_id) - if not channel: - log.warning(f'Target channel {input.telegram_id} not found when permissions changed') - raise domain.TargetChannelNotFound() + project = await self.database.get_project_for_user_by_telegram(user.id, input.telegram_id) + if not project: + log.warning(f'Project channel {input.telegram_id} not found when permissions changed') + raise domain.ProjectNotFound() if not missing_permissions: - if channel.status != domain.TargetChannelStatus.ACTIVE: - channel.status = domain.TargetChannelStatus.ACTIVE - await self.database.update_target_channel(channel) + if project.status != domain.ProjectStatus.ACTIVE: + project.status = domain.ProjectStatus.ACTIVE + await self.database.update_project(project) await self.telegram_bot.send_message( f'✅ Канал "{input.chat_title}" был активирован.\n\nВсе необходимые права боту предоставлены!', input.user_telegram_id, ) - log.info(f'Target channel {input.telegram_id} reactivated - all permissions granted') + log.info(f'Project channel {input.telegram_id} reactivated - all permissions granted') return - if channel.status == domain.TargetChannelStatus.ACTIVE: - channel.status = domain.TargetChannelStatus.INACTIVE - await self.database.update_target_channel(channel) + if project.status == domain.ProjectStatus.ACTIVE: + project.status = domain.ProjectStatus.INACTIVE + await self.database.update_project(project) missed_permissions = '\n'.join(f'• {p}' for p in missing_permissions) await self.telegram_bot.send_message( - f'⚠️ Канал "{input.chat_title}" был деактивирован.\ - \n\nБоту убрали необходимые права:\n{missed_permissions}', + ( + f'⚠️ Канал "{input.chat_title}" был деактивирован.\n\n' + f'Боту убрали необходимые права:\n{missed_permissions}' + ), input.user_telegram_id, ) - log.warning(f'Target channel {input.telegram_id} deactivated due to missing permissions: {missing_permissions}') + log.warning( + 'Project channel %s deactivated due to missing permissions: %s', + input.telegram_id, + missing_permissions, + ) diff --git a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py new file mode 100644 index 0000000..bee4707 --- /dev/null +++ b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py @@ -0,0 +1,67 @@ +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 attach_channel_to_purchase_plan( + self: 'Usecase', + project_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.AttachChannelToPurchasePlanInput, +) -> dto.PurchasePlanChannelOutput: + await self.ensure_workspace_access(workspace_id, user_id) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + plan = await self.ensure_active_purchase_plan(project) + + # Поиск канала по username + channel = await self.database.get_channel(workspace_id, username=input.username) + + if not channel: + # Создать неподтвержденный канал + channel = domain.Channel( + workspace_id=workspace_id, + username=input.username, + telegram_id=None, + title=None, + verification_status=domain.ChannelVerificationStatus.UNVERIFIED, + ) + await self.database.create_channel(channel) + log.info('Created unverified channel with username %s', input.username) + else: + log.info('Found existing channel %s for username %s', channel.id, input.username) + + plan_channel = await self.database.add_channel_to_purchase_plan( + plan.id, + channel.id, + status=input.status, + planned_cost=input.planned_cost, + comment=input.comment, + ) + + log.info('Channel %s attached to purchase plan %s (project %s)', channel.id, plan.id, project.id) + + return dto.PurchasePlanChannelOutput( + id=plan_channel.id, + status=plan_channel.status, + planned_cost=plan_channel.planned_cost, + comment=plan_channel.comment, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + verification_status=channel.verification_status, + ), + ) diff --git a/src/usecase/purchase_plan/get_purchase_plan_channels.py b/src/usecase/purchase_plan/get_purchase_plan_channels.py new file mode 100644 index 0000000..7ad758b --- /dev/null +++ b/src/usecase/purchase_plan/get_purchase_plan_channels.py @@ -0,0 +1,43 @@ +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_purchase_plan_channels( + self: 'Usecase', input: dto.GetPurchasePlanChannelsInput +) -> dto.GetPurchasePlanChannelsOutput: + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + project = await self.database.get_project(input.workspace_id, project_id=input.project_id) + if not project: + raise domain.ProjectNotFound(input.project_id) + + plan = await self.ensure_active_purchase_plan(project) + + plan_channels = await self.database.get_purchase_plan_channels(plan.id) + log.debug('Fetched %s channels for purchase plan %s (project %s)', len(plan_channels), plan.id, project.id) + + return dto.GetPurchasePlanChannelsOutput( + channels=[ + dto.PurchasePlanChannelOutput( + id=plan_channel.id, + status=plan_channel.status, + planned_cost=plan_channel.planned_cost, + comment=plan_channel.comment, + channel=dto.ChannelOutput( + id=plan_channel.channel.id, + telegram_id=plan_channel.channel.telegram_id, + title=plan_channel.channel.title, + username=plan_channel.channel.username, + verification_status=plan_channel.channel.verification_status, + ), + ) + for plan_channel in plan_channels + ] + ) diff --git a/src/usecase/purchase_plan/remove_purchase_plan_channel.py b/src/usecase/purchase_plan/remove_purchase_plan_channel.py new file mode 100644 index 0000000..746f97f --- /dev/null +++ b/src/usecase/purchase_plan/remove_purchase_plan_channel.py @@ -0,0 +1,33 @@ +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def remove_purchase_plan_channel( + self: 'Usecase', + channel_id: uuid.UUID, + project_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, +) -> None: + await self.ensure_workspace_access(workspace_id, user_id) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + plan = await self.ensure_active_purchase_plan(project) + + plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) + if not plan_channel: + raise domain.PurchasePlanChannelNotFound(channel_id) + + await self.database.remove_channel_from_purchase_plan(plan.id, plan_channel.channel_id) + log.info('Removed channel %s from purchase plan %s (project %s)', plan_channel.channel_id, plan.id, project.id) diff --git a/src/usecase/purchase_plan/update_purchase_plan_channel.py b/src/usecase/purchase_plan/update_purchase_plan_channel.py new file mode 100644 index 0000000..e766d12 --- /dev/null +++ b/src/usecase/purchase_plan/update_purchase_plan_channel.py @@ -0,0 +1,71 @@ +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_purchase_plan_channel( + self: 'Usecase', + channel_id: uuid.UUID, + project_id: uuid.UUID, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.UpdatePurchasePlanChannelInput, +) -> dto.PurchasePlanChannelOutput: + await self.ensure_workspace_access(workspace_id, user_id) + + project = await self.database.get_project(workspace_id, project_id=project_id) + if not project: + raise domain.ProjectNotFound(project_id) + + plan = await self.ensure_active_purchase_plan(project) + + plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) + if not plan_channel: + raise domain.PurchasePlanChannelNotFound(channel_id) + + updated_fields: dict[str, object | None] = {} + if input.status is not None: + plan_channel.status = input.status + updated_fields['status'] = input.status + if input.planned_cost is not None: + plan_channel.planned_cost = input.planned_cost + updated_fields['planned_cost'] = input.planned_cost + if input.comment is not None: + plan_channel.comment = input.comment + updated_fields['comment'] = input.comment + + if updated_fields: + await self.database.update_purchase_plan_channel(plan_channel) + log.info( + 'Updated purchase plan channel %s for project %s fields %s', + channel_id, + project.id, + ', '.join(updated_fields.keys()), + ) + + channel: domain.Channel | None = plan_channel.channel + if channel is None: + channel = await self.database.get_channel(workspace_id, channel_id=plan_channel.channel_id) + if channel is None: + raise domain.ChannelNotFound(plan_channel.channel_id) + + return dto.PurchasePlanChannelOutput( + id=plan_channel.id, + status=plan_channel.status, + planned_cost=plan_channel.planned_cost, + comment=plan_channel.comment, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + verification_status=channel.verification_status, + ), + ) diff --git a/src/usecase/subscription/handle_subscription.py b/src/usecase/subscription/handle_subscription.py index 1208854..a8df615 100644 --- a/src/usecase/subscription/handle_subscription.py +++ b/src/usecase/subscription/handle_subscription.py @@ -22,16 +22,23 @@ async def handle_subscription( log.warning('Placement not found for invite_link: %s', invite_link) return - subscriber = domain.Subscriber( - telegram_id=user_telegram_id, - username=username, - first_name=first_name, - last_name=last_name, - ) - await self.database.upsert_subscriber(subscriber) + subscriber = await self.database.get_user(telegram_id=user_telegram_id) + if not subscriber: + subscriber = domain.User( + telegram_id=user_telegram_id, + username=username, + first_name=first_name, + last_name=last_name, + ) + await self.database.create_user(subscriber) + else: + subscriber.username = username or subscriber.username + subscriber.first_name = first_name or subscriber.first_name + subscriber.last_name = last_name or subscriber.last_name + await self.database.update_user(subscriber) - active_subscription = await self.database.get_active_subscription_by_subscriber_and_channel( - subscriber.id, placement.target_channel.telegram_id + active_subscription = await self.database.get_active_subscription_by_subscriber_and_project( + subscriber.id, placement.project_id ) if active_subscription: @@ -43,7 +50,7 @@ async def handle_subscription( 'ignoring new subscription attempt via placement %s', subscriber.id, user_telegram_id, - placement.target_channel_id, + placement.project_id, active_subscription.placement_id, placement.id, ) @@ -72,7 +79,6 @@ async def handle_subscription( subscription = domain.Subscription( placement_id=placement.id, subscriber_id=subscriber.id, - target_channel_id=placement.target_channel_id, invite_link=invite_link, ) async with self.database.transaction(): diff --git a/src/usecase/subscription/handle_unsubscription.py b/src/usecase/subscription/handle_unsubscription.py index a08d709..83575d0 100644 --- a/src/usecase/subscription/handle_unsubscription.py +++ b/src/usecase/subscription/handle_unsubscription.py @@ -12,14 +12,17 @@ log = logging.getLogger(__name__) async def handle_unsubscription(self: 'Usecase', user_telegram_id: int, channel_telegram_id: int) -> None: - subscriber = await self.database.get_subscriber(user_telegram_id) + subscriber = await self.database.get_user(telegram_id=user_telegram_id) if not subscriber: log.warning('Subscriber not found for telegram_id: %s', user_telegram_id) return - subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_channel( - subscriber.id, channel_telegram_id - ) + project = await self.database.get_project_by_channel_telegram(channel_telegram_id) + if not project: + log.warning('Project not found for channel telegram_id: %s', channel_telegram_id) + return + + subscriptions = await self.database.get_active_subscriptions_by_subscriber_and_project(subscriber.id, project.id) if not subscriptions: log.info( diff --git a/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py b/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py deleted file mode 100644 index 342f92a..0000000 --- a/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py +++ /dev/null @@ -1,26 +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 disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.DisconnectTargetChanByTgIdInput) -> None: - user = await self.database.get_user(telegram_id=input.user_telegram_id) - if not user: - log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') - return - - channel = await self.database.get_target_channel_for_user_by_telegram(user.id, input.telegram_id) - if not channel: - log.warning(f'Target channel {input.telegram_id} not found') - raise domain.TargetChannelNotFound() - - channel.status = domain.TargetChannelStatus.INACTIVE - await self.database.update_target_channel(channel) - - log.info(f'Target channel {input.telegram_id} deactivated') diff --git a/src/usecase/target_channel/get_user_target_chans.py b/src/usecase/target_channel/get_user_target_chans.py deleted file mode 100644 index 04b4b5e..0000000 --- a/src/usecase/target_channel/get_user_target_chans.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import TYPE_CHECKING - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - - -async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) - - channels = await self.database.get_workspace_target_channels(input.workspace_id) - - return dto.GetUserTargetChansOutput( - target_channels=[ - dto.ConnectTargetChanOutput( - id=channel.id, - telegram_id=channel.telegram_id, - title=channel.title, - username=channel.username, - status=channel.status, - is_active=channel.status == domain.TargetChannelStatus.ACTIVE, - ) - for channel in channels - ] - )