diff --git a/migrations/models/2_20251213220918_update.py b/migrations/models/2_20251213220918_update.py new file mode 100644 index 0000000..5813882 --- /dev/null +++ b/migrations/models/2_20251213220918_update.py @@ -0,0 +1,124 @@ +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 ade9059..ef77626 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -19,6 +19,55 @@ class Postgres(DatabaseBase): async def create_user(self, user: domain.User) -> None: await user.save() + async def create_workspace(self, workspace: domain.Workspace) -> None: + await workspace.save() + + async def update_workspace(self, workspace: domain.Workspace) -> None: + await workspace.save() + + 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 get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]: + return ( + await domain.WorkspaceUser.filter(user_id=user_id) + .prefetch_related('workspace') + .order_by('created_at') + .all() + ) + + async def get_workspace(self, workspace_id: uuid.UUID) -> domain.Workspace | None: + return await domain.Workspace.get_or_none(id=workspace_id) + + async def get_workspace_for_user(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.Workspace | None: + membership = ( + await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) + .prefetch_related('workspace') + .first() + ) + return membership.workspace if membership else None + + async def get_default_workspace_for_user(self, user_id: uuid.UUID) -> domain.Workspace | None: + membership = ( + await domain.WorkspaceUser.filter(user_id=user_id) + .prefetch_related('workspace') + .order_by('created_at') + .first() + ) + return membership.workspace if membership else None + + async def get_workspace_membership( + self, workspace_id: uuid.UUID, user_id: uuid.UUID + ) -> domain.WorkspaceUser | None: + return await domain.WorkspaceUser.get_or_none(workspace_id=workspace_id, user_id=user_id).prefetch_related( + 'workspace' + ) + async def create_login_token(self, login_token: domain.LoginToken) -> None: await login_token.save() @@ -46,12 +95,12 @@ class Postgres(DatabaseBase): raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован async def get_target_channel( - self, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None + 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(user_id=user_id) + q = domain.TargetChannel.filter(workspace_id=workspace_id) if channel_id: q = q.filter(id=channel_id) if telegram_id: @@ -59,8 +108,17 @@ class Postgres(DatabaseBase): 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(user_id=channel.user_id, telegram_id=channel.telegram_id) + existing = await self.get_target_channel(workspace_id=channel.workspace_id, telegram_id=channel.telegram_id) if not existing: await channel.save() @@ -68,22 +126,24 @@ class Postgres(DatabaseBase): existing.title = channel.title existing.username = channel.username - existing.user_id = channel.user_id + 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_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: - return await domain.TargetChannel.filter(user_id=user_id, status=domain.TargetChannelStatus.ACTIVE).all() + 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, user_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None + self, workspace_id: uuid.UUID, channel_id: uuid.UUID | None = None, telegram_id: int | None = None ) -> domain.ExternalChannel | None: - query = domain.ExternalChannel.filter(user_id=user_id) + query = domain.ExternalChannel.filter(workspace_id=workspace_id) if channel_id: query = query.filter(id=channel_id) if telegram_id: @@ -94,8 +154,8 @@ class Postgres(DatabaseBase): 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_user_external_channels(self, user_id: uuid.UUID) -> list[domain.ExternalChannel]: - return await domain.ExternalChannel.filter(user_id=user_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] @@ -121,16 +181,18 @@ class Postgres(DatabaseBase): async def update_external_channel(self, channel: domain.ExternalChannel) -> None: await channel.save() - async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: - return await domain.Creative.get_or_none(id=creative_id, user_id=user_id).prefetch_related('target_channel') + 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' + ) async def update_creative(self, creative: domain.Creative) -> None: await creative.save() - async def get_user_creatives( - self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False + async def get_workspace_creatives( + self, workspace_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: - query = domain.Creative.filter(user_id=user_id) + query = domain.Creative.filter(workspace_id=workspace_id) if target_channel_id: query = query.filter(target_channel_id=target_channel_id) @@ -143,8 +205,8 @@ class Postgres(DatabaseBase): async def delete_creative(self, creative_id: uuid.UUID) -> None: await domain.Creative.filter(id=creative_id).delete() - async def get_placement(self, user_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: - return await domain.Placement.get_or_none(id=placement_id, user_id=user_id).prefetch_related( + 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' ) @@ -154,15 +216,15 @@ class Postgres(DatabaseBase): async def update_placement(self, placement: domain.Placement) -> None: await placement.save() - async def get_user_placements( + async def get_workspace_placements( self, - user_id: uuid.UUID, + workspace_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, external_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, ) -> list[domain.Placement]: - query = domain.Placement.filter(user_id=user_id) + query = domain.Placement.filter(workspace_id=workspace_id) if target_channel_id: query = query.filter(target_channel_id=target_channel_id) diff --git a/src/controller/http/__init__.py b/src/controller/http/__init__.py index d611ab5..8f4a205 100644 --- a/src/controller/http/__init__.py +++ b/src/controller/http/__init__.py @@ -7,6 +7,7 @@ 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.views import views_router +from src.controller.http.workspaces import workspaces_router api_router = APIRouter() @@ -19,5 +20,6 @@ api_v1_router.include_router(creatives_router) api_v1_router.include_router(placements_router) api_v1_router.include_router(views_router) api_v1_router.include_router(analytics_router) +api_v1_router.include_router(workspaces_router) api_router.include_router(api_v1_router) diff --git a/src/controller/http/analytics.py b/src/controller/http/analytics.py index 39b471c..f462abc 100644 --- a/src/controller/http/analytics.py +++ b/src/controller/http/analytics.py @@ -8,16 +8,18 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -analytics_router = APIRouter(prefix='/analytics', tags=['analytics']) +analytics_router = APIRouter(prefix='/workspaces/{workspace_id}/analytics', tags=['analytics']) @analytics_router.get('/placements') 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, ) -> dto.GetPlacementsAnalyticsOutput: input = dto.GetPlacementsAnalyticsInput( user_id=current_user.user_id, + workspace_id=workspace_id, target_channel_id=target_channel_id, ) return await deps.get_usecase().get_placements_analytics(input) @@ -25,11 +27,13 @@ async def get_placements_analytics( @analytics_router.get('/creatives') 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, ) -> dto.GetCreativesAnalyticsOutput: input = dto.GetCreativesAnalyticsInput( user_id=current_user.user_id, + workspace_id=workspace_id, target_channel_id=target_channel_id, ) return await deps.get_usecase().get_creatives_analytics(input) @@ -37,11 +41,13 @@ async def get_creatives_analytics( @analytics_router.get('/external-channels') async def get_external_channels_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( user_id=current_user.user_id, + workspace_id=workspace_id, target_channel_id=target_channel_id, ) return await deps.get_usecase().get_external_channels_analytics(input) @@ -49,6 +55,7 @@ async def get_external_channels_analytics( @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, date_from: datetime.datetime | None = None, @@ -57,6 +64,7 @@ async def get_spending_analytics( ) -> dto.GetSpendingAnalyticsOutput: input = dto.GetSpendingAnalyticsInput( user_id=current_user.user_id, + workspace_id=workspace_id, target_channel_id=target_channel_id, date_from=date_from, date_to=date_to, diff --git a/src/controller/http/creatives.py b/src/controller/http/creatives.py index 49e269f..366de56 100644 --- a/src/controller/http/creatives.py +++ b/src/controller/http/creatives.py @@ -7,17 +7,19 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -creatives_router = APIRouter(prefix='/creatives', tags=['creatives']) +creatives_router = APIRouter(prefix='/workspaces/{workspace_id}/creatives', tags=['creatives']) @creatives_router.get('') async def list_creatives( + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], target_channel_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, include_archived=include_archived, ) @@ -28,11 +30,13 @@ async def list_creatives( @creatives_router.get('/{creative_id}') async def get_creative( creative_id: uuid.UUID, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.CreativeOutput: input = dto.GetCreativeInput( creative_id=creative_id, user_id=current_user.user_id, + workspace_id=workspace_id, ) return await deps.get_usecase().get_creative(input=input) @@ -41,32 +45,37 @@ async def get_creative( @creatives_router.post('') async def create_creative( request: dto.CreateCreativeInput, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.CreativeOutput: - return await deps.get_usecase().create_creative(request, current_user.user_id) + return await deps.get_usecase().create_creative(request, current_user.user_id, workspace_id) @creatives_router.patch('/{creative_id}') async def update_creative( creative_id: uuid.UUID, request: dto.UpdateCreativeInput, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.CreativeOutput: return await deps.get_usecase().update_creative( creative_id=creative_id, input=request, user_id=current_user.user_id, + workspace_id=workspace_id, ) @creatives_router.delete('/{creative_id}') async def delete_creative( creative_id: uuid.UUID, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> None: input = dto.DeleteCreativeInput( creative_id=creative_id, user_id=current_user.user_id, + workspace_id=workspace_id, ) await deps.get_usecase().delete_creative(input) diff --git a/src/controller/http/external_channels.py b/src/controller/http/external_channels.py index c3ebe1f..1a2c9cd 100644 --- a/src/controller/http/external_channels.py +++ b/src/controller/http/external_channels.py @@ -7,18 +7,20 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -external_channels_router = APIRouter(prefix='/external_channels', tags=['external_channels']) +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) @@ -27,12 +29,14 @@ async def get_external_channels( @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, ) @@ -40,6 +44,7 @@ async def create_external_channel( 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).""" @@ -47,6 +52,7 @@ async def update_external_channel( channel_id=channel_id, input=request, user_id=current_user.user_id, + workspace_id=workspace_id, ) @@ -54,6 +60,7 @@ async def update_external_channel( 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: """ @@ -65,18 +72,21 @@ async def 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 f26bdf9..926448a 100644 --- a/src/controller/http/placements.py +++ b/src/controller/http/placements.py @@ -7,11 +7,12 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -placements_router = APIRouter(prefix='/placements', tags=['placements']) +placements_router = APIRouter(prefix='/workspaces/{workspace_id}/placements', tags=['placements']) @placements_router.get('') 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, @@ -20,6 +21,7 @@ async def list_placements( ) -> 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, creative_id=creative_id, @@ -32,11 +34,13 @@ async def list_placements( @placements_router.get('/{placement_id}') async def get_placement( placement_id: uuid.UUID, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.PlacementOutput: input = dto.GetPlacementInput( placement_id=placement_id, user_id=current_user.user_id, + workspace_id=workspace_id, ) return await deps.get_usecase().get_placement(input=input) @@ -45,32 +49,37 @@ async def get_placement( @placements_router.post('') async def create_placement( request: dto.CreatePlacementInput, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.PlacementOutput: - return await deps.get_usecase().create_placement(request, current_user.user_id) + return await deps.get_usecase().create_placement(request, current_user.user_id, workspace_id) @placements_router.patch('/{placement_id}') async def update_placement( placement_id: uuid.UUID, request: dto.UpdatePlacementInput, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.PlacementOutput: return await deps.get_usecase().update_placement( placement_id=placement_id, input=request, user_id=current_user.user_id, + workspace_id=workspace_id, ) @placements_router.delete('/{placement_id}') async def delete_placement( placement_id: uuid.UUID, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> None: input = dto.DeletePlacementInput( placement_id=placement_id, user_id=current_user.user_id, + workspace_id=workspace_id, ) await deps.get_usecase().delete_placement(input) diff --git a/src/controller/http/target_channels.py b/src/controller/http/target_channels.py index d6a1473..3c3f257 100644 --- a/src/controller/http/target_channels.py +++ b/src/controller/http/target_channels.py @@ -1,3 +1,4 @@ +import uuid from typing import Annotated from fastapi import Depends @@ -6,15 +7,17 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -target_channels_router = APIRouter(prefix='/target_channels', tags=['target_channels']) +target_channels_router = APIRouter(prefix='/workspaces/{workspace_id}/target_channels', tags=['target_channels']) @target_channels_router.get('') async def get_user_target_chans( + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], ) -> dto.GetUserTargetChansOutput: input = dto.GetUserTargetChansInput( user_id=current_user.user_id, + workspace_id=workspace_id, ) return await deps.get_usecase().get_user_target_chans(input=input) diff --git a/src/controller/http/views.py b/src/controller/http/views.py index 63f4803..ba78df2 100644 --- a/src/controller/http/views.py +++ b/src/controller/http/views.py @@ -8,12 +8,13 @@ from fastapi.routing import APIRouter from src import deps, dto from src.adapter.jwt import JWTPayload -views_router = APIRouter(prefix='/placements/{placement_id}/views', tags=['views']) +views_router = APIRouter(prefix='/workspaces/{workspace_id}/placements/{placement_id}/views', tags=['views']) @views_router.get('/history') async def get_views_history( placement_id: uuid.UUID, + workspace_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], from_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None, @@ -21,23 +22,9 @@ async def get_views_history( input_data = dto.GetViewsHistoryInput( placement_id=placement_id, user_id=current_user.user_id, + workspace_id=workspace_id, from_date=from_date, to_date=to_date, ) return await deps.get_usecase().get_views_history(input=input_data) - - -@views_router.post('/manual') -async def update_views_manually( - placement_id: uuid.UUID, - views_count: int, - current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.PlacementOutput: - input_data = dto.UpdateViewsManuallyInput( - placement_id=placement_id, - user_id=current_user.user_id, - views_count=views_count, - ) - - return await deps.get_usecase().update_views_manually(input=input_data) diff --git a/src/controller/http/workspaces.py b/src/controller/http/workspaces.py new file mode 100644 index 0000000..c351cd9 --- /dev/null +++ b/src/controller/http/workspaces.py @@ -0,0 +1,42 @@ +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 + +workspaces_router = APIRouter(prefix='/workspaces', tags=['workspaces']) + + +@workspaces_router.get('') +async def list_workspaces( + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.GetWorkspacesOutput: + return await deps.get_usecase().get_workspaces(current_user.user_id) + + +@workspaces_router.post('') +async def create_workspace( + request: dto.CreateWorkspaceInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.CreateWorkspaceOutput: + return await deps.get_usecase().create_workspace(current_user.user_id, request) + + +@workspaces_router.patch('/{workspace_id}') +async def update_workspace( + workspace_id: uuid.UUID, + request: dto.UpdateWorkspaceInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMembershipOutput: + return await deps.get_usecase().update_workspace(workspace_id, current_user.user_id, request) + + +@workspaces_router.delete('/{workspace_id}') +async def delete_workspace( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> None: + await deps.get_usecase().delete_workspace(workspace_id, current_user.user_id) diff --git a/src/domain/__init__.py b/src/domain/__init__.py index 2615019..039af07 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -1,5 +1,7 @@ __all__ = ( 'User', + 'Workspace', + 'WorkspaceUser', 'TargetChannel', 'ExternalChannel', 'Creative', @@ -17,6 +19,8 @@ __all__ = ( 'PostViewsAvailability', 'LoginToken', 'UserNotFound', + 'WorkspaceNotFound', + 'WorkspaceAccessDenied', 'LoginTokenNotFound', 'LoginTokenExpired', 'LoginTokenAlreadyUsed', @@ -44,6 +48,8 @@ from .error import ( PlacementNotFound, TargetChannelNotFound, UserNotFound, + WorkspaceAccessDenied, + WorkspaceNotFound, ) from .external_channel import ExternalChannel from .login_token import LoginToken @@ -54,3 +60,4 @@ 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 diff --git a/src/domain/creative.py b/src/domain/creative.py index a2062d4..133780d 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -8,7 +8,7 @@ from .base import TimestampedModel if TYPE_CHECKING: from .target_channel import TargetChannel - from .user import User + from .workspace import Workspace class CreativeStatus(str, enum.Enum): @@ -23,15 +23,15 @@ class Creative(TimestampedModel): status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) placements_count = fields.IntField(default=0) - user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( - 'models.User', related_name='creatives', on_delete=fields.CASCADE, index=True + 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 ) if TYPE_CHECKING: - user_id: UUID + workspace_id: UUID target_channel_id: UUID class Meta: diff --git a/src/domain/error.py b/src/domain/error.py index e1f39e5..a6d059b 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -64,3 +64,15 @@ def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException: if placement_id is None: return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found') return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} not found') + + +def WorkspaceNotFound(workspace_id: uuid.UUID | None = None) -> HTTPException: + if workspace_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace {workspace_id} not found') + + +def WorkspaceAccessDenied(workspace_id: uuid.UUID | None = None) -> HTTPException: + if workspace_id is None: + return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied') + return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} access denied') diff --git a/src/domain/external_channel.py b/src/domain/external_channel.py index 81a537e..eec94ea 100644 --- a/src/domain/external_channel.py +++ b/src/domain/external_channel.py @@ -7,7 +7,7 @@ from .base import TimestampedModel if TYPE_CHECKING: from .target_channel import TargetChannel - from .user import User + from .workspace import Workspace class ExternalChannel(TimestampedModel): @@ -18,14 +18,13 @@ class ExternalChannel(TimestampedModel): description = fields.TextField(null=True) subscribers_count = fields.IntField(null=True) - user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( - 'models.User', related_name='external_channels', on_delete=fields.CASCADE, index=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'] - user_id: UUID + workspace_id: UUID class Meta: table = 'external_channel' - unique_together = (('user_id', 'telegram_id'),) diff --git a/src/domain/placement.py b/src/domain/placement.py index 6b59cb7..390cab3 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: from .creative import Creative from .external_channel import ExternalChannel from .target_channel import TargetChannel - from .user import User + from .workspace import Workspace class PlacementStatus(str, enum.Enum): @@ -59,15 +59,15 @@ class Placement(TimestampedModel): creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True ) - user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( - 'models.User', related_name='placements', on_delete=fields.CASCADE, index=True + workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField( + 'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True ) if TYPE_CHECKING: target_channel_id: UUID external_channel_id: UUID creative_id: UUID - user_id: UUID + workspace_id: UUID class Meta: table = 'placement' diff --git a/src/domain/target_channel.py b/src/domain/target_channel.py index 527aa23..79a5241 100644 --- a/src/domain/target_channel.py +++ b/src/domain/target_channel.py @@ -8,7 +8,7 @@ from .base import TimestampedModel if TYPE_CHECKING: from .external_channel import ExternalChannel - from .user import User + from .workspace import Workspace class TargetChannelStatus(str, enum.Enum): @@ -24,8 +24,8 @@ class TargetChannel(TimestampedModel): username = fields.CharField(max_length=255, null=True, index=True) status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE) - user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( - 'models.User', related_name='target_channels', on_delete=fields.CASCADE, index=True + 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( @@ -37,7 +37,7 @@ class TargetChannel(TimestampedModel): ) if TYPE_CHECKING: - user_id: UUID + workspace_id: UUID class Meta: table = 'target_channel' diff --git a/src/domain/workspace.py b/src/domain/workspace.py new file mode 100644 index 0000000..c4a8e60 --- /dev/null +++ b/src/domain/workspace.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from tortoise import fields + +from .base import TimestampedModel + +if TYPE_CHECKING: + from .user import User + + +class Workspace(TimestampedModel): + id = fields.UUIDField(pk=True) + name = fields.CharField(max_length=255) + + class Meta: + table = 'workspace' + + +class WorkspaceUser(TimestampedModel): + id = fields.UUIDField(pk=True) + + workspace: fields.ForeignKeyRelation[Workspace] = fields.ForeignKeyField( + 'models.Workspace', related_name='workspace_users', on_delete=fields.CASCADE, index=True + ) + user: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + 'models.User', related_name='workspace_users', on_delete=fields.CASCADE, index=True + ) + + if TYPE_CHECKING: + workspace_id: uuid.UUID + user_id: uuid.UUID + + class Meta: + table = 'workspace_user' + unique_together = (('workspace_id', 'user_id'),) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index f9b706d..50f4f8d 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -53,6 +53,11 @@ __all__ = ( 'ParserPostFoundEvent', 'ParserPostDeletedEvent', 'ParserViewsUpdatedEvent', + 'WorkspaceMembershipOutput', + 'GetWorkspacesOutput', + 'CreateWorkspaceInput', + 'CreateWorkspaceOutput', + 'UpdateWorkspaceInput', ) from .analytics import ( @@ -122,3 +127,10 @@ from .views import ( PlacementViewsHistoryOutput, UpdateViewsManuallyInput, ) +from .workspace import ( + CreateWorkspaceInput, + CreateWorkspaceOutput, + GetWorkspacesOutput, + UpdateWorkspaceInput, + WorkspaceMembershipOutput, +) diff --git a/src/dto/analytics.py b/src/dto/analytics.py index 57dbbad..adf70e9 100644 --- a/src/dto/analytics.py +++ b/src/dto/analytics.py @@ -54,6 +54,7 @@ class ExternalChannelAnalyticsOutput(pydantic.BaseModel): class GetPlacementsAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID + workspace_id: uuid.UUID target_channel_id: uuid.UUID | None = None @@ -63,6 +64,7 @@ class GetPlacementsAnalyticsOutput(pydantic.BaseModel): class GetCreativesAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID + workspace_id: uuid.UUID target_channel_id: uuid.UUID | None = None @@ -72,6 +74,7 @@ class GetCreativesAnalyticsOutput(pydantic.BaseModel): class GetExternalChannelsAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID + workspace_id: uuid.UUID target_channel_id: uuid.UUID | None = None @@ -90,6 +93,7 @@ class SpendingDataPoint(pydantic.BaseModel): class GetSpendingAnalyticsInput(pydantic.BaseModel): user_id: uuid.UUID + workspace_id: uuid.UUID target_channel_id: uuid.UUID | None = None date_from: datetime.datetime | None = None date_to: datetime.datetime | None = None diff --git a/src/dto/creative.py b/src/dto/creative.py index d772c28..71ea1b9 100644 --- a/src/dto/creative.py +++ b/src/dto/creative.py @@ -19,6 +19,7 @@ class CreativeOutput(pydantic.BaseModel): class GetCreativesInput(pydantic.BaseModel): user_id: uuid.UUID + workspace_id: uuid.UUID target_channel_id: uuid.UUID | None = None include_archived: bool = False @@ -30,6 +31,7 @@ class GetCreativesOutput(pydantic.BaseModel): class GetCreativeInput(pydantic.BaseModel): creative_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID class CreateCreativeInput(pydantic.BaseModel): @@ -47,3 +49,4 @@ class UpdateCreativeInput(pydantic.BaseModel): class DeleteCreativeInput(pydantic.BaseModel): creative_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID diff --git a/src/dto/external_channel.py b/src/dto/external_channel.py index 1453c46..c2d0b8f 100644 --- a/src/dto/external_channel.py +++ b/src/dto/external_channel.py @@ -15,6 +15,7 @@ class ExternalChannelOutput(pydantic.BaseModel): class GetExternalChannelsInput(pydantic.BaseModel): target_channel_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID class GetExternalChannelsOutput(pydantic.BaseModel): @@ -33,6 +34,7 @@ class CreateExternalChannelInput(pydantic.BaseModel): class DeleteExternalChannelInput(pydantic.BaseModel): channel_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID class UpdateExternalChannelInput(pydantic.BaseModel): diff --git a/src/dto/nats_messages.py b/src/dto/nats_messages.py deleted file mode 100644 index 5c4710a..0000000 --- a/src/dto/nats_messages.py +++ /dev/null @@ -1,48 +0,0 @@ -import pydantic - - -# Исходящие сообщения (к tgex-parser) -class ParserTrackTaskRequest(pydantic.BaseModel): - """Сообщение для parser.track - добавить задачу на отслеживание канала""" - - task_id: str # UUID placement - channel: str # username внешнего канала - target_text: str # invite_link для поиска в постах - - -class ParserGetViewsRequest(pydantic.BaseModel): - """Сообщение для parser.get_views - синхронно получить просмотры поста""" - - channel: str - message_id: int - - -# Входящие события (от tgex-parser) -class ParserPostFoundEvent(pydantic.BaseModel): - """Событие parser.event.post_found - найден новый пост с target_text""" - - task_id: str # UUID placement - channel: str - message_id: int - link: str # полная ссылка https://t.me/channel/message_id - text: str # текст поста - views: int - - -class ParserPostDeletedEvent(pydantic.BaseModel): - """Событие parser.event.post_deleted - отслеживаемый пост удален""" - - task_id: str - channel: str - message_id: int - link: str - views: int - - -class ParserViewsUpdatedEvent(pydantic.BaseModel): - """Событие parser.event.views_updated - обновилось количество просмотров""" - - task_id: str - channel: str - message_id: int - views: int diff --git a/src/dto/placement.py b/src/dto/placement.py index 4c9df0f..abf0d22 100644 --- a/src/dto/placement.py +++ b/src/dto/placement.py @@ -30,6 +30,7 @@ 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 creative_id: uuid.UUID | None = None @@ -43,6 +44,7 @@ class GetPlacementsOutput(pydantic.BaseModel): class GetPlacementInput(pydantic.BaseModel): placement_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID class CreatePlacementInput(pydantic.BaseModel): @@ -65,3 +67,4 @@ class UpdatePlacementInput(pydantic.BaseModel): class DeletePlacementInput(pydantic.BaseModel): placement_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID diff --git a/src/dto/target_channel.py b/src/dto/target_channel.py index 58abe8d..1574533 100644 --- a/src/dto/target_channel.py +++ b/src/dto/target_channel.py @@ -37,6 +37,7 @@ class ConnectTargetChanOutput(pydantic.BaseModel): class GetUserTargetChansInput(pydantic.BaseModel): user_id: uuid.UUID + workspace_id: uuid.UUID class GetUserTargetChansOutput(pydantic.BaseModel): diff --git a/src/dto/views.py b/src/dto/views.py index afd5d3b..45ede91 100644 --- a/src/dto/views.py +++ b/src/dto/views.py @@ -19,6 +19,7 @@ class GetViewsHistoryInput(pydantic.BaseModel): placement_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID from_date: datetime.datetime | None = None # Фильтр: с какой даты to_date: datetime.datetime | None = None # Фильтр: по какую дату @@ -34,4 +35,5 @@ class UpdateViewsManuallyInput(pydantic.BaseModel): placement_id: uuid.UUID user_id: uuid.UUID + workspace_id: uuid.UUID views_count: int diff --git a/src/dto/workspace.py b/src/dto/workspace.py new file mode 100644 index 0000000..a5a094a --- /dev/null +++ b/src/dto/workspace.py @@ -0,0 +1,23 @@ +import uuid + +import pydantic + + +class WorkspaceMembershipOutput(pydantic.BaseModel): + id: uuid.UUID + name: str + + +class CreateWorkspaceOutput(WorkspaceMembershipOutput): ... + + +class GetWorkspacesOutput(pydantic.BaseModel): + workspaces: list[WorkspaceMembershipOutput] + + +class CreateWorkspaceInput(pydantic.BaseModel): + name: str + + +class UpdateWorkspaceInput(pydantic.BaseModel): + name: str | None = None diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index 24416d1..884329e 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -22,6 +22,7 @@ from .creative.get_creative import get_creative from .creative.get_creatives import get_creatives from .creative.handle_creative_callback import handle_creative_callback from .creative.handle_creative_text_input import handle_creative_text_input +from .creative.handle_creative_workspace_callback import handle_creative_workspace_callback from .creative.start_creative_creation import start_creative_creation from .creative.update_creative import update_creative from .external_channel.create_external_channel import create_external_channel @@ -41,7 +42,10 @@ from .target_channel.disconnect_target_chan_by_tg_id import disconnect_target_ch from .target_channel.get_user_target_chans import get_user_target_chans from .target_channel.update_target_chan_permissions import update_target_chan_permissions from .views.get_views_history import get_views_history -from .views.update_views_manually import update_views_manually +from .workspace.create_workspace import create_workspace +from .workspace.delete_workspace import delete_workspace +from .workspace.get_workspaces import get_workspaces +from .workspace.update_workspace import update_workspace class Database(typing.Protocol): @@ -51,6 +55,24 @@ class Database(typing.Protocol): async def create_user(self, user: domain.User) -> None: ... + async def create_workspace(self, workspace: domain.Workspace) -> None: ... + + async def update_workspace(self, workspace: domain.Workspace) -> None: ... + + async def delete_workspace(self, workspace_id: UUID) -> None: ... + + async def add_user_to_workspace(self, workspace_id: UUID, user_id: UUID) -> None: ... + + async def get_user_workspaces(self, user_id: UUID) -> list[domain.WorkspaceUser]: ... + + async def get_workspace(self, workspace_id: UUID) -> domain.Workspace | None: ... + + async def get_workspace_for_user(self, workspace_id: UUID, user_id: UUID) -> domain.Workspace | None: ... + + async def get_default_workspace_for_user(self, user_id: UUID) -> domain.Workspace | None: ... + + async def get_workspace_membership(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser | None: ... + async def create_login_token(self, login_token: domain.LoginToken) -> None: ... async def create_creative(self, creative: domain.Creative) -> None: ... @@ -60,24 +82,28 @@ class Database(typing.Protocol): async def mark_token_as_used(self, token: str) -> None: ... async def get_target_channel( - self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None + self, workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None + ) -> domain.TargetChannel | None: ... + + async def get_target_channel_for_user_by_telegram( + self, user_id: UUID, telegram_id: int ) -> domain.TargetChannel | None: ... async def upsert_target_channel(self, channel: domain.TargetChannel) -> None: ... - async def get_user_target_channels(self, user_id: UUID) -> list[domain.TargetChannel]: ... + async def get_workspace_target_channels(self, workspace_id: UUID) -> list[domain.TargetChannel]: ... async def update_target_channel(self, channel: domain.TargetChannel) -> None: ... async def get_external_channel( - self, user_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None + self, workspace_id: UUID, channel_id: UUID | None = None, telegram_id: int | None = None ) -> domain.ExternalChannel | None: ... async def create_external_channel(self, channel: domain.ExternalChannel) -> None: ... async def get_external_channels_for_target(self, target_channel_id: UUID) -> list[domain.ExternalChannel]: ... - async def get_user_external_channels(self, user_id: UUID) -> list[domain.ExternalChannel]: ... + async def get_workspace_external_channels(self, workspace_id: UUID) -> list[domain.ExternalChannel]: ... async def add_external_channel_to_targets( self, external_channel: domain.ExternalChannel, target_channel_ids: list[UUID] @@ -89,25 +115,25 @@ class Database(typing.Protocol): async def update_external_channel(self, channel: domain.ExternalChannel) -> None: ... - async def get_creative(self, user_id: UUID, creative_id: UUID) -> domain.Creative | 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_user_creatives( - self, user_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False + async def get_workspace_creatives( + self, workspace_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False ) -> list[domain.Creative]: ... async def delete_creative(self, creative_id: UUID) -> None: ... - async def get_placement(self, user_id: UUID, placement_id: UUID) -> domain.Placement | None: ... + async def get_placement(self, workspace_id: UUID, placement_id: UUID) -> domain.Placement | None: ... async def create_placement(self, placement: domain.Placement) -> None: ... async def update_placement(self, placement: domain.Placement) -> None: ... - async def get_user_placements( + async def get_workspace_placements( self, - user_id: UUID, + workspace_id: UUID, target_channel_id: UUID | None = None, external_channel_id: UUID | None = None, creative_id: UUID | None = None, @@ -181,6 +207,13 @@ class Usecase: telegram_bot: TelegramBotWriter jwt_encoder: JWTEncoder + async def ensure_workspace_access(self, workspace_id: UUID, user_id: UUID) -> domain.Workspace: + workspace = await self.database.get_workspace_for_user(workspace_id, user_id) + if not workspace: + raise domain.WorkspaceNotFound(workspace_id) + + return workspace + validate_login_token = validate_login_token telegram_login = telegram_login telegram_start = telegram_start @@ -201,6 +234,7 @@ class Usecase: delete_creative = delete_creative start_creative_creation = start_creative_creation handle_creative_callback = handle_creative_callback + handle_creative_workspace_callback = handle_creative_workspace_callback handle_creative_text_input = handle_creative_text_input get_placements = get_placements get_placement = get_placement @@ -210,8 +244,11 @@ class Usecase: handle_subscription = handle_subscription handle_unsubscription = handle_unsubscription get_views_history = get_views_history - update_views_manually = update_views_manually get_placements_analytics = get_placements_analytics get_creatives_analytics = get_creatives_analytics get_external_channels_analytics = get_external_channels_analytics get_spending_analytics = get_spending_analytics + get_workspaces = get_workspaces + create_workspace = create_workspace + update_workspace = update_workspace + delete_workspace = delete_workspace diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 5f1d294..99cb5cf 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -14,13 +14,19 @@ log = logging.getLogger(__name__) async def get_creatives_analytics( self: 'Usecase', input: dto.GetCreativesAnalyticsInput ) -> 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.user_id, 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) - creatives = await self.database.get_user_creatives(input.user_id, input.target_channel_id, include_archived=False) - placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + creatives = await self.database.get_workspace_creatives( + input.workspace_id, input.target_channel_id, include_archived=False + ) + placements = await self.database.get_workspace_placements( + input.workspace_id, input.target_channel_id, include_archived=False + ) @dataclass class CreativeStats: diff --git a/src/usecase/analytics/get_external_channels_analytics.py b/src/usecase/analytics/get_external_channels_analytics.py index 3843c68..29ae1c0 100644 --- a/src/usecase/analytics/get_external_channels_analytics.py +++ b/src/usecase/analytics/get_external_channels_analytics.py @@ -14,15 +14,19 @@ log = logging.getLogger(__name__) async def get_external_channels_analytics( self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput ) -> dto.GetExternalChannelsAnalyticsOutput: + 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.user_id, 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) else: - external_channels = await self.database.get_user_external_channels(input.user_id) + external_channels = await self.database.get_workspace_external_channels(input.workspace_id) - placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + placements = await self.database.get_workspace_placements( + input.workspace_id, input.target_channel_id, include_archived=False + ) @dataclass class ChannelStats: diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 847569f..10c3460 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -24,12 +24,16 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None: async def get_placements_analytics( self: 'Usecase', input: dto.GetPlacementsAnalyticsInput ) -> 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.user_id, 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) - placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + placements = await self.database.get_workspace_placements( + input.workspace_id, input.target_channel_id, include_archived=False + ) result = [ dto.PlacementAnalyticsOutput( diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index 3bab756..dd14315 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -33,12 +33,16 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str: async def get_spending_analytics( self: 'Usecase', input: dto.GetSpendingAnalyticsInput ) -> 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.user_id, 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) - placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) + placements = await self.database.get_workspace_placements( + input.workspace_id, input.target_channel_id, include_archived=False + ) filtered = [ p diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index 23c2438..5d2494b 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -10,8 +10,12 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID) -> dto.CreativeOutput: - target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) +async def create_creative( + self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> 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) @@ -21,9 +25,8 @@ async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_ text=input.text, status=domain.CreativeStatus.ACTIVE, target_channel_id=target_channel.id, - user_id=user_id, + workspace_id=workspace_id, ) - await self.database.create_creative(creative) return dto.CreativeOutput( diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py index f60cd7b..b50914e 100644 --- a/src/usecase/creative/delete_creative.py +++ b/src/usecase/creative/delete_creative.py @@ -10,7 +10,9 @@ log = logging.getLogger(__name__) async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: - creative = await self.database.get_creative(input.user_id, input.creative_id) + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + creative = await self.database.get_creative(input.workspace_id, input.creative_id) if not creative: log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id) diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index bc1c3aa..597761e 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -10,9 +10,10 @@ log = logging.getLogger(__name__) async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput: - creative = await self.database.get_creative(input.user_id, input.creative_id) + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + creative = await self.database.get_creative(input.workspace_id, input.creative_id) if not creative: - log.warning('User %s attempted to access unavailable creative %s', input.user_id, input.creative_id) raise domain.CreativeNotFound(input.creative_id) return dto.CreativeOutput( diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index 5fe4f5e..9649eab 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -7,7 +7,11 @@ if TYPE_CHECKING: async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput: - creatives = await self.database.get_user_creatives(input.user_id, input.target_channel_id, input.include_archived) + 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 + ) return dto.GetCreativesOutput( creatives=[ diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index c3ca16d..c1eff7a 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -11,9 +11,15 @@ log = logging.getLogger(__name__) async def update_creative( - self: 'Usecase', creative_id: uuid.UUID, input: dto.UpdateCreativeInput, user_id: uuid.UUID + self: 'Usecase', + creative_id: uuid.UUID, + input: dto.UpdateCreativeInput, + user_id: uuid.UUID, + workspace_id: uuid.UUID, ) -> dto.CreativeOutput: - creative = await self.database.get_creative(user_id, creative_id) + await self.ensure_workspace_access(workspace_id, user_id) + + creative = await self.database.get_creative(workspace_id, creative_id) if not creative: log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id) raise domain.CreativeNotFound(creative_id) diff --git a/src/usecase/external_channel/create_external_channel.py b/src/usecase/external_channel/create_external_channel.py index 688ba3b..3d3be74 100644 --- a/src/usecase/external_channel/create_external_channel.py +++ b/src/usecase/external_channel/create_external_channel.py @@ -11,17 +11,18 @@ log = logging.getLogger(__name__) async def create_external_channel( - self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID + self: 'Usecase', input: dto.CreateExternalChannelInput, user_id: uuid.UUID, workspace_id: uuid.UUID ) -> dto.ExternalChannelOutput: - existing_channel = await self.database.get_external_channel(user_id, telegram_id=input.telegram_id) + 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(user_id, channel_id=target_id) + target_channel = await self.database.get_target_channel(workspace_id, channel_id=target_id) if not target_channel: - log.warning('User %s attempted to add external channel to unavailable target %s', user_id, target_id) raise domain.TargetChannelNotFound(target_id) async with self.database.transaction(): @@ -31,7 +32,7 @@ async def create_external_channel( username=input.username, description=input.description, subscribers_count=input.subscribers_count, - user_id=user_id, + workspace_id=workspace_id, ) await self.database.create_external_channel(channel) diff --git a/src/usecase/external_channel/delete_external_channel.py b/src/usecase/external_channel/delete_external_channel.py index 74be4f4..34d9b98 100644 --- a/src/usecase/external_channel/delete_external_channel.py +++ b/src/usecase/external_channel/delete_external_channel.py @@ -10,9 +10,10 @@ log = logging.getLogger(__name__) async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None: - channel = await self.database.get_external_channel(input.user_id, channel_id=input.channel_id) + 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: - log.warning('User %s attempted to delete unavailable external channel %s', input.user_id, input.channel_id) raise domain.ExternalChannelNotFound(input.channel_id) await self.database.delete_external_channel(input.channel_id) diff --git a/src/usecase/external_channel/get_external_channels.py b/src/usecase/external_channel/get_external_channels.py index d2f3530..bef908b 100644 --- a/src/usecase/external_channel/get_external_channels.py +++ b/src/usecase/external_channel/get_external_channels.py @@ -10,7 +10,9 @@ log = logging.getLogger(__name__) async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput: - target_channel = await self.database.get_target_channel(input.user_id, channel_id=input.target_channel_id) + 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) diff --git a/src/usecase/external_channel/update_external_channel.py b/src/usecase/external_channel/update_external_channel.py index e23a3be..464573c 100644 --- a/src/usecase/external_channel/update_external_channel.py +++ b/src/usecase/external_channel/update_external_channel.py @@ -11,9 +11,15 @@ log = logging.getLogger(__name__) async def update_external_channel( - self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelInput, user_id: uuid.UUID + self: 'Usecase', + channel_id: uuid.UUID, + input: dto.UpdateExternalChannelInput, + user_id: uuid.UUID, + workspace_id: uuid.UUID, ) -> dto.ExternalChannelOutput: - external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) + 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) diff --git a/src/usecase/external_channel/update_external_channel_links.py b/src/usecase/external_channel/update_external_channel_links.py index dbf740f..35ab9d4 100644 --- a/src/usecase/external_channel/update_external_channel_links.py +++ b/src/usecase/external_channel/update_external_channel_links.py @@ -11,22 +11,28 @@ log = logging.getLogger(__name__) async def update_external_channel_links( - self: 'Usecase', channel_id: uuid.UUID, input: dto.UpdateExternalChannelLinksInput, user_id: uuid.UUID + self: 'Usecase', + channel_id: uuid.UUID, + input: dto.UpdateExternalChannelLinksInput, + user_id: uuid.UUID, + workspace_id: uuid.UUID, ) -> dto.ExternalChannelOutput: - external_channel = await self.database.get_external_channel(user_id, channel_id=channel_id) + 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(user_id, channel_id=target_id) + 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(channel_id, 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) diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index 70f9682..1911781 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -10,16 +10,20 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) -async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID) -> dto.PlacementOutput: - target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) +async def create_placement( + self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID +) -> 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) - external_channel = await self.database.get_external_channel(user_id, channel_id=input.external_channel_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) - creative = await self.database.get_creative(user_id, input.creative_id) + creative = await self.database.get_creative(workspace_id, input.creative_id) if not creative: raise domain.CreativeNotFound(input.creative_id) @@ -30,7 +34,7 @@ async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, use target_channel_id=input.target_channel_id, external_channel_id=input.external_channel_id, creative_id=input.creative_id, - user_id=user_id, + workspace_id=workspace_id, placement_date=input.placement_date, cost=input.cost, comment=input.comment, diff --git a/src/usecase/placement/delete_placement.py b/src/usecase/placement/delete_placement.py index 040ceae..213ace0 100644 --- a/src/usecase/placement/delete_placement.py +++ b/src/usecase/placement/delete_placement.py @@ -10,7 +10,9 @@ log = logging.getLogger(__name__) async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: - placement = await self.database.get_placement(input.user_id, input.placement_id) + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id) @@ -20,7 +22,7 @@ async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> return async with self.database.transaction(): - creative = await self.database.get_creative(input.user_id, placement.creative_id) + creative = await self.database.get_creative(input.workspace_id, placement.creative_id) if creative and creative.placements_count > 0: creative.placements_count -= 1 await self.database.update_creative(creative) diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py index 1abea36..006094b 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -10,7 +10,9 @@ log = logging.getLogger(__name__) async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: - placement = await self.database.get_placement(input.user_id, input.placement_id) + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id) diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py index 06e604c..795ffbd 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -7,8 +7,10 @@ if TYPE_CHECKING: async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: - placements = await self.database.get_user_placements( - input.user_id, + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + placements = await self.database.get_workspace_placements( + input.workspace_id, target_channel_id=input.target_channel_id, external_channel_id=input.external_channel_id, creative_id=input.creative_id, diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index 70c9e84..3cb00e1 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -11,9 +11,15 @@ log = logging.getLogger(__name__) async def update_placement( - self: 'Usecase', placement_id: uuid.UUID, input: dto.UpdatePlacementInput, user_id: uuid.UUID + self: 'Usecase', + placement_id: uuid.UUID, + input: dto.UpdatePlacementInput, + user_id: uuid.UUID, + workspace_id: uuid.UUID, ) -> dto.PlacementOutput: - placement = await self.database.get_placement(user_id, placement_id) + await self.ensure_workspace_access(workspace_id, user_id) + + placement = await self.database.get_placement(workspace_id, placement_id) if not placement: log.warning('Placement %s not found for user %s', placement_id, user_id) raise domain.PlacementNotFound(placement_id) 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 index d1c033d..342f92a 100644 --- a/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py +++ b/src/usecase/target_channel/disconnect_target_chan_by_tg_id.py @@ -15,7 +15,7 @@ async def disconnect_target_chan_by_tg_id(self: 'Usecase', input: dto.Disconnect log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel') return - channel = await self.database.get_target_channel(user.id, telegram_id=input.telegram_id) + 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() diff --git a/src/usecase/target_channel/get_user_target_chans.py b/src/usecase/target_channel/get_user_target_chans.py index 7c3e2cc..04b4b5e 100644 --- a/src/usecase/target_channel/get_user_target_chans.py +++ b/src/usecase/target_channel/get_user_target_chans.py @@ -7,7 +7,9 @@ if TYPE_CHECKING: async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput: - channels = await self.database.get_user_target_channels(input.user_id) + 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=[ diff --git a/src/usecase/target_channel/update_target_chan_permissions.py b/src/usecase/target_channel/update_target_chan_permissions.py index a71ae1c..dc19472 100644 --- a/src/usecase/target_channel/update_target_chan_permissions.py +++ b/src/usecase/target_channel/update_target_chan_permissions.py @@ -22,7 +22,7 @@ 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(user.id, telegram_id=input.telegram_id) + 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() diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py index 2aaf8d3..d9e0c2c 100644 --- a/src/usecase/views/get_views_history.py +++ b/src/usecase/views/get_views_history.py @@ -10,7 +10,9 @@ log = logging.getLogger(__name__) async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput: - placement = await self.database.get_placement(input.user_id, input.placement_id) + await self.ensure_workspace_access(input.workspace_id, input.user_id) + + placement = await self.database.get_placement(input.workspace_id, input.placement_id) if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id) diff --git a/src/usecase/views/update_views_manually.py b/src/usecase/views/update_views_manually.py deleted file mode 100644 index 93f16fa..0000000 --- a/src/usecase/views/update_views_manually.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging -from typing import TYPE_CHECKING - -from tortoise import timezone - -from src import domain, dto - -if TYPE_CHECKING: - from .. import Usecase - -log = logging.getLogger(__name__) - - -async def update_views_manually(self: 'Usecase', input: dto.UpdateViewsManuallyInput) -> dto.PlacementOutput: - placement = await self.database.get_placement(input.user_id, input.placement_id) - if not placement: - log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) - raise domain.PlacementNotFound(input.placement_id) - - fetched_at = timezone.now() - - async with self.database.transaction(): - placement.views_count = input.views_count - placement.views_availability = domain.PostViewsAvailability.MANUAL - placement.last_views_fetch_at = fetched_at - await self.database.update_placement(placement) - - history = domain.PlacementViewsHistory( - placement_id=placement.id, - views_count=input.views_count, - fetched_at=fetched_at, - ) - await self.database.create_placement_views_history(history) - - log.info('Views manually updated for placement %s: %d', placement.id, input.views_count) - - 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, - creative_id=placement.creative_id, - creative_name=placement.creative.name, - placement_date=placement.placement_date, - cost=placement.cost, - comment=placement.comment, - ad_post_url=placement.ad_post_url, - invite_link_type=placement.invite_link_type, - invite_link=placement.invite_link, - status=placement.status, - subscriptions_count=placement.subscriptions_count, - views_count=placement.views_count, - views_availability=placement.views_availability, - last_views_fetch_at=placement.last_views_fetch_at, - created_at=placement.created_at, - ) diff --git a/src/usecase/workspace/create_workspace.py b/src/usecase/workspace/create_workspace.py new file mode 100644 index 0000000..846e8af --- /dev/null +++ b/src/usecase/workspace/create_workspace.py @@ -0,0 +1,28 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def create_workspace( + self: 'Usecase', user_id: uuid.UUID, input: dto.CreateWorkspaceInput +) -> dto.CreateWorkspaceOutput: + user = await self.database.get_user(user_id=user_id) + if not user: + raise domain.UserNotFound(user_id) + + workspace = domain.Workspace( + name=input.name, + ) + + async with self.database.transaction(): + await self.database.create_workspace(workspace) + await self.database.add_user_to_workspace(workspace.id, user_id) + + return dto.CreateWorkspaceOutput( + id=workspace.id, + name=workspace.name, + ) diff --git a/src/usecase/workspace/delete_workspace.py b/src/usecase/workspace/delete_workspace.py new file mode 100644 index 0000000..d8eb95a --- /dev/null +++ b/src/usecase/workspace/delete_workspace.py @@ -0,0 +1,11 @@ +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .. import Usecase + + +async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: + self.ensure_workspace_access(workspace_id, user_id) + + await self.database.delete_workspace(workspace_id) diff --git a/src/usecase/workspace/get_workspaces.py b/src/usecase/workspace/get_workspaces.py new file mode 100644 index 0000000..9e9625f --- /dev/null +++ b/src/usecase/workspace/get_workspaces.py @@ -0,0 +1,21 @@ +import uuid +from typing import TYPE_CHECKING + +from src import dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspaces(self: 'Usecase', user_id: uuid.UUID) -> dto.GetWorkspacesOutput: + memberships = await self.database.get_user_workspaces(user_id) + + return dto.GetWorkspacesOutput( + workspaces=[ + dto.WorkspaceMembershipOutput( + id=membership.workspace_id, + name=membership.workspace.name, + ) + for membership in memberships + ] + ) diff --git a/src/usecase/workspace/update_workspace.py b/src/usecase/workspace/update_workspace.py new file mode 100644 index 0000000..6ec82c8 --- /dev/null +++ b/src/usecase/workspace/update_workspace.py @@ -0,0 +1,26 @@ +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def update_workspace( + self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput +) -> dto.WorkspaceMembershipOutput: + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership: + raise domain.WorkspaceNotFound(workspace_id) + + workspace = membership.workspace + + if input.name is not None: + workspace.name = input.name + await self.database.update_workspace(workspace) + + return dto.WorkspaceMembershipOutput( + id=workspace.id, + name=workspace.name, + )