feat: add workspace

This commit is contained in:
Artem Tsyrulnikov
2025-12-13 22:09:55 +03:00
parent 2fdd56c3e0
commit af32e2a507
55 changed files with 677 additions and 227 deletions

View File

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

View File

@@ -19,6 +19,55 @@ class Postgres(DatabaseBase):
async def create_user(self, user: domain.User) -> None: async def create_user(self, user: domain.User) -> None:
await user.save() 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: async def create_login_token(self, login_token: domain.LoginToken) -> None:
await login_token.save() await login_token.save()
@@ -46,12 +95,12 @@ class Postgres(DatabaseBase):
raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован raise domain.LoginTokenAlreadyUsed() # либо нет токена, либо он уже использован
async def get_target_channel( 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: ) -> domain.TargetChannel | None:
if not channel_id and not telegram_id: if not channel_id and not telegram_id:
raise ValueError('Either channel_id or telegram_id must be provided') 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: if channel_id:
q = q.filter(id=channel_id) q = q.filter(id=channel_id)
if telegram_id: if telegram_id:
@@ -59,8 +108,17 @@ class Postgres(DatabaseBase):
return await q.first() 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: 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: if not existing:
await channel.save() await channel.save()
@@ -68,22 +126,24 @@ class Postgres(DatabaseBase):
existing.title = channel.title existing.title = channel.title
existing.username = channel.username existing.username = channel.username
existing.user_id = channel.user_id existing.workspace_id = channel.workspace_id
existing.status = channel.status existing.status = channel.status
existing.deleted_at = None existing.deleted_at = None
existing.updated_at = timezone.now() existing.updated_at = timezone.now()
await existing.save() await existing.save()
async def get_user_target_channels(self, user_id: uuid.UUID) -> list[domain.TargetChannel]: async def get_workspace_target_channels(self, workspace_id: uuid.UUID) -> list[domain.TargetChannel]:
return await domain.TargetChannel.filter(user_id=user_id, status=domain.TargetChannelStatus.ACTIVE).all() return await domain.TargetChannel.filter(
workspace_id=workspace_id, status=domain.TargetChannelStatus.ACTIVE
).all()
async def update_target_channel(self, channel: domain.TargetChannel) -> None: async def update_target_channel(self, channel: domain.TargetChannel) -> None:
await channel.save() await channel.save()
async def get_external_channel( 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: ) -> domain.ExternalChannel | None:
query = domain.ExternalChannel.filter(user_id=user_id) query = domain.ExternalChannel.filter(workspace_id=workspace_id)
if channel_id: if channel_id:
query = query.filter(id=channel_id) query = query.filter(id=channel_id)
if telegram_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]: 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() 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]: async def get_workspace_external_channels(self, workspace_id: uuid.UUID) -> list[domain.ExternalChannel]:
return await domain.ExternalChannel.filter(user_id=user_id).all() return await domain.ExternalChannel.filter(workspace_id=workspace_id).all()
async def add_external_channel_to_targets( async def add_external_channel_to_targets(
self, external_channel: domain.ExternalChannel, target_channel_ids: list[uuid.UUID] 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: async def update_external_channel(self, channel: domain.ExternalChannel) -> None:
await channel.save() await channel.save()
async def get_creative(self, user_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None: async def get_creative(self, workspace_id: uuid.UUID, creative_id: uuid.UUID) -> domain.Creative | None:
return await domain.Creative.get_or_none(id=creative_id, user_id=user_id).prefetch_related('target_channel') 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: async def update_creative(self, creative: domain.Creative) -> None:
await creative.save() await creative.save()
async def get_user_creatives( async def get_workspace_creatives(
self, user_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False self, workspace_id: uuid.UUID, target_channel_id: uuid.UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]: ) -> list[domain.Creative]:
query = domain.Creative.filter(user_id=user_id) query = domain.Creative.filter(workspace_id=workspace_id)
if target_channel_id: if target_channel_id:
query = query.filter(target_channel_id=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: async def delete_creative(self, creative_id: uuid.UUID) -> None:
await domain.Creative.filter(id=creative_id).delete() await domain.Creative.filter(id=creative_id).delete()
async def get_placement(self, user_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None: async def get_placement(self, workspace_id: uuid.UUID, placement_id: uuid.UUID) -> domain.Placement | None:
return await domain.Placement.get_or_none(id=placement_id, user_id=user_id).prefetch_related( return await domain.Placement.get_or_none(id=placement_id, workspace_id=workspace_id).prefetch_related(
'target_channel', 'external_channel', 'creative' 'target_channel', 'external_channel', 'creative'
) )
@@ -154,15 +216,15 @@ class Postgres(DatabaseBase):
async def update_placement(self, placement: domain.Placement) -> None: async def update_placement(self, placement: domain.Placement) -> None:
await placement.save() await placement.save()
async def get_user_placements( async def get_workspace_placements(
self, self,
user_id: uuid.UUID, workspace_id: uuid.UUID,
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
external_channel_id: uuid.UUID | None = None, external_channel_id: uuid.UUID | None = None,
creative_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> list[domain.Placement]: ) -> list[domain.Placement]:
query = domain.Placement.filter(user_id=user_id) query = domain.Placement.filter(workspace_id=workspace_id)
if target_channel_id: if target_channel_id:
query = query.filter(target_channel_id=target_channel_id) query = query.filter(target_channel_id=target_channel_id)

View File

@@ -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.placements import placements_router
from src.controller.http.target_channels import target_channels_router from src.controller.http.target_channels import target_channels_router
from src.controller.http.views import views_router from src.controller.http.views import views_router
from src.controller.http.workspaces import workspaces_router
api_router = APIRouter() 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(placements_router)
api_v1_router.include_router(views_router) api_v1_router.include_router(views_router)
api_v1_router.include_router(analytics_router) api_v1_router.include_router(analytics_router)
api_v1_router.include_router(workspaces_router)
api_router.include_router(api_v1_router) api_router.include_router(api_v1_router)

View File

@@ -8,16 +8,18 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload 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') @analytics_router.get('/placements')
async def get_placements_analytics( async def get_placements_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
) -> dto.GetPlacementsAnalyticsOutput: ) -> dto.GetPlacementsAnalyticsOutput:
input = dto.GetPlacementsAnalyticsInput( input = dto.GetPlacementsAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
) )
return await deps.get_usecase().get_placements_analytics(input) return await deps.get_usecase().get_placements_analytics(input)
@@ -25,11 +27,13 @@ async def get_placements_analytics(
@analytics_router.get('/creatives') @analytics_router.get('/creatives')
async def get_creatives_analytics( async def get_creatives_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
) -> dto.GetCreativesAnalyticsOutput: ) -> dto.GetCreativesAnalyticsOutput:
input = dto.GetCreativesAnalyticsInput( input = dto.GetCreativesAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
) )
return await deps.get_usecase().get_creatives_analytics(input) return await deps.get_usecase().get_creatives_analytics(input)
@@ -37,11 +41,13 @@ async def get_creatives_analytics(
@analytics_router.get('/external-channels') @analytics_router.get('/external-channels')
async def get_external_channels_analytics( async def get_external_channels_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
) -> dto.GetExternalChannelsAnalyticsOutput: ) -> dto.GetExternalChannelsAnalyticsOutput:
input = dto.GetExternalChannelsAnalyticsInput( input = dto.GetExternalChannelsAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
) )
return await deps.get_usecase().get_external_channels_analytics(input) return await deps.get_usecase().get_external_channels_analytics(input)
@@ -49,6 +55,7 @@ async def get_external_channels_analytics(
@analytics_router.get('/spending') @analytics_router.get('/spending')
async def get_spending_analytics( async def get_spending_analytics(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
date_from: datetime.datetime | None = None, date_from: datetime.datetime | None = None,
@@ -57,6 +64,7 @@ async def get_spending_analytics(
) -> dto.GetSpendingAnalyticsOutput: ) -> dto.GetSpendingAnalyticsOutput:
input = dto.GetSpendingAnalyticsInput( input = dto.GetSpendingAnalyticsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
date_from=date_from, date_from=date_from,
date_to=date_to, date_to=date_to,

View File

@@ -7,17 +7,19 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload 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('') @creatives_router.get('')
async def list_creatives( async def list_creatives(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
) -> dto.GetCreativesOutput: ) -> dto.GetCreativesOutput:
input = dto.GetCreativesInput( input = dto.GetCreativesInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
include_archived=include_archived, include_archived=include_archived,
) )
@@ -28,11 +30,13 @@ async def list_creatives(
@creatives_router.get('/{creative_id}') @creatives_router.get('/{creative_id}')
async def get_creative( async def get_creative(
creative_id: uuid.UUID, creative_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.CreativeOutput: ) -> dto.CreativeOutput:
input = dto.GetCreativeInput( input = dto.GetCreativeInput(
creative_id=creative_id, creative_id=creative_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
return await deps.get_usecase().get_creative(input=input) return await deps.get_usecase().get_creative(input=input)
@@ -41,32 +45,37 @@ async def get_creative(
@creatives_router.post('') @creatives_router.post('')
async def create_creative( async def create_creative(
request: dto.CreateCreativeInput, request: dto.CreateCreativeInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.CreativeOutput: ) -> 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}') @creatives_router.patch('/{creative_id}')
async def update_creative( async def update_creative(
creative_id: uuid.UUID, creative_id: uuid.UUID,
request: dto.UpdateCreativeInput, request: dto.UpdateCreativeInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.CreativeOutput: ) -> dto.CreativeOutput:
return await deps.get_usecase().update_creative( return await deps.get_usecase().update_creative(
creative_id=creative_id, creative_id=creative_id,
input=request, input=request,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
@creatives_router.delete('/{creative_id}') @creatives_router.delete('/{creative_id}')
async def delete_creative( async def delete_creative(
creative_id: uuid.UUID, creative_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None: ) -> None:
input = dto.DeleteCreativeInput( input = dto.DeleteCreativeInput(
creative_id=creative_id, creative_id=creative_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
await deps.get_usecase().delete_creative(input) await deps.get_usecase().delete_creative(input)

View File

@@ -7,18 +7,20 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload 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}') @external_channels_router.get('/target/{target_channel_id}')
async def get_external_channels( async def get_external_channels(
target_channel_id: uuid.UUID, target_channel_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetExternalChannelsOutput: ) -> dto.GetExternalChannelsOutput:
"""Get all external channels for a specific target channel.""" """Get all external channels for a specific target channel."""
input = dto.GetExternalChannelsInput( input = dto.GetExternalChannelsInput(
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
return await deps.get_usecase().get_external_channels(input=input) return await deps.get_usecase().get_external_channels(input=input)
@@ -27,12 +29,14 @@ async def get_external_channels(
@external_channels_router.post('') @external_channels_router.post('')
async def create_external_channel( async def create_external_channel(
request: dto.CreateExternalChannelInput, request: dto.CreateExternalChannelInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ExternalChannelOutput: ) -> dto.ExternalChannelOutput:
"""Create a new external channel and link it to target channels.""" """Create a new external channel and link it to target channels."""
return await deps.get_usecase().create_external_channel( return await deps.get_usecase().create_external_channel(
input=request, input=request,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
@@ -40,6 +44,7 @@ async def create_external_channel(
async def update_external_channel( async def update_external_channel(
channel_id: uuid.UUID, channel_id: uuid.UUID,
request: dto.UpdateExternalChannelInput, request: dto.UpdateExternalChannelInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ExternalChannelOutput: ) -> dto.ExternalChannelOutput:
"""Update external channel fields (title, username, description, subscribers_count).""" """Update external channel fields (title, username, description, subscribers_count)."""
@@ -47,6 +52,7 @@ async def update_external_channel(
channel_id=channel_id, channel_id=channel_id,
input=request, input=request,
user_id=current_user.user_id, 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( async def update_external_channel_links(
channel_id: uuid.UUID, channel_id: uuid.UUID,
request: dto.UpdateExternalChannelLinksInput, request: dto.UpdateExternalChannelLinksInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.ExternalChannelOutput: ) -> dto.ExternalChannelOutput:
""" """
@@ -65,18 +72,21 @@ async def update_external_channel_links(
channel_id=channel_id, channel_id=channel_id,
input=request, input=request,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
@external_channels_router.delete('/{channel_id}') @external_channels_router.delete('/{channel_id}')
async def delete_external_channel( async def delete_external_channel(
channel_id: uuid.UUID, channel_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None: ) -> None:
"""Delete an external channel.""" """Delete an external channel."""
input = dto.DeleteExternalChannelInput( input = dto.DeleteExternalChannelInput(
channel_id=channel_id, channel_id=channel_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
await deps.get_usecase().delete_external_channel(input=input) await deps.get_usecase().delete_external_channel(input=input)

View File

@@ -7,11 +7,12 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload from src.adapter.jwt import JWTPayload
placements_router = APIRouter(prefix='/placements', tags=['placements']) placements_router = APIRouter(prefix='/workspaces/{workspace_id}/placements', tags=['placements'])
@placements_router.get('') @placements_router.get('')
async def list_placements( async def list_placements(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
target_channel_id: uuid.UUID | None = None, target_channel_id: uuid.UUID | None = None,
external_channel_id: uuid.UUID | None = None, external_channel_id: uuid.UUID | None = None,
@@ -20,6 +21,7 @@ async def list_placements(
) -> dto.GetPlacementsOutput: ) -> dto.GetPlacementsOutput:
input = dto.GetPlacementsInput( input = dto.GetPlacementsInput(
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
target_channel_id=target_channel_id, target_channel_id=target_channel_id,
external_channel_id=external_channel_id, external_channel_id=external_channel_id,
creative_id=creative_id, creative_id=creative_id,
@@ -32,11 +34,13 @@ async def list_placements(
@placements_router.get('/{placement_id}') @placements_router.get('/{placement_id}')
async def get_placement( async def get_placement(
placement_id: uuid.UUID, placement_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput: ) -> dto.PlacementOutput:
input = dto.GetPlacementInput( input = dto.GetPlacementInput(
placement_id=placement_id, placement_id=placement_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
return await deps.get_usecase().get_placement(input=input) return await deps.get_usecase().get_placement(input=input)
@@ -45,32 +49,37 @@ async def get_placement(
@placements_router.post('') @placements_router.post('')
async def create_placement( async def create_placement(
request: dto.CreatePlacementInput, request: dto.CreatePlacementInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput: ) -> dto.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}') @placements_router.patch('/{placement_id}')
async def update_placement( async def update_placement(
placement_id: uuid.UUID, placement_id: uuid.UUID,
request: dto.UpdatePlacementInput, request: dto.UpdatePlacementInput,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.PlacementOutput: ) -> dto.PlacementOutput:
return await deps.get_usecase().update_placement( return await deps.get_usecase().update_placement(
placement_id=placement_id, placement_id=placement_id,
input=request, input=request,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
@placements_router.delete('/{placement_id}') @placements_router.delete('/{placement_id}')
async def delete_placement( async def delete_placement(
placement_id: uuid.UUID, placement_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> None: ) -> None:
input = dto.DeletePlacementInput( input = dto.DeletePlacementInput(
placement_id=placement_id, placement_id=placement_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
) )
await deps.get_usecase().delete_placement(input) await deps.get_usecase().delete_placement(input)

View File

@@ -1,3 +1,4 @@
import uuid
from typing import Annotated from typing import Annotated
from fastapi import Depends from fastapi import Depends
@@ -6,15 +7,17 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload 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('') @target_channels_router.get('')
async def get_user_target_chans( async def get_user_target_chans(
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
) -> dto.GetUserTargetChansOutput: ) -> dto.GetUserTargetChansOutput:
input = dto.GetUserTargetChansInput( input = dto.GetUserTargetChansInput(
user_id=current_user.user_id, 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_user_target_chans(input=input)

View File

@@ -8,12 +8,13 @@ from fastapi.routing import APIRouter
from src import deps, dto from src import deps, dto
from src.adapter.jwt import JWTPayload 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') @views_router.get('/history')
async def get_views_history( async def get_views_history(
placement_id: uuid.UUID, placement_id: uuid.UUID,
workspace_id: uuid.UUID,
current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], current_user: Annotated[JWTPayload, Depends(deps.get_current_user)],
from_date: datetime.datetime | None = None, from_date: datetime.datetime | None = None,
to_date: datetime.datetime | None = None, to_date: datetime.datetime | None = None,
@@ -21,23 +22,9 @@ async def get_views_history(
input_data = dto.GetViewsHistoryInput( input_data = dto.GetViewsHistoryInput(
placement_id=placement_id, placement_id=placement_id,
user_id=current_user.user_id, user_id=current_user.user_id,
workspace_id=workspace_id,
from_date=from_date, from_date=from_date,
to_date=to_date, to_date=to_date,
) )
return await deps.get_usecase().get_views_history(input=input_data) 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)

View File

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

View File

@@ -1,5 +1,7 @@
__all__ = ( __all__ = (
'User', 'User',
'Workspace',
'WorkspaceUser',
'TargetChannel', 'TargetChannel',
'ExternalChannel', 'ExternalChannel',
'Creative', 'Creative',
@@ -17,6 +19,8 @@ __all__ = (
'PostViewsAvailability', 'PostViewsAvailability',
'LoginToken', 'LoginToken',
'UserNotFound', 'UserNotFound',
'WorkspaceNotFound',
'WorkspaceAccessDenied',
'LoginTokenNotFound', 'LoginTokenNotFound',
'LoginTokenExpired', 'LoginTokenExpired',
'LoginTokenAlreadyUsed', 'LoginTokenAlreadyUsed',
@@ -44,6 +48,8 @@ from .error import (
PlacementNotFound, PlacementNotFound,
TargetChannelNotFound, TargetChannelNotFound,
UserNotFound, UserNotFound,
WorkspaceAccessDenied,
WorkspaceNotFound,
) )
from .external_channel import ExternalChannel from .external_channel import ExternalChannel
from .login_token import LoginToken from .login_token import LoginToken
@@ -54,3 +60,4 @@ from .subscription import Subscription, SubscriptionStatus
from .target_channel import TargetChannel, TargetChannelStatus from .target_channel import TargetChannel, TargetChannelStatus
from .telegram_state import TelegramState, TelegramStateEnum from .telegram_state import TelegramState, TelegramStateEnum
from .user import User from .user import User
from .workspace import Workspace, WorkspaceUser

View File

@@ -8,7 +8,7 @@ from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .target_channel import TargetChannel from .target_channel import TargetChannel
from .user import User from .workspace import Workspace
class CreativeStatus(str, enum.Enum): class CreativeStatus(str, enum.Enum):
@@ -23,15 +23,15 @@ class Creative(TimestampedModel):
status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE) status = fields.CharEnumField(CreativeStatus, default=CreativeStatus.ACTIVE)
placements_count = fields.IntField(default=0) placements_count = fields.IntField(default=0)
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.User', related_name='creatives', on_delete=fields.CASCADE, index=True 'models.Workspace', related_name='creatives', on_delete=fields.CASCADE, index=True
) )
target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField( target_channel: fields.ForeignKeyRelation['TargetChannel'] = fields.ForeignKeyField(
'models.TargetChannel', related_name='creatives', on_delete=fields.CASCADE, index=True 'models.TargetChannel', related_name='creatives', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
user_id: UUID workspace_id: UUID
target_channel_id: UUID target_channel_id: UUID
class Meta: class Meta:

View File

@@ -64,3 +64,15 @@ def PlacementNotFound(placement_id: uuid.UUID | None = None) -> HTTPException:
if placement_id is None: if placement_id is None:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found') return HTTPException(status.HTTP_404_NOT_FOUND, 'Placement not found')
return HTTPException(status.HTTP_404_NOT_FOUND, f'Placement {placement_id} 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')

View File

@@ -7,7 +7,7 @@ from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .target_channel import TargetChannel from .target_channel import TargetChannel
from .user import User from .workspace import Workspace
class ExternalChannel(TimestampedModel): class ExternalChannel(TimestampedModel):
@@ -18,14 +18,13 @@ class ExternalChannel(TimestampedModel):
description = fields.TextField(null=True) description = fields.TextField(null=True)
subscribers_count = fields.IntField(null=True) subscribers_count = fields.IntField(null=True)
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.User', related_name='external_channels', on_delete=fields.CASCADE, index=True 'models.Workspace', related_name='external_channels', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
target_channels: fields.ManyToManyRelation['TargetChannel'] target_channels: fields.ManyToManyRelation['TargetChannel']
user_id: UUID workspace_id: UUID
class Meta: class Meta:
table = 'external_channel' table = 'external_channel'
unique_together = (('user_id', 'telegram_id'),)

View File

@@ -10,7 +10,7 @@ if TYPE_CHECKING:
from .creative import Creative from .creative import Creative
from .external_channel import ExternalChannel from .external_channel import ExternalChannel
from .target_channel import TargetChannel from .target_channel import TargetChannel
from .user import User from .workspace import Workspace
class PlacementStatus(str, enum.Enum): class PlacementStatus(str, enum.Enum):
@@ -59,15 +59,15 @@ class Placement(TimestampedModel):
creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField( creative: fields.ForeignKeyRelation['Creative'] = fields.ForeignKeyField(
'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Creative', related_name='placements', on_delete=fields.CASCADE, index=True
) )
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.User', related_name='placements', on_delete=fields.CASCADE, index=True 'models.Workspace', related_name='placements', on_delete=fields.CASCADE, index=True
) )
if TYPE_CHECKING: if TYPE_CHECKING:
target_channel_id: UUID target_channel_id: UUID
external_channel_id: UUID external_channel_id: UUID
creative_id: UUID creative_id: UUID
user_id: UUID workspace_id: UUID
class Meta: class Meta:
table = 'placement' table = 'placement'

View File

@@ -8,7 +8,7 @@ from .base import TimestampedModel
if TYPE_CHECKING: if TYPE_CHECKING:
from .external_channel import ExternalChannel from .external_channel import ExternalChannel
from .user import User from .workspace import Workspace
class TargetChannelStatus(str, enum.Enum): class TargetChannelStatus(str, enum.Enum):
@@ -24,8 +24,8 @@ class TargetChannel(TimestampedModel):
username = fields.CharField(max_length=255, null=True, index=True) username = fields.CharField(max_length=255, null=True, index=True)
status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE) status = fields.CharEnumField(TargetChannelStatus, default=TargetChannelStatus.ACTIVE)
user: fields.ForeignKeyRelation['User'] = fields.ForeignKeyField( workspace: fields.ForeignKeyRelation['Workspace'] = fields.ForeignKeyField(
'models.User', related_name='target_channels', on_delete=fields.CASCADE, index=True 'models.Workspace', related_name='target_channels', on_delete=fields.CASCADE, index=True
) )
external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField( external_channels: fields.ManyToManyRelation['ExternalChannel'] = fields.ManyToManyField(
@@ -37,7 +37,7 @@ class TargetChannel(TimestampedModel):
) )
if TYPE_CHECKING: if TYPE_CHECKING:
user_id: UUID workspace_id: UUID
class Meta: class Meta:
table = 'target_channel' table = 'target_channel'

38
src/domain/workspace.py Normal file
View File

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

View File

@@ -53,6 +53,11 @@ __all__ = (
'ParserPostFoundEvent', 'ParserPostFoundEvent',
'ParserPostDeletedEvent', 'ParserPostDeletedEvent',
'ParserViewsUpdatedEvent', 'ParserViewsUpdatedEvent',
'WorkspaceMembershipOutput',
'GetWorkspacesOutput',
'CreateWorkspaceInput',
'CreateWorkspaceOutput',
'UpdateWorkspaceInput',
) )
from .analytics import ( from .analytics import (
@@ -122,3 +127,10 @@ from .views import (
PlacementViewsHistoryOutput, PlacementViewsHistoryOutput,
UpdateViewsManuallyInput, UpdateViewsManuallyInput,
) )
from .workspace import (
CreateWorkspaceInput,
CreateWorkspaceOutput,
GetWorkspacesOutput,
UpdateWorkspaceInput,
WorkspaceMembershipOutput,
)

View File

@@ -54,6 +54,7 @@ class ExternalChannelAnalyticsOutput(pydantic.BaseModel):
class GetPlacementsAnalyticsInput(pydantic.BaseModel): class GetPlacementsAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None target_channel_id: uuid.UUID | None = None
@@ -63,6 +64,7 @@ class GetPlacementsAnalyticsOutput(pydantic.BaseModel):
class GetCreativesAnalyticsInput(pydantic.BaseModel): class GetCreativesAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None target_channel_id: uuid.UUID | None = None
@@ -72,6 +74,7 @@ class GetCreativesAnalyticsOutput(pydantic.BaseModel):
class GetExternalChannelsAnalyticsInput(pydantic.BaseModel): class GetExternalChannelsAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None target_channel_id: uuid.UUID | None = None
@@ -90,6 +93,7 @@ class SpendingDataPoint(pydantic.BaseModel):
class GetSpendingAnalyticsInput(pydantic.BaseModel): class GetSpendingAnalyticsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None target_channel_id: uuid.UUID | None = None
date_from: datetime.datetime | None = None date_from: datetime.datetime | None = None
date_to: datetime.datetime | None = None date_to: datetime.datetime | None = None

View File

@@ -19,6 +19,7 @@ class CreativeOutput(pydantic.BaseModel):
class GetCreativesInput(pydantic.BaseModel): class GetCreativesInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None target_channel_id: uuid.UUID | None = None
include_archived: bool = False include_archived: bool = False
@@ -30,6 +31,7 @@ class GetCreativesOutput(pydantic.BaseModel):
class GetCreativeInput(pydantic.BaseModel): class GetCreativeInput(pydantic.BaseModel):
creative_id: uuid.UUID creative_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
class CreateCreativeInput(pydantic.BaseModel): class CreateCreativeInput(pydantic.BaseModel):
@@ -47,3 +49,4 @@ class UpdateCreativeInput(pydantic.BaseModel):
class DeleteCreativeInput(pydantic.BaseModel): class DeleteCreativeInput(pydantic.BaseModel):
creative_id: uuid.UUID creative_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID

View File

@@ -15,6 +15,7 @@ class ExternalChannelOutput(pydantic.BaseModel):
class GetExternalChannelsInput(pydantic.BaseModel): class GetExternalChannelsInput(pydantic.BaseModel):
target_channel_id: uuid.UUID target_channel_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
class GetExternalChannelsOutput(pydantic.BaseModel): class GetExternalChannelsOutput(pydantic.BaseModel):
@@ -33,6 +34,7 @@ class CreateExternalChannelInput(pydantic.BaseModel):
class DeleteExternalChannelInput(pydantic.BaseModel): class DeleteExternalChannelInput(pydantic.BaseModel):
channel_id: uuid.UUID channel_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
class UpdateExternalChannelInput(pydantic.BaseModel): class UpdateExternalChannelInput(pydantic.BaseModel):

View File

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

View File

@@ -30,6 +30,7 @@ class PlacementOutput(pydantic.BaseModel):
class GetPlacementsInput(pydantic.BaseModel): class GetPlacementsInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
target_channel_id: uuid.UUID | None = None target_channel_id: uuid.UUID | None = None
external_channel_id: uuid.UUID | None = None external_channel_id: uuid.UUID | None = None
creative_id: uuid.UUID | None = None creative_id: uuid.UUID | None = None
@@ -43,6 +44,7 @@ class GetPlacementsOutput(pydantic.BaseModel):
class GetPlacementInput(pydantic.BaseModel): class GetPlacementInput(pydantic.BaseModel):
placement_id: uuid.UUID placement_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
class CreatePlacementInput(pydantic.BaseModel): class CreatePlacementInput(pydantic.BaseModel):
@@ -65,3 +67,4 @@ class UpdatePlacementInput(pydantic.BaseModel):
class DeletePlacementInput(pydantic.BaseModel): class DeletePlacementInput(pydantic.BaseModel):
placement_id: uuid.UUID placement_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID

View File

@@ -37,6 +37,7 @@ class ConnectTargetChanOutput(pydantic.BaseModel):
class GetUserTargetChansInput(pydantic.BaseModel): class GetUserTargetChansInput(pydantic.BaseModel):
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
class GetUserTargetChansOutput(pydantic.BaseModel): class GetUserTargetChansOutput(pydantic.BaseModel):

View File

@@ -19,6 +19,7 @@ class GetViewsHistoryInput(pydantic.BaseModel):
placement_id: uuid.UUID placement_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
from_date: datetime.datetime | None = None # Фильтр: с какой даты from_date: datetime.datetime | None = None # Фильтр: с какой даты
to_date: datetime.datetime | None = None # Фильтр: по какую дату to_date: datetime.datetime | None = None # Фильтр: по какую дату
@@ -34,4 +35,5 @@ class UpdateViewsManuallyInput(pydantic.BaseModel):
placement_id: uuid.UUID placement_id: uuid.UUID
user_id: uuid.UUID user_id: uuid.UUID
workspace_id: uuid.UUID
views_count: int views_count: int

23
src/dto/workspace.py Normal file
View File

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

View File

@@ -22,6 +22,7 @@ from .creative.get_creative import get_creative
from .creative.get_creatives import get_creatives from .creative.get_creatives import get_creatives
from .creative.handle_creative_callback import handle_creative_callback from .creative.handle_creative_callback import handle_creative_callback
from .creative.handle_creative_text_input import handle_creative_text_input 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.start_creative_creation import start_creative_creation
from .creative.update_creative import update_creative from .creative.update_creative import update_creative
from .external_channel.create_external_channel import create_external_channel 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.get_user_target_chans import get_user_target_chans
from .target_channel.update_target_chan_permissions import update_target_chan_permissions from .target_channel.update_target_chan_permissions import update_target_chan_permissions
from .views.get_views_history import get_views_history 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): class Database(typing.Protocol):
@@ -51,6 +55,24 @@ class Database(typing.Protocol):
async def create_user(self, user: domain.User) -> None: ... 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_login_token(self, login_token: domain.LoginToken) -> None: ...
async def create_creative(self, creative: domain.Creative) -> 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 mark_token_as_used(self, token: str) -> None: ...
async def get_target_channel( 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: ... ) -> domain.TargetChannel | None: ...
async def upsert_target_channel(self, channel: 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 update_target_channel(self, channel: domain.TargetChannel) -> None: ...
async def get_external_channel( 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: ... ) -> domain.ExternalChannel | None: ...
async def create_external_channel(self, channel: 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_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( async def add_external_channel_to_targets(
self, external_channel: domain.ExternalChannel, target_channel_ids: list[UUID] 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 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 update_creative(self, creative: domain.Creative) -> None: ...
async def get_user_creatives( async def get_workspace_creatives(
self, user_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False self, workspace_id: UUID, target_channel_id: UUID | None = None, include_archived: bool = False
) -> list[domain.Creative]: ... ) -> list[domain.Creative]: ...
async def delete_creative(self, creative_id: UUID) -> None: ... 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 create_placement(self, placement: domain.Placement) -> None: ...
async def update_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, self,
user_id: UUID, workspace_id: UUID,
target_channel_id: UUID | None = None, target_channel_id: UUID | None = None,
external_channel_id: UUID | None = None, external_channel_id: UUID | None = None,
creative_id: UUID | None = None, creative_id: UUID | None = None,
@@ -181,6 +207,13 @@ class Usecase:
telegram_bot: TelegramBotWriter telegram_bot: TelegramBotWriter
jwt_encoder: JWTEncoder 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 validate_login_token = validate_login_token
telegram_login = telegram_login telegram_login = telegram_login
telegram_start = telegram_start telegram_start = telegram_start
@@ -201,6 +234,7 @@ class Usecase:
delete_creative = delete_creative delete_creative = delete_creative
start_creative_creation = start_creative_creation start_creative_creation = start_creative_creation
handle_creative_callback = handle_creative_callback handle_creative_callback = handle_creative_callback
handle_creative_workspace_callback = handle_creative_workspace_callback
handle_creative_text_input = handle_creative_text_input handle_creative_text_input = handle_creative_text_input
get_placements = get_placements get_placements = get_placements
get_placement = get_placement get_placement = get_placement
@@ -210,8 +244,11 @@ class Usecase:
handle_subscription = handle_subscription handle_subscription = handle_subscription
handle_unsubscription = handle_unsubscription handle_unsubscription = handle_unsubscription
get_views_history = get_views_history get_views_history = get_views_history
update_views_manually = update_views_manually
get_placements_analytics = get_placements_analytics get_placements_analytics = get_placements_analytics
get_creatives_analytics = get_creatives_analytics get_creatives_analytics = get_creatives_analytics
get_external_channels_analytics = get_external_channels_analytics get_external_channels_analytics = get_external_channels_analytics
get_spending_analytics = get_spending_analytics get_spending_analytics = get_spending_analytics
get_workspaces = get_workspaces
create_workspace = create_workspace
update_workspace = update_workspace
delete_workspace = delete_workspace

View File

@@ -14,13 +14,19 @@ log = logging.getLogger(__name__)
async def get_creatives_analytics( async def get_creatives_analytics(
self: 'Usecase', input: dto.GetCreativesAnalyticsInput self: 'Usecase', input: dto.GetCreativesAnalyticsInput
) -> dto.GetCreativesAnalyticsOutput: ) -> dto.GetCreativesAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_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: if not target_channel:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.TargetChannelNotFound(input.target_channel_id)
creatives = await self.database.get_user_creatives(input.user_id, input.target_channel_id, include_archived=False) creatives = await self.database.get_workspace_creatives(
placements = await self.database.get_user_placements(input.user_id, input.target_channel_id, include_archived=False) 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 @dataclass
class CreativeStats: class CreativeStats:

View File

@@ -14,15 +14,19 @@ log = logging.getLogger(__name__)
async def get_external_channels_analytics( async def get_external_channels_analytics(
self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput self: 'Usecase', input: dto.GetExternalChannelsAnalyticsInput
) -> dto.GetExternalChannelsAnalyticsOutput: ) -> dto.GetExternalChannelsAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_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: if not target_channel:
raise domain.TargetChannelNotFound(input.target_channel_id) raise domain.TargetChannelNotFound(input.target_channel_id)
external_channels = await self.database.get_external_channels_for_target(input.target_channel_id) external_channels = await self.database.get_external_channels_for_target(input.target_channel_id)
else: 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 @dataclass
class ChannelStats: class ChannelStats:

View File

@@ -24,12 +24,16 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
async def get_placements_analytics( async def get_placements_analytics(
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
) -> dto.GetPlacementsAnalyticsOutput: ) -> dto.GetPlacementsAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_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: if not target_channel:
raise domain.TargetChannelNotFound(input.target_channel_id) 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 = [ result = [
dto.PlacementAnalyticsOutput( dto.PlacementAnalyticsOutput(

View File

@@ -33,12 +33,16 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str:
async def get_spending_analytics( async def get_spending_analytics(
self: 'Usecase', input: dto.GetSpendingAnalyticsInput self: 'Usecase', input: dto.GetSpendingAnalyticsInput
) -> dto.GetSpendingAnalyticsOutput: ) -> dto.GetSpendingAnalyticsOutput:
await self.ensure_workspace_access(input.workspace_id, input.user_id)
if input.target_channel_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: if not target_channel:
raise domain.TargetChannelNotFound(input.target_channel_id) 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 = [ filtered = [
p p

View File

@@ -10,8 +10,12 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID) -> dto.CreativeOutput: async def create_creative(
target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) 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: if not target_channel:
log.warning('User %s attempted to create creative for unavailable target %s', user_id, input.target_channel_id) 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) raise domain.TargetChannelNotFound(input.target_channel_id)
@@ -21,9 +25,8 @@ async def create_creative(self: 'Usecase', input: dto.CreateCreativeInput, user_
text=input.text, text=input.text,
status=domain.CreativeStatus.ACTIVE, status=domain.CreativeStatus.ACTIVE,
target_channel_id=target_channel.id, target_channel_id=target_channel.id,
user_id=user_id, workspace_id=workspace_id,
) )
await self.database.create_creative(creative) await self.database.create_creative(creative)
return dto.CreativeOutput( return dto.CreativeOutput(

View File

@@ -10,7 +10,9 @@ log = logging.getLogger(__name__)
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: 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: if not creative:
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id) log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
raise domain.CreativeNotFound(input.creative_id) raise domain.CreativeNotFound(input.creative_id)

View File

@@ -10,9 +10,10 @@ log = logging.getLogger(__name__)
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput: 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: 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) raise domain.CreativeNotFound(input.creative_id)
return dto.CreativeOutput( return dto.CreativeOutput(

View File

@@ -7,7 +7,11 @@ if TYPE_CHECKING:
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput: 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( return dto.GetCreativesOutput(
creatives=[ creatives=[

View File

@@ -11,9 +11,15 @@ log = logging.getLogger(__name__)
async def update_creative( 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: ) -> 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: if not creative:
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id) log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
raise domain.CreativeNotFound(creative_id) raise domain.CreativeNotFound(creative_id)

View File

@@ -11,17 +11,18 @@ log = logging.getLogger(__name__)
async def create_external_channel( 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: ) -> 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: if existing_channel:
raise domain.ExternalChannelAlreadyExists(input.telegram_id) raise domain.ExternalChannelAlreadyExists(input.telegram_id)
for target_id in input.target_channel_ids: 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: 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) raise domain.TargetChannelNotFound(target_id)
async with self.database.transaction(): async with self.database.transaction():
@@ -31,7 +32,7 @@ async def create_external_channel(
username=input.username, username=input.username,
description=input.description, description=input.description,
subscribers_count=input.subscribers_count, subscribers_count=input.subscribers_count,
user_id=user_id, workspace_id=workspace_id,
) )
await self.database.create_external_channel(channel) await self.database.create_external_channel(channel)

View File

@@ -10,9 +10,10 @@ log = logging.getLogger(__name__)
async def delete_external_channel(self: 'Usecase', input: dto.DeleteExternalChannelInput) -> None: 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: 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) raise domain.ExternalChannelNotFound(input.channel_id)
await self.database.delete_external_channel(input.channel_id) await self.database.delete_external_channel(input.channel_id)

View File

@@ -10,7 +10,9 @@ log = logging.getLogger(__name__)
async def get_external_channels(self: 'Usecase', input: dto.GetExternalChannelsInput) -> dto.GetExternalChannelsOutput: 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: if not target_channel:
log.warning('Target channel %s not found or not accessible for user %s', input.target_channel_id, input.user_id) 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) raise domain.TargetChannelNotFound(input.target_channel_id)

View File

@@ -11,9 +11,15 @@ log = logging.getLogger(__name__)
async def update_external_channel( 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: ) -> 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: if not external_channel:
log.warning('External channel %s not found', channel_id) log.warning('External channel %s not found', channel_id)
raise domain.ExternalChannelNotFound(channel_id) raise domain.ExternalChannelNotFound(channel_id)

View File

@@ -11,22 +11,28 @@ log = logging.getLogger(__name__)
async def update_external_channel_links( 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: ) -> 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: if not external_channel:
log.warning('External channel %s not found', channel_id) log.warning('External channel %s not found', channel_id)
raise domain.ExternalChannelNotFound(channel_id) raise domain.ExternalChannelNotFound(channel_id)
for target_id in input.add_target_channel_ids: 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: if not target_channel:
log.warning('User %s attempted to link external channel to target %s they do not own', user_id, target_id) 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) raise domain.TargetChannelNotFound(target_id)
async with self.database.transaction(): async with self.database.transaction():
if input.add_target_channel_ids: 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: for target_id in input.remove_target_channel_ids:
await self.database.remove_external_channel_from_target(channel_id, target_id) await self.database.remove_external_channel_from_target(channel_id, target_id)

View File

@@ -10,16 +10,20 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def create_placement(self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID) -> dto.PlacementOutput: async def create_placement(
target_channel = await self.database.get_target_channel(user_id, channel_id=input.target_channel_id) 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: if not target_channel:
raise domain.TargetChannelNotFound(input.target_channel_id) 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: if not external_channel:
raise domain.ExternalChannelNotFound(input.external_channel_id) 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: if not creative:
raise domain.CreativeNotFound(input.creative_id) 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, target_channel_id=input.target_channel_id,
external_channel_id=input.external_channel_id, external_channel_id=input.external_channel_id,
creative_id=input.creative_id, creative_id=input.creative_id,
user_id=user_id, workspace_id=workspace_id,
placement_date=input.placement_date, placement_date=input.placement_date,
cost=input.cost, cost=input.cost,
comment=input.comment, comment=input.comment,

View File

@@ -10,7 +10,9 @@ log = logging.getLogger(__name__)
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: 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: if not placement:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
raise domain.PlacementNotFound(input.placement_id) raise domain.PlacementNotFound(input.placement_id)
@@ -20,7 +22,7 @@ async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) ->
return return
async with self.database.transaction(): 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: if creative and creative.placements_count > 0:
creative.placements_count -= 1 creative.placements_count -= 1
await self.database.update_creative(creative) await self.database.update_creative(creative)

View File

@@ -10,7 +10,9 @@ log = logging.getLogger(__name__)
async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: 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: if not placement:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
raise domain.PlacementNotFound(input.placement_id) raise domain.PlacementNotFound(input.placement_id)

View File

@@ -7,8 +7,10 @@ if TYPE_CHECKING:
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
placements = await self.database.get_user_placements( await self.ensure_workspace_access(input.workspace_id, input.user_id)
input.user_id,
placements = await self.database.get_workspace_placements(
input.workspace_id,
target_channel_id=input.target_channel_id, target_channel_id=input.target_channel_id,
external_channel_id=input.external_channel_id, external_channel_id=input.external_channel_id,
creative_id=input.creative_id, creative_id=input.creative_id,

View File

@@ -11,9 +11,15 @@ log = logging.getLogger(__name__)
async def update_placement( 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: ) -> 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: if not placement:
log.warning('Placement %s not found for user %s', placement_id, user_id) log.warning('Placement %s not found for user %s', placement_id, user_id)
raise domain.PlacementNotFound(placement_id) raise domain.PlacementNotFound(placement_id)

View File

@@ -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') log.warning(f'User with telegram_id {input.user_telegram_id} not found when disconnecting channel')
return 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: if not channel:
log.warning(f'Target channel {input.telegram_id} not found') log.warning(f'Target channel {input.telegram_id} not found')
raise domain.TargetChannelNotFound() raise domain.TargetChannelNotFound()

View File

@@ -7,7 +7,9 @@ if TYPE_CHECKING:
async def get_user_target_chans(self: 'Usecase', input: dto.GetUserTargetChansInput) -> dto.GetUserTargetChansOutput: 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( return dto.GetUserTargetChansOutput(
target_channels=[ target_channels=[

View File

@@ -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') log.warning(f'User with telegram_id {input.user_telegram_id} not found when updating channel permissions')
return 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: if not channel:
log.warning(f'Target channel {input.telegram_id} not found when permissions changed') log.warning(f'Target channel {input.telegram_id} not found when permissions changed')
raise domain.TargetChannelNotFound() raise domain.TargetChannelNotFound()

View File

@@ -10,7 +10,9 @@ log = logging.getLogger(__name__)
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput: 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: if not placement:
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
raise domain.PlacementNotFound(input.placement_id) raise domain.PlacementNotFound(input.placement_id)

View File

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

View File

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

View File

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

View File

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

View File

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