From 4c3873c727a45b34e2924f7bc614e9cb39793554 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sun, 14 Dec 2025 13:49:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D0=B8=D0=BD=D0=B2=D0=B0=D0=B9=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...22113_init.py => 0_20251214134713_init.py} | 146 +++++++++--------- src/adapter/postgres.py | 120 ++++++++++++-- src/controller/http/__init__.py | 4 + src/controller/http/workspace_invites.py | 34 ++++ src/controller/http/workspace_members.py | 36 +++++ src/controller/telegram_callback/__init__.py | 1 + .../telegram_callback/workspace_invites.py | 27 ++++ src/domain/__init__.py | 16 ++ src/domain/error.py | 18 +++ src/domain/workspace.py | 18 ++- src/domain/workspace_permissions.py | 102 ++++++++++++ src/dto/__init__.py | 22 +++ src/dto/workspace.py | 101 ++++++++++++ src/usecase/__init__.py | 91 ++++++++++- .../analytics/get_channel_analytics.py | 26 +++- .../analytics/get_creatives_analytics.py | 18 ++- .../analytics/get_placements_analytics.py | 13 +- .../analytics/get_spending_analytics.py | 13 +- src/usecase/creative/create_creative.py | 7 +- src/usecase/creative/delete_creative.py | 6 +- src/usecase/creative/get_creative.py | 6 +- src/usecase/creative/get_creatives.py | 17 +- src/usecase/creative/update_creative.py | 6 +- src/usecase/placement/create_placement.py | 7 +- src/usecase/placement/delete_placement.py | 6 +- src/usecase/placement/get_placement.py | 6 +- src/usecase/placement/get_placements.py | 13 +- src/usecase/placement/update_placement.py | 6 +- src/usecase/project/get_workspace_projects.py | 12 +- .../attach_channel_to_purchase_plan.py | 6 +- .../get_purchase_plan_channels.py | 6 +- .../remove_purchase_plan_channel.py | 6 +- .../update_purchase_plan_channel.py | 6 +- src/usecase/views/get_views_history.py | 6 +- src/usecase/workspace/create_workspace.py | 7 +- .../workspace/create_workspace_invite.py | 76 +++++++++ src/usecase/workspace/delete_workspace.py | 4 +- .../workspace/get_workspace_invites.py | 22 +++ .../workspace/get_workspace_members.py | 22 +++ src/usecase/workspace/mappers.py | 57 +++++++ .../workspace/tg_accept_workspace_invite.py | 74 +++++++++ src/usecase/workspace/update_workspace.py | 2 + .../update_workspace_member_permissions.py | 46 ++++++ 43 files changed, 1111 insertions(+), 127 deletions(-) rename migrations/models/{0_20251214122113_init.py => 0_20251214134713_init.py} (69%) create mode 100644 src/controller/http/workspace_invites.py create mode 100644 src/controller/http/workspace_members.py create mode 100644 src/controller/telegram_callback/workspace_invites.py create mode 100644 src/domain/workspace_permissions.py create mode 100644 src/usecase/workspace/create_workspace_invite.py create mode 100644 src/usecase/workspace/get_workspace_invites.py create mode 100644 src/usecase/workspace/get_workspace_members.py create mode 100644 src/usecase/workspace/mappers.py create mode 100644 src/usecase/workspace/tg_accept_workspace_invite.py create mode 100644 src/usecase/workspace/update_workspace_member_permissions.py diff --git a/migrations/models/0_20251214122113_init.py b/migrations/models/0_20251214134713_init.py similarity index 69% rename from migrations/models/0_20251214122113_init.py rename to migrations/models/0_20251214134713_init.py index 4871ae4..355b56c 100644 --- a/migrations/models/0_20251214122113_init.py +++ b/migrations/models/0_20251214134713_init.py @@ -1,5 +1,6 @@ from tortoise import BaseDBAsyncClient +# ruff: noqa RUN_IN_TRANSACTION = True @@ -204,25 +205,25 @@ CREATE TABLE IF NOT EXISTS "workspace_user_permission" ( "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, - "permission" VARCHAR(17) NOT NULL, + "permission" VARCHAR(16) NOT NULL, "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") ); CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id"); -COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics'; +COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'PROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nADMIN_FULL: admin_full'; CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" ( "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMPTZ, "id" UUID NOT NULL PRIMARY KEY, - "permission" VARCHAR(17) NOT NULL, + "permission" VARCHAR(16) NOT NULL, "scope_type" VARCHAR(9) NOT NULL, "scope_id" UUID NOT NULL, "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, CONSTRAINT "uid_workspace_u_workspa_488ed3" UNIQUE ("workspace_user_id", "permission", "scope_type", "scope_id") ); CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id"); -COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics'; +COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'PROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nADMIN_FULL: admin_full'; COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project'; CREATE TABLE IF NOT EXISTS "aerich" ( "id" SERIAL NOT NULL PRIMARY KEY, @@ -238,72 +239,73 @@ async def downgrade(db: BaseDBAsyncClient) -> str: MODELS_STATE = ( - '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' + 'IXWrBwut7SW8G0cI/f4/Nf+ymg==' ) diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index b502fd9..8c53168 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -31,13 +31,16 @@ class Postgres(DatabaseBase): async def delete_workspace(self, workspace_id: uuid.UUID) -> None: await domain.Workspace.filter(id=workspace_id).delete() - async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: - await domain.WorkspaceUser.create( + async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser: + return await domain.WorkspaceUser.create( workspace_id=workspace_id, user_id=user_id, status=domain.WorkspaceUserStatus.ACTIVE, ) + async def update_workspace_user(self, workspace_user: domain.WorkspaceUser) -> None: + await workspace_user.save() + async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]: return ( await domain.WorkspaceUser.filter(user_id=user_id) @@ -69,10 +72,83 @@ class Postgres(DatabaseBase): 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' + return ( + await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id) + .prefetch_related('workspace', 'user', 'permissions', 'permission_scopes') + .first() ) + async def get_workspace_members(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]: + return ( + await domain.WorkspaceUser.filter(workspace_id=workspace_id) + .prefetch_related('workspace', 'user', 'permissions', 'permission_scopes') + .order_by('created_at') + .all() + ) + + async def get_workspace_member(self, workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None: + return ( + await domain.WorkspaceUser.filter(id=workspace_user_id) + .prefetch_related('workspace', 'user', 'permissions', 'permission_scopes') + .first() + ) + + async def create_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: + await invite.save() + + async def update_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: + await invite.save() + + async def get_workspace_invite(self, invite_id: uuid.UUID) -> domain.WorkspaceInvite | None: + return ( + await domain.WorkspaceInvite.filter(id=invite_id) + .prefetch_related('workspace', 'user', 'invited_by') + .first() + ) + + async def get_workspace_invite_by_user( + self, workspace_id: uuid.UUID, user_id: uuid.UUID + ) -> domain.WorkspaceInvite | None: + return await domain.WorkspaceInvite.get_or_none(workspace_id=workspace_id, user_id=user_id) + + async def get_workspace_invites(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]: + return ( + await domain.WorkspaceInvite.filter(workspace_id=workspace_id) + .prefetch_related('user', 'invited_by') + .order_by('created_at') + .all() + ) + + async def set_workspace_user_permissions( + self, + workspace_user_id: uuid.UUID, + global_permissions: set[domain.PermissionKey], + scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]], + ) -> None: + await domain.WorkspaceUserPermission.filter(workspace_user_id=workspace_user_id).delete() + await domain.WorkspaceUserPermissionScope.filter(workspace_user_id=workspace_user_id).delete() + + if global_permissions: + await domain.WorkspaceUserPermission.bulk_create( + [ + domain.WorkspaceUserPermission(workspace_user_id=workspace_user_id, permission=permission) + for permission in global_permissions + ] + ) + + if scoped_permissions: + await domain.WorkspaceUserPermissionScope.bulk_create( + [ + domain.WorkspaceUserPermissionScope( + workspace_user_id=workspace_user_id, + permission=permission, + scope_type=scope_type, + scope_id=scope_id, + ) + for permission, scope_type, scope_id in scoped_permissions + ] + ) + async def create_login_token(self, login_token: domain.LoginToken) -> None: await login_token.save() @@ -87,6 +163,9 @@ class Postgres(DatabaseBase): raise ValueError('Either user_id or telegram_id must be provided') + async def get_user_by_username(self, username: str) -> domain.User | None: + return await domain.User.get_or_none(username__iexact=username) + async def get_login_token(self, token: str) -> domain.LoginToken | None: return await domain.LoginToken.get_or_none(token=token) @@ -163,13 +242,17 @@ class Postgres(DatabaseBase): async def update_project(self, project: domain.Project) -> None: await project.save() - async def get_workspace_projects(self, workspace_id: uuid.UUID) -> list[domain.Project]: - return ( - await domain.Project.filter(workspace_id=workspace_id) - .prefetch_related('channel') - .order_by('created_at') - .all() - ) + async def get_workspace_projects( + self, workspace_id: uuid.UUID, allowed_project_ids: set[uuid.UUID] | None = None + ) -> list[domain.Project]: + query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel') + + if allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(id__in=list(allowed_project_ids)) + + return await query.order_by('created_at').all() async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None: return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE) @@ -237,12 +320,20 @@ class Postgres(DatabaseBase): await creative.save() async def get_workspace_creatives( - self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, include_archived: bool = False + self, + workspace_id: uuid.UUID, + project_id: uuid.UUID | None = None, + include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, ) -> list[domain.Creative]: query = domain.Creative.filter(workspace_id=workspace_id) if project_id: query = query.filter(project_id=project_id) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(project_id__in=list(allowed_project_ids)) if not include_archived: query = query.filter(status=domain.CreativeStatus.ACTIVE) @@ -270,11 +361,16 @@ class Postgres(DatabaseBase): placement_channel_id: uuid.UUID | None = None, creative_id: uuid.UUID | None = None, include_archived: bool = False, + allowed_project_ids: set[uuid.UUID] | None = None, ) -> list[domain.Placement]: query = domain.Placement.filter(workspace_id=workspace_id) if project_id: query = query.filter(project_id=project_id) + elif allowed_project_ids is not None: + if not allowed_project_ids: + return [] + query = query.filter(project_id__in=list(allowed_project_ids)) if placement_channel_id: query = query.filter(placement_channel_id=placement_channel_id) if creative_id: diff --git a/src/controller/http/__init__.py b/src/controller/http/__init__.py index a8f0a10..e6bb53e 100644 --- a/src/controller/http/__init__.py +++ b/src/controller/http/__init__.py @@ -8,6 +8,8 @@ from src.controller.http.placements import placements_router from src.controller.http.projects import projects_router from src.controller.http.purchase_plans import purchase_plan_channels_router from src.controller.http.views import views_router +from src.controller.http.workspace_invites import workspace_invites_router +from src.controller.http.workspace_members import workspace_members_router from src.controller.http.workspaces import workspaces_router api_router = APIRouter() @@ -23,5 +25,7 @@ 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_v1_router.include_router(workspace_members_router) +api_v1_router.include_router(workspace_invites_router) api_router.include_router(api_v1_router) diff --git a/src/controller/http/workspace_invites.py b/src/controller/http/workspace_invites.py new file mode 100644 index 0000000..7c48aee --- /dev/null +++ b/src/controller/http/workspace_invites.py @@ -0,0 +1,34 @@ +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 + +workspace_invites_router = APIRouter(prefix='/workspaces/{workspace_id}/invites', tags=['workspace invites']) + + +@workspace_invites_router.get('') +async def list_workspace_invites( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.GetWorkspaceInvitesOutput: + return await deps.get_usecase().get_workspace_invites( + workspace_id=workspace_id, + user_id=current_user.user_id, + ) + + +@workspace_invites_router.post('') +async def create_workspace_invite( + workspace_id: uuid.UUID, + request: dto.CreateWorkspaceInviteInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceInviteOutput: + return await deps.get_usecase().create_workspace_invite( + workspace_id=workspace_id, + user_id=current_user.user_id, + input=request, + ) diff --git a/src/controller/http/workspace_members.py b/src/controller/http/workspace_members.py new file mode 100644 index 0000000..24dc293 --- /dev/null +++ b/src/controller/http/workspace_members.py @@ -0,0 +1,36 @@ +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 + +workspace_members_router = APIRouter(prefix='/workspaces/{workspace_id}/members', tags=['workspace members']) + + +@workspace_members_router.get('') +async def list_workspace_members( + workspace_id: uuid.UUID, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.GetWorkspaceMembersOutput: + return await deps.get_usecase().get_workspace_members( + workspace_id=workspace_id, + user_id=current_user.user_id, + ) + + +@workspace_members_router.put('/{workspace_user_id}/permissions') +async def put_workspace_member_permissions( + workspace_id: uuid.UUID, + workspace_user_id: uuid.UUID, + request: dto.UpdateWorkspaceMemberPermissionsInput, + current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], +) -> dto.WorkspaceMemberOutput: + return await deps.get_usecase().update_workspace_member_permissions( + workspace_id=workspace_id, + workspace_user_id=workspace_user_id, + user_id=current_user.user_id, + input=request, + ) diff --git a/src/controller/telegram_callback/__init__.py b/src/controller/telegram_callback/__init__.py index 8facd79..16d66e7 100644 --- a/src/controller/telegram_callback/__init__.py +++ b/src/controller/telegram_callback/__init__.py @@ -10,4 +10,5 @@ from . import ( # noqa: E402, F401 my_chat_member, project_commands, start_with_login, + workspace_invites, ) diff --git a/src/controller/telegram_callback/workspace_invites.py b/src/controller/telegram_callback/workspace_invites.py new file mode 100644 index 0000000..ca12b61 --- /dev/null +++ b/src/controller/telegram_callback/workspace_invites.py @@ -0,0 +1,27 @@ +import logging + +from aiogram import F +from aiogram.types import CallbackQuery + +from src import deps +from src.controller.telegram_callback import telegram_callback_router +from src.usecase.workspace.create_workspace_invite import INVITE_ACCEPT_CALLBACK_PREFIX + +log = logging.getLogger(__name__) + + +@telegram_callback_router.callback_query(F.data.startswith(f'{INVITE_ACCEPT_CALLBACK_PREFIX}:')) +async def callback_workspace_invite_accept(callback: CallbackQuery) -> None: + if not callback.from_user or not callback.message or not callback.data: + log.error('Incomplete callback data for workspace invite acceptance') + return + + usecase = deps.get_usecase() + await usecase.tg_accept_workspace_invite( + telegram_id=callback.from_user.id, + chat_id=callback.message.chat.id, + callback_data=callback.data, + message_id=callback.message.message_id, + ) + + await callback.answer() diff --git a/src/domain/__init__.py b/src/domain/__init__.py index bb96319..c0b7b1c 100644 --- a/src/domain/__init__.py +++ b/src/domain/__init__.py @@ -7,6 +7,9 @@ __all__ = ( 'WorkspaceUserStatus', 'WorkspaceUserPermission', 'WorkspaceUserPermissionScope', + 'WorkspacePermissions', + 'WorkspacePermissionContext', + 'build_workspace_permission_context', 'PermissionKey', 'PermissionScopeType', 'Channel', @@ -34,6 +37,9 @@ __all__ = ( 'UserNotFound', 'WorkspaceNotFound', 'WorkspaceAccessDenied', + 'WorkspaceInviteNotFound', + 'WorkspaceInviteAlreadyExists', + 'WorkspaceMemberAlreadyExists', 'LoginTokenNotFound', 'LoginTokenExpired', 'LoginTokenAlreadyUsed', @@ -46,6 +52,7 @@ __all__ = ( 'CreativeNotFound', 'CreativeInUse', 'PlacementNotFound', + 'UserByUsernameNotFound', ) from .channel import Channel, ChannelVerificationStatus @@ -63,8 +70,12 @@ from .error import ( ProjectNotFound, PurchasePlanChannelNotFound, PurchasePlanNotFound, + UserByUsernameNotFound, UserNotFound, WorkspaceAccessDenied, + WorkspaceInviteAlreadyExists, + WorkspaceInviteNotFound, + WorkspaceMemberAlreadyExists, WorkspaceNotFound, ) from .login_token import LoginToken @@ -91,3 +102,8 @@ from .workspace import ( WorkspaceUserPermissionScope, WorkspaceUserStatus, ) +from .workspace_permissions import ( + WorkspacePermissionContext, + WorkspacePermissions, + build_workspace_permission_context, +) diff --git a/src/domain/error.py b/src/domain/error.py index 393ae60..9d0b1f1 100644 --- a/src/domain/error.py +++ b/src/domain/error.py @@ -9,6 +9,10 @@ def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException: return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found') +def UserByUsernameNotFound(username: str) -> HTTPException: + return HTTPException(status.HTTP_404_NOT_FOUND, f'User @{username} not found') + + def LoginTokenNotFound() -> HTTPException: return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found') @@ -84,3 +88,17 @@ def WorkspaceAccessDenied(workspace_id: uuid.UUID | None = None) -> HTTPExceptio 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') + + +def WorkspaceInviteNotFound(invite_id: uuid.UUID | None = None) -> HTTPException: + if invite_id is None: + return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace invite not found') + return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace invite {invite_id} not found') + + +def WorkspaceInviteAlreadyExists() -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user') + + +def WorkspaceMemberAlreadyExists() -> HTTPException: + return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member') diff --git a/src/domain/workspace.py b/src/domain/workspace.py index aee2034..549467d 100644 --- a/src/domain/workspace.py +++ b/src/domain/workspace.py @@ -66,18 +66,26 @@ class WorkspaceInvite(TimestampedModel): 'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True ) + if TYPE_CHECKING: + workspace_id: uuid.UUID + invited_by_id: uuid.UUID + user_id: uuid.UUID + class Meta: table = 'workspace_invite' unique_together = (('workspace_id', 'user_id'),) -class PermissionKey(str, enum.Enum): - MANAGE_PROJECTS = 'manage_projects' - MANAGE_PLACEMENTS = 'manage_placements' - VIEW_ANALYTICS = 'view_analytics' +class PermissionKey(enum.StrEnum): + PROJECTS_READ = 'projects_read' + PROJECTS_WRITE = 'projects_write' + PLACEMENTS_READ = 'placements_read' + PLACEMENTS_WRITE = 'placements_write' + ANALYTICS_READ = 'analytics_read' + ADMIN_FULL = 'admin_full' -class PermissionScopeType(str, enum.Enum): +class PermissionScopeType(enum.StrEnum): WORKSPACE = 'workspace' PROJECT = 'project' diff --git a/src/domain/workspace_permissions.py b/src/domain/workspace_permissions.py new file mode 100644 index 0000000..9d9211b --- /dev/null +++ b/src/domain/workspace_permissions.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from . import WorkspaceAccessDenied +from .workspace import ( + PermissionKey, + PermissionScopeType, + Workspace, + WorkspaceUser, +) + + +@dataclass +class WorkspacePermissions: + global_permissions: set[PermissionKey] + scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]] + + @classmethod + def from_membership(cls, membership: WorkspaceUser) -> WorkspacePermissions: + global_permissions = { + permission.permission + for permission in getattr(membership, 'permissions', []) or [] + } + + scoped_permissions: dict[tuple[PermissionKey, PermissionScopeType], set[UUID]] = {} + for scope in getattr(membership, 'permission_scopes', []) or []: + key = (scope.permission, scope.scope_type) + scoped_permissions.setdefault(key, set()).add(scope.scope_id) + + return cls(global_permissions=global_permissions, scoped_permissions=scoped_permissions) + + def has_global(self, permission: PermissionKey) -> bool: + return permission in self.global_permissions or PermissionKey.ADMIN_FULL in self.global_permissions + + def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None: + if self.has_global(permission): + return None + + allowed: set[UUID] = set() + for key in (permission, PermissionKey.ADMIN_FULL): + project_scope = self.scoped_permissions.get((key, PermissionScopeType.PROJECT)) + if project_scope: + allowed.update(project_scope) + + return allowed + + def has_permission( + self, + permission: PermissionKey, + *, + scope_type: PermissionScopeType | None = None, + scope_id: UUID | None = None, + ) -> bool: + if self.has_global(permission): + return True + + if scope_type == PermissionScopeType.PROJECT and scope_id is not None: + allowed = self.allowed_project_ids(permission) + if allowed is None: + return True + return scope_id in allowed + + return False + + def has_any(self, permission: PermissionKey) -> bool: + allowed = self.allowed_project_ids(permission) + if allowed is None: + return True + return bool(allowed) + + +@dataclass +class WorkspacePermissionContext: + workspace: Workspace + membership: WorkspaceUser + permissions: WorkspacePermissions + + def allowed_project_ids(self, permission: PermissionKey) -> set[UUID] | None: + return self.permissions.allowed_project_ids(permission) + + def ensure_project_permission(self, permission: PermissionKey, project_id: UUID) -> None: + if not self.permissions.has_permission( + permission, + scope_type=PermissionScopeType.PROJECT, + scope_id=project_id, + ): + raise WorkspaceAccessDenied(self.workspace.id) + + +def build_workspace_permission_context(membership: WorkspaceUser) -> WorkspacePermissionContext: + if membership.workspace is None: + raise ValueError('Workspace relation must be loaded for membership permissions') + + permissions = WorkspacePermissions.from_membership(membership) + + return WorkspacePermissionContext( + workspace=membership.workspace, + membership=membership, + permissions=permissions, + ) diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 0795691..98b193e 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -54,6 +54,17 @@ __all__ = ( 'CreateWorkspaceInput', 'CreateWorkspaceOutput', 'UpdateWorkspaceInput', + 'WorkspaceMemberOutput', + 'WorkspaceMemberUserOutput', + 'WorkspacePermissionOutput', + 'WorkspacePermissionScopeOutput', + 'GetWorkspaceMembersOutput', + 'WorkspacePermissionInput', + 'WorkspacePermissionScopeInput', + 'UpdateWorkspaceMemberPermissionsInput', + 'CreateWorkspaceInviteInput', + 'WorkspaceInviteOutput', + 'GetWorkspaceInvitesOutput', ) from .analytics import ( @@ -117,8 +128,19 @@ from .views import ( ) from .workspace import ( CreateWorkspaceInput, + CreateWorkspaceInviteInput, CreateWorkspaceOutput, + GetWorkspaceInvitesOutput, + GetWorkspaceMembersOutput, GetWorkspacesOutput, UpdateWorkspaceInput, + UpdateWorkspaceMemberPermissionsInput, + WorkspaceInviteOutput, + WorkspaceMemberOutput, WorkspaceMembershipOutput, + WorkspaceMemberUserOutput, + WorkspacePermissionInput, + WorkspacePermissionOutput, + WorkspacePermissionScopeInput, + WorkspacePermissionScopeOutput, ) diff --git a/src/dto/workspace.py b/src/dto/workspace.py index a5a094a..50d93bd 100644 --- a/src/dto/workspace.py +++ b/src/dto/workspace.py @@ -2,6 +2,8 @@ import uuid import pydantic +from src import domain + class WorkspaceMembershipOutput(pydantic.BaseModel): id: uuid.UUID @@ -21,3 +23,102 @@ class CreateWorkspaceInput(pydantic.BaseModel): class UpdateWorkspaceInput(pydantic.BaseModel): name: str | None = None + + +class WorkspaceMemberUserOutput(pydantic.BaseModel): + id: uuid.UUID + telegram_id: int + username: str | None + + +class WorkspacePermissionScopeOutput(pydantic.BaseModel): + type: domain.PermissionScopeType + id: uuid.UUID + + +class WorkspacePermissionOutput(pydantic.BaseModel): + key: domain.PermissionKey + scopes: list[WorkspacePermissionScopeOutput] + + +class WorkspaceMemberOutput(pydantic.BaseModel): + id: uuid.UUID + status: domain.WorkspaceUserStatus + user: WorkspaceMemberUserOutput + permissions: list[WorkspacePermissionOutput] + + +class GetWorkspaceMembersOutput(pydantic.BaseModel): + members: list[WorkspaceMemberOutput] + + +class WorkspacePermissionScopeInput(pydantic.BaseModel): + type: domain.PermissionScopeType = pydantic.Field( + description='Тип области действия. Сейчас используется только "project".' + ) + id: uuid.UUID = pydantic.Field(description='Идентификатор сущности в указанной области (например, project_id).') + + +class WorkspacePermissionInput(pydantic.BaseModel): + key: domain.PermissionKey = pydantic.Field( + description=( + 'Ключ права. Доступные значения: "projects_read", "projects_write", ' + '"placements_read", "placements_write", "analytics_read", "admin_full".' + ) + ) + scopes: list[WorkspacePermissionScopeInput] = pydantic.Field( + default_factory=list, + description='Ограничения по областям. Пустой список означает глобальное право.', + ) + + +class UpdateWorkspaceMemberPermissionsInput(pydantic.BaseModel): + permissions: list[WorkspacePermissionInput] = pydantic.Field( + default_factory=list, + description='Список прав, который полностью заменит текущий набор участника.', + ) + + model_config = pydantic.ConfigDict( + json_schema_extra={ + 'examples': [ + { + 'permissions': [ + {'key': 'admin_full'}, + { + 'key': 'projects_write', + 'scopes': [ + {'type': 'project', 'id': '11111111-1111-1111-1111-111111111111'}, + {'type': 'project', 'id': '22222222-2222-2222-2222-222222222222'}, + ], + }, + ] + } + ] + } + ) + + +class CreateWorkspaceInviteInput(pydantic.BaseModel): + username: str = pydantic.Field(min_length=1) + + @pydantic.field_validator('username') + @classmethod + def normalize_username(cls, value: str) -> str: + username = value.strip() + if username.startswith('@'): + username = username[1:] + username = username.strip() + if not username: + raise ValueError('Username must not be empty') + return username + + +class WorkspaceInviteOutput(pydantic.BaseModel): + id: uuid.UUID + status: domain.WorkspaceInviteStatus + user: WorkspaceMemberUserOutput + invited_by: WorkspaceMemberUserOutput + + +class GetWorkspaceInvitesOutput(pydantic.BaseModel): + invites: list[WorkspaceInviteOutput] diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index dc3e073..7665b6e 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -44,15 +44,26 @@ from .subscription.handle_subscription import handle_subscription from .subscription.handle_unsubscription import handle_unsubscription from .views.get_views_history import get_views_history from .workspace.create_workspace import create_workspace +from .workspace.create_workspace_invite import create_workspace_invite from .workspace.delete_workspace import delete_workspace +from .workspace.get_workspace_invites import get_workspace_invites +from .workspace.get_workspace_members import get_workspace_members from .workspace.get_workspaces import get_workspaces +from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite from .workspace.update_workspace import update_workspace +from .workspace.update_workspace_member_permissions import update_workspace_member_permissions class Database(typing.Protocol): def transaction(self) -> typing.AsyncContextManager[None]: ... - async def get_user(self, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ... + async def get_user( + self, + user_id: UUID | None = None, + telegram_id: int | None = None, + ) -> domain.User | None: ... + + async def get_user_by_username(self, username: str) -> domain.User | None: ... async def create_user(self, user: domain.User) -> None: ... @@ -62,7 +73,16 @@ class Database(typing.Protocol): 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 add_user_to_workspace(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser: ... + + async def set_workspace_user_permissions( + self, + workspace_user_id: UUID, + global_permissions: set[domain.PermissionKey], + scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, UUID]], + ) -> None: ... + + async def update_workspace_user(self, workspace_user: domain.WorkspaceUser) -> None: ... async def get_user_workspaces(self, user_id: UUID) -> list[domain.WorkspaceUser]: ... @@ -74,6 +94,22 @@ class Database(typing.Protocol): async def get_workspace_membership(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser | None: ... + async def get_workspace_members(self, workspace_id: UUID) -> list[domain.WorkspaceUser]: ... + + async def get_workspace_member(self, workspace_user_id: UUID) -> domain.WorkspaceUser | None: ... + + async def create_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: ... + + async def update_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: ... + + async def get_workspace_invite(self, invite_id: UUID) -> domain.WorkspaceInvite | None: ... + + async def get_workspace_invite_by_user( + self, workspace_id: UUID, user_id: UUID + ) -> domain.WorkspaceInvite | None: ... + + async def get_workspace_invites(self, workspace_id: UUID) -> list[domain.WorkspaceInvite]: ... + async def create_login_token(self, login_token: domain.LoginToken) -> None: ... async def create_creative(self, creative: domain.Creative) -> None: ... @@ -116,7 +152,9 @@ class Database(typing.Protocol): async def update_project(self, project: domain.Project) -> None: ... - async def get_workspace_projects(self, workspace_id: UUID) -> list[domain.Project]: ... + async def get_workspace_projects( + self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None + ) -> list[domain.Project]: ... async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ... @@ -149,7 +187,11 @@ class Database(typing.Protocol): async def update_creative(self, creative: domain.Creative) -> None: ... async def get_workspace_creatives( - self, workspace_id: UUID, project_id: UUID | None = None, include_archived: bool = False + self, + workspace_id: UUID, + project_id: UUID | None = None, + include_archived: bool = False, + allowed_project_ids: set[UUID] | None = None, ) -> list[domain.Creative]: ... async def delete_creative(self, creative_id: UUID) -> None: ... @@ -167,6 +209,7 @@ class Database(typing.Protocol): placement_channel_id: UUID | None = None, creative_id: UUID | None = None, include_archived: bool = False, + allowed_project_ids: set[UUID] | None = None, ) -> list[domain.Placement]: ... async def delete_placement(self, placement_id: UUID) -> None: ... @@ -239,6 +282,34 @@ class Usecase: return workspace + async def ensure_workspace_permission( + self, + workspace_id: UUID, + user_id: UUID, + permission: domain.PermissionKey, + *, + for_project_id: UUID | None = None, + ) -> domain.WorkspacePermissionContext: + membership = await self.database.get_workspace_membership(workspace_id, user_id) + if not membership or not membership.workspace: + raise domain.WorkspaceNotFound(workspace_id) + + permissions = domain.WorkspacePermissions.from_membership(membership) + + if for_project_id is not None: + has_permission = permissions.has_permission( + permission, + scope_type=domain.PermissionScopeType.PROJECT, + scope_id=for_project_id, + ) + else: + has_permission = permissions.has_any(permission) + + if not has_permission: + raise domain.WorkspaceAccessDenied(workspace_id) + + return domain.build_workspace_permission_context(membership) + async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace: workspace = await self.database.get_default_workspace_for_user(user.id) if workspace: @@ -249,7 +320,12 @@ class Usecase: async with self.database.transaction(): await self.database.create_workspace(workspace) - await self.database.add_user_to_workspace(workspace.id, user.id) + membership = await self.database.add_user_to_workspace(workspace.id, user.id) + await self.database.set_workspace_user_permissions( + membership.id, + global_permissions={domain.PermissionKey.ADMIN_FULL}, + scoped_permissions=[], + ) return workspace @@ -301,3 +377,8 @@ class Usecase: create_workspace = create_workspace update_workspace = update_workspace delete_workspace = delete_workspace + get_workspace_members = get_workspace_members + update_workspace_member_permissions = update_workspace_member_permissions + create_workspace_invite = create_workspace_invite + get_workspace_invites = get_workspace_invites + tg_accept_workspace_invite = tg_accept_workspace_invite diff --git a/src/usecase/analytics/get_channel_analytics.py b/src/usecase/analytics/get_channel_analytics.py index 81f2407..9ab8c2b 100644 --- a/src/usecase/analytics/get_channel_analytics.py +++ b/src/usecase/analytics/get_channel_analytics.py @@ -12,22 +12,42 @@ log = logging.getLogger(__name__) async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) if input.project_id: project = await self.database.get_project(input.workspace_id, input.project_id) if not project: raise domain.ProjectNotFound(input.project_id) + context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) plan = await self.ensure_active_purchase_plan(project) plan_channels = await self.database.get_purchase_plan_channels(plan.id) channels = [pc.channel for pc in plan_channels] + allowed_project_filter = None else: - channels = await self.database.get_workspace_channels(input.workspace_id) + allowed_project_filter = allowed_project_ids + if allowed_project_ids is None: + channels = await self.database.get_workspace_channels(input.workspace_id) + else: + channels = [] placements = await self.database.get_workspace_placements( - input.workspace_id, input.project_id, include_archived=False + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_filter, ) + if input.project_id is None and allowed_project_ids is not None: + channel_map: dict[UUID, domain.Channel] = {} + for placement in placements: + if placement.placement_channel: + channel_map[placement.placement_channel_id] = placement.placement_channel + channels = list(channel_map.values()) + @dataclass class ChannelStats: total_cost: float = 0.0 diff --git a/src/usecase/analytics/get_creatives_analytics.py b/src/usecase/analytics/get_creatives_analytics.py index 50942bb..68681fd 100644 --- a/src/usecase/analytics/get_creatives_analytics.py +++ b/src/usecase/analytics/get_creatives_analytics.py @@ -14,18 +14,30 @@ 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) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) if input.project_id: project = await self.database.get_project(input.workspace_id, input.project_id) if not project: raise domain.ProjectNotFound(input.project_id) + context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) + allowed_project_ids = None creatives = await self.database.get_workspace_creatives( - input.workspace_id, input.project_id, include_archived=False + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, ) placements = await self.database.get_workspace_placements( - input.workspace_id, input.project_id, include_archived=False + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, ) @dataclass diff --git a/src/usecase/analytics/get_placements_analytics.py b/src/usecase/analytics/get_placements_analytics.py index 0343d38..e1706f4 100644 --- a/src/usecase/analytics/get_placements_analytics.py +++ b/src/usecase/analytics/get_placements_analytics.py @@ -24,15 +24,24 @@ 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) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) if input.project_id: project = await self.database.get_project(input.workspace_id, input.project_id) if not project: raise domain.ProjectNotFound(input.project_id) + context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) + allowed_project_ids = None placements = await self.database.get_workspace_placements( - input.workspace_id, input.project_id, include_archived=False + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, ) result = [ diff --git a/src/usecase/analytics/get_spending_analytics.py b/src/usecase/analytics/get_spending_analytics.py index a2c421b..bd6ae8e 100644 --- a/src/usecase/analytics/get_spending_analytics.py +++ b/src/usecase/analytics/get_spending_analytics.py @@ -33,15 +33,24 @@ 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) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ) if input.project_id: project = await self.database.get_project(input.workspace_id, input.project_id) if not project: raise domain.ProjectNotFound(input.project_id) + context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id) + allowed_project_ids = None placements = await self.database.get_workspace_placements( - input.workspace_id, input.project_id, include_archived=False + input.workspace_id, + input.project_id, + include_archived=False, + allowed_project_ids=allowed_project_ids, ) filtered = [ diff --git a/src/usecase/creative/create_creative.py b/src/usecase/creative/create_creative.py index c39cf9c..4028b27 100644 --- a/src/usecase/creative/create_creative.py +++ b/src/usecase/creative/create_creative.py @@ -13,7 +13,12 @@ log = logging.getLogger(__name__) 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) + await self.ensure_workspace_permission( + workspace_id, + user_id, + domain.PermissionKey.PROJECTS_WRITE, + for_project_id=input.project_id, + ) project = await self.database.get_project(workspace_id, project_id=input.project_id) if not project: diff --git a/src/usecase/creative/delete_creative.py b/src/usecase/creative/delete_creative.py index b50914e..cfb326e 100644 --- a/src/usecase/creative/delete_creative.py +++ b/src/usecase/creative/delete_creative.py @@ -10,13 +10,17 @@ log = logging.getLogger(__name__) async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE + ) 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) + context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id) + if creative.placements_count > 0: log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id) raise domain.CreativeInUse(input.creative_id) diff --git a/src/usecase/creative/get_creative.py b/src/usecase/creative/get_creative.py index 282428a..5a77c7a 100644 --- a/src/usecase/creative/get_creative.py +++ b/src/usecase/creative/get_creative.py @@ -10,12 +10,16 @@ log = logging.getLogger(__name__) async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ + ) creative = await self.database.get_creative(input.workspace_id, input.creative_id) if not creative: raise domain.CreativeNotFound(input.creative_id) + context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id) + return dto.CreativeOutput( id=creative.id, name=creative.name, diff --git a/src/usecase/creative/get_creatives.py b/src/usecase/creative/get_creatives.py index afcc161..db9cbf9 100644 --- a/src/usecase/creative/get_creatives.py +++ b/src/usecase/creative/get_creatives.py @@ -1,16 +1,27 @@ from typing import TYPE_CHECKING -from src import dto +from src import domain, dto if TYPE_CHECKING: from .. import Usecase async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ) + + if input.project_id is not None: + context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, input.project_id) + allowed_project_ids = None creatives = await self.database.get_workspace_creatives( - input.workspace_id, input.project_id, input.include_archived + input.workspace_id, + input.project_id, + input.include_archived, + allowed_project_ids=allowed_project_ids, ) return dto.GetCreativesOutput( diff --git a/src/usecase/creative/update_creative.py b/src/usecase/creative/update_creative.py index 19c809d..6af83eb 100644 --- a/src/usecase/creative/update_creative.py +++ b/src/usecase/creative/update_creative.py @@ -17,13 +17,17 @@ async def update_creative( user_id: uuid.UUID, workspace_id: uuid.UUID, ) -> dto.CreativeOutput: - await self.ensure_workspace_access(workspace_id, user_id) + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE + ) 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) + context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id) + if input.name: creative.name = input.name if input.text: diff --git a/src/usecase/placement/create_placement.py b/src/usecase/placement/create_placement.py index 491a71a..26fad5f 100644 --- a/src/usecase/placement/create_placement.py +++ b/src/usecase/placement/create_placement.py @@ -13,7 +13,12 @@ log = logging.getLogger(__name__) async def create_placement( self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID ) -> dto.PlacementOutput: - await self.ensure_workspace_access(workspace_id, user_id) + await self.ensure_workspace_permission( + workspace_id, + user_id, + domain.PermissionKey.PLACEMENTS_WRITE, + for_project_id=input.project_id, + ) project = await self.database.get_project(workspace_id, project_id=input.project_id) if not project: diff --git a/src/usecase/placement/delete_placement.py b/src/usecase/placement/delete_placement.py index 213ace0..0bb1bfa 100644 --- a/src/usecase/placement/delete_placement.py +++ b/src/usecase/placement/delete_placement.py @@ -10,13 +10,17 @@ log = logging.getLogger(__name__) async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) placement = await self.database.get_placement(input.workspace_id, input.placement_id) if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id) + if placement.status != domain.PlacementStatus.ACTIVE: await self.database.delete_placement(input.placement_id) return diff --git a/src/usecase/placement/get_placement.py b/src/usecase/placement/get_placement.py index 24db7fc..ffe33b0 100644 --- a/src/usecase/placement/get_placement.py +++ b/src/usecase/placement/get_placement.py @@ -10,13 +10,17 @@ log = logging.getLogger(__name__) async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) placement = await self.database.get_placement(input.workspace_id, input.placement_id) if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + return dto.PlacementOutput( id=placement.id, project_id=placement.project_id, diff --git a/src/usecase/placement/get_placements.py b/src/usecase/placement/get_placements.py index 48b38bb..d2e8c84 100644 --- a/src/usecase/placement/get_placements.py +++ b/src/usecase/placement/get_placements.py @@ -1,13 +1,21 @@ from typing import TYPE_CHECKING -from src import dto +from src import domain, dto if TYPE_CHECKING: from .. import Usecase async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) + + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ) + + if input.project_id is not None: + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id) + allowed_project_ids = None placements = await self.database.get_workspace_placements( input.workspace_id, @@ -15,6 +23,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. placement_channel_id=input.placement_channel_id, creative_id=input.creative_id, include_archived=input.include_archived, + allowed_project_ids=allowed_project_ids, ) return dto.GetPlacementsOutput( diff --git a/src/usecase/placement/update_placement.py b/src/usecase/placement/update_placement.py index 0a8b41e..e1386b7 100644 --- a/src/usecase/placement/update_placement.py +++ b/src/usecase/placement/update_placement.py @@ -17,13 +17,17 @@ async def update_placement( user_id: uuid.UUID, workspace_id: uuid.UUID, ) -> dto.PlacementOutput: - await self.ensure_workspace_access(workspace_id, user_id) + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) placement = await self.database.get_placement(workspace_id, placement_id) if not placement: log.warning('Placement %s not found for user %s', placement_id, user_id) raise domain.PlacementNotFound(placement_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id) + if input.placement_date is not None: placement.placement_date = input.placement_date if input.cost is not None: diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py index 2d0abc8..1872db8 100644 --- a/src/usecase/project/get_workspace_projects.py +++ b/src/usecase/project/get_workspace_projects.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from src import dto +from src import domain, dto if TYPE_CHECKING: from .. import Usecase @@ -9,9 +9,15 @@ if TYPE_CHECKING: async def get_workspace_projects( self: 'Usecase', input: dto.GetWorkspaceProjectsInput ) -> dto.GetWorkspaceProjectsOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ + ) - projects = await self.database.get_workspace_projects(input.workspace_id) + allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ) + + projects = await self.database.get_workspace_projects( + input.workspace_id, allowed_project_ids=allowed_project_ids + ) return dto.GetWorkspaceProjectsOutput( projects=[ diff --git a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py index bee4707..c91179a 100644 --- a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py +++ b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py @@ -17,12 +17,16 @@ async def attach_channel_to_purchase_plan( user_id: uuid.UUID, input: dto.AttachChannelToPurchasePlanInput, ) -> dto.PurchasePlanChannelOutput: - await self.ensure_workspace_access(workspace_id, user_id) + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: raise domain.ProjectNotFound(project_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + plan = await self.ensure_active_purchase_plan(project) # Поиск канала по username diff --git a/src/usecase/purchase_plan/get_purchase_plan_channels.py b/src/usecase/purchase_plan/get_purchase_plan_channels.py index 7ad758b..16ae8d8 100644 --- a/src/usecase/purchase_plan/get_purchase_plan_channels.py +++ b/src/usecase/purchase_plan/get_purchase_plan_channels.py @@ -12,12 +12,16 @@ log = logging.getLogger(__name__) async def get_purchase_plan_channels( self: 'Usecase', input: dto.GetPurchasePlanChannelsInput ) -> dto.GetPurchasePlanChannelsOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) project = await self.database.get_project(input.workspace_id, project_id=input.project_id) if not project: raise domain.ProjectNotFound(input.project_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) + plan = await self.ensure_active_purchase_plan(project) plan_channels = await self.database.get_purchase_plan_channels(plan.id) diff --git a/src/usecase/purchase_plan/remove_purchase_plan_channel.py b/src/usecase/purchase_plan/remove_purchase_plan_channel.py index 746f97f..95bcde7 100644 --- a/src/usecase/purchase_plan/remove_purchase_plan_channel.py +++ b/src/usecase/purchase_plan/remove_purchase_plan_channel.py @@ -17,12 +17,16 @@ async def remove_purchase_plan_channel( workspace_id: uuid.UUID, user_id: uuid.UUID, ) -> None: - await self.ensure_workspace_access(workspace_id, user_id) + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: raise domain.ProjectNotFound(project_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + plan = await self.ensure_active_purchase_plan(project) plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) diff --git a/src/usecase/purchase_plan/update_purchase_plan_channel.py b/src/usecase/purchase_plan/update_purchase_plan_channel.py index e766d12..4142c92 100644 --- a/src/usecase/purchase_plan/update_purchase_plan_channel.py +++ b/src/usecase/purchase_plan/update_purchase_plan_channel.py @@ -18,12 +18,16 @@ async def update_purchase_plan_channel( user_id: uuid.UUID, input: dto.UpdatePurchasePlanChannelInput, ) -> dto.PurchasePlanChannelOutput: - await self.ensure_workspace_access(workspace_id, user_id) + context = await self.ensure_workspace_permission( + workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE + ) project = await self.database.get_project(workspace_id, project_id=project_id) if not project: raise domain.ProjectNotFound(project_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id) + plan = await self.ensure_active_purchase_plan(project) plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) diff --git a/src/usecase/views/get_views_history.py b/src/usecase/views/get_views_history.py index d9e0c2c..7f94dda 100644 --- a/src/usecase/views/get_views_history.py +++ b/src/usecase/views/get_views_history.py @@ -10,13 +10,17 @@ log = logging.getLogger(__name__) async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput: - await self.ensure_workspace_access(input.workspace_id, input.user_id) + context = await self.ensure_workspace_permission( + input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ + ) placement = await self.database.get_placement(input.workspace_id, input.placement_id) if not placement: log.warning('Placement %s not found for user %s', input.placement_id, input.user_id) raise domain.PlacementNotFound(input.placement_id) + context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id) + histories = await self.database.get_views_history( input.placement_id, from_date=input.from_date, diff --git a/src/usecase/workspace/create_workspace.py b/src/usecase/workspace/create_workspace.py index 846e8af..cf429a2 100644 --- a/src/usecase/workspace/create_workspace.py +++ b/src/usecase/workspace/create_workspace.py @@ -20,7 +20,12 @@ async def create_workspace( async with self.database.transaction(): await self.database.create_workspace(workspace) - await self.database.add_user_to_workspace(workspace.id, user_id) + membership = await self.database.add_user_to_workspace(workspace.id, user_id) + await self.database.set_workspace_user_permissions( + membership.id, + global_permissions={domain.PermissionKey.ADMIN_FULL}, + scoped_permissions=[], + ) return dto.CreateWorkspaceOutput( id=workspace.id, diff --git a/src/usecase/workspace/create_workspace_invite.py b/src/usecase/workspace/create_workspace_invite.py new file mode 100644 index 0000000..9daae83 --- /dev/null +++ b/src/usecase/workspace/create_workspace_invite.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from aiogram.types import InlineKeyboardButton + +from src import domain, dto +from src.usecase.workspace.mappers import workspace_invite_to_dto + +if TYPE_CHECKING: + from .. import Usecase + + +INVITE_ACCEPT_CALLBACK_PREFIX = 'workspace_invite_accept' + + +async def create_workspace_invite( + self: Usecase, + workspace_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.CreateWorkspaceInviteInput, +) -> dto.WorkspaceInviteOutput: + context = await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + username = input.username + normalized_username = username.lower() + + invited_user = await self.database.get_user_by_username(normalized_username) + if not invited_user: + raise domain.UserByUsernameNotFound(username) + + existing_membership = await self.database.get_workspace_membership(workspace_id, invited_user.id) + if existing_membership and existing_membership.status != domain.WorkspaceUserStatus.REMOVED: + raise domain.WorkspaceMemberAlreadyExists() + + existing_invite = await self.database.get_workspace_invite_by_user(workspace_id, invited_user.id) + if existing_invite: + if existing_invite.status == domain.WorkspaceInviteStatus.ACCEPTED: + raise domain.WorkspaceMemberAlreadyExists() + raise domain.WorkspaceInviteAlreadyExists() + + invite = domain.WorkspaceInvite( + workspace_id=workspace_id, + invited_by_id=user_id, + user_id=invited_user.id, + ) + + async with self.database.transaction(): + await self.database.create_workspace_invite(invite) + + invited_by_user = context.membership.user + if invited_by_user is None: + invited_by_user = await self.database.get_user(user_id=user_id) + if invited_by_user is None: + raise domain.UserNotFound(user_id) + + invite.user = invited_user + invite.invited_by = invited_by_user + + invite_message = ( + f'✨ Вас пригласили в рабочее пространство «{context.workspace.name}».\n\n' + 'Нажмите кнопку ниже, чтобы принять приглашение.' + ) + + accept_button = InlineKeyboardButton( + text='Принять приглашение', + callback_data=f'{INVITE_ACCEPT_CALLBACK_PREFIX}:{invite.id}', + ) + await self.telegram_bot.send_message_with_inline_keyboard( + invite_message, + chat_id=invited_user.telegram_id, + buttons=[[accept_button]], + ) + + return workspace_invite_to_dto(invite) diff --git a/src/usecase/workspace/delete_workspace.py b/src/usecase/workspace/delete_workspace.py index 70892c5..7a65a51 100644 --- a/src/usecase/workspace/delete_workspace.py +++ b/src/usecase/workspace/delete_workspace.py @@ -1,11 +1,13 @@ import uuid from typing import TYPE_CHECKING +from src import domain + if TYPE_CHECKING: from .. import Usecase async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: - await self.ensure_workspace_access(workspace_id, user_id) + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) await self.database.delete_workspace(workspace_id) diff --git a/src/usecase/workspace/get_workspace_invites.py b/src/usecase/workspace/get_workspace_invites.py new file mode 100644 index 0000000..ecdacd6 --- /dev/null +++ b/src/usecase/workspace/get_workspace_invites.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto +from src.usecase.workspace.mappers import workspace_invite_to_dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspace_invites( + self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID +) -> dto.GetWorkspaceInvitesOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + invites = await self.database.get_workspace_invites(workspace_id) + + return dto.GetWorkspaceInvitesOutput( + invites=[workspace_invite_to_dto(invite) for invite in invites] + ) diff --git a/src/usecase/workspace/get_workspace_members.py b/src/usecase/workspace/get_workspace_members.py new file mode 100644 index 0000000..a91eb22 --- /dev/null +++ b/src/usecase/workspace/get_workspace_members.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto +from src.usecase.workspace.mappers import workspace_member_to_dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def get_workspace_members( + self: Usecase, workspace_id: uuid.UUID, user_id: uuid.UUID +) -> dto.GetWorkspaceMembersOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + members = await self.database.get_workspace_members(workspace_id) + + return dto.GetWorkspaceMembersOutput( + members=[workspace_member_to_dto(member) for member in members] + ) diff --git a/src/usecase/workspace/mappers.py b/src/usecase/workspace/mappers.py new file mode 100644 index 0000000..f751c02 --- /dev/null +++ b/src/usecase/workspace/mappers.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from src import domain, dto + + +def workspace_member_to_dto(member: domain.WorkspaceUser) -> dto.WorkspaceMemberOutput: + if member.user is None: + raise ValueError('Workspace member user relation is not loaded') + + permissions_map: dict[domain.PermissionKey, list[dto.WorkspacePermissionScopeOutput]] = {} + + for permission in getattr(member, 'permissions', []) or []: + permissions_map.setdefault(permission.permission, []) + + for scope in getattr(member, 'permission_scopes', []) or []: + permissions_map.setdefault(scope.permission, []).append( + dto.WorkspacePermissionScopeOutput( + type=scope.scope_type, + id=scope.scope_id, + ) + ) + + permissions_output = [ + dto.WorkspacePermissionOutput(key=key, scopes=scopes) + for key, scopes in sorted(permissions_map.items(), key=lambda item: item[0].value) + ] + + return dto.WorkspaceMemberOutput( + id=member.id, + status=member.status, + user=dto.WorkspaceMemberUserOutput( + id=member.user.id, + telegram_id=member.user.telegram_id, + username=member.user.username, + ), + permissions=permissions_output, + ) + + +def workspace_invite_to_dto(invite: domain.WorkspaceInvite) -> dto.WorkspaceInviteOutput: + if invite.user is None or invite.invited_by is None: + raise ValueError('Workspace invite relations are not loaded') + + return dto.WorkspaceInviteOutput( + id=invite.id, + status=invite.status, + user=dto.WorkspaceMemberUserOutput( + id=invite.user.id, + telegram_id=invite.user.telegram_id, + username=invite.user.username, + ), + invited_by=dto.WorkspaceMemberUserOutput( + id=invite.invited_by.id, + telegram_id=invite.invited_by.telegram_id, + username=invite.invited_by.username, + ), + ) diff --git a/src/usecase/workspace/tg_accept_workspace_invite.py b/src/usecase/workspace/tg_accept_workspace_invite.py new file mode 100644 index 0000000..063ca25 --- /dev/null +++ b/src/usecase/workspace/tg_accept_workspace_invite.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import logging +import uuid +from typing import TYPE_CHECKING + +from src import domain +from src.usecase.workspace.create_workspace_invite import INVITE_ACCEPT_CALLBACK_PREFIX + +if TYPE_CHECKING: + from .. import Usecase + +log = logging.getLogger(__name__) + + +async def tg_accept_workspace_invite( + self: Usecase, + telegram_id: int, + chat_id: int, + callback_data: str, + message_id: int, +) -> None: + user = await self.database.get_user(telegram_id=telegram_id) + if not user: + await self.telegram_bot.send_message('❌ Вы не авторизованы. Используйте /start для входа.', chat_id) + return + + parts = callback_data.split(':', 1) + if len(parts) != 2 or parts[0] != INVITE_ACCEPT_CALLBACK_PREFIX: + log.warning('Unexpected callback data for workspace invite: %s', callback_data) + await self.telegram_bot.send_message('❌ Приглашение не найдено.', chat_id) + return + + try: + invite_id = uuid.UUID(parts[1]) + except ValueError: + log.warning('Invalid workspace invite id: %s', parts[1]) + await self.telegram_bot.send_message('❌ Приглашение больше недоступно.', chat_id) + return + + invite = await self.database.get_workspace_invite(invite_id) + if not invite or invite.user_id != user.id: + await self.telegram_bot.send_message('❌ Приглашение не найдено или вы не можете его принять.', chat_id) + if message_id: + await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id) + return + + if invite.status != domain.WorkspaceInviteStatus.PENDING: + await self.telegram_bot.send_message('⚠️ Это приглашение уже обработано.', chat_id) + if message_id: + await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id) + return + + async with self.database.transaction(): + invite.status = domain.WorkspaceInviteStatus.ACCEPTED + await self.database.update_workspace_invite(invite) + + membership = await self.database.get_workspace_membership(invite.workspace_id, user.id) + if membership: + if membership.status != domain.WorkspaceUserStatus.ACTIVE: + membership.status = domain.WorkspaceUserStatus.ACTIVE + await self.database.update_workspace_user(membership) + else: + await self.database.add_user_to_workspace(invite.workspace_id, user.id) + + workspace_name = invite.workspace.name if invite.workspace else 'рабочее пространство' + await self.telegram_bot.send_message( + f'✅ Вы присоединились к рабочему пространству «{workspace_name}».\n\n' + 'Администратор сможет выдать вам необходимые права в веб-интерфейсе.', + chat_id, + ) + + if message_id: + await self.telegram_bot.edit_message_reply_markup(chat_id=chat_id, message_id=message_id) diff --git a/src/usecase/workspace/update_workspace.py b/src/usecase/workspace/update_workspace.py index 6ec82c8..3783195 100644 --- a/src/usecase/workspace/update_workspace.py +++ b/src/usecase/workspace/update_workspace.py @@ -10,6 +10,8 @@ if TYPE_CHECKING: async def update_workspace( self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput ) -> dto.WorkspaceMembershipOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + membership = await self.database.get_workspace_membership(workspace_id, user_id) if not membership: raise domain.WorkspaceNotFound(workspace_id) diff --git a/src/usecase/workspace/update_workspace_member_permissions.py b/src/usecase/workspace/update_workspace_member_permissions.py new file mode 100644 index 0000000..b6d7c78 --- /dev/null +++ b/src/usecase/workspace/update_workspace_member_permissions.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from src import domain, dto +from src.usecase.workspace.mappers import workspace_member_to_dto + +if TYPE_CHECKING: + from .. import Usecase + + +async def update_workspace_member_permissions( + self: Usecase, + workspace_id: uuid.UUID, + workspace_user_id: uuid.UUID, + user_id: uuid.UUID, + input: dto.UpdateWorkspaceMemberPermissionsInput, +) -> dto.WorkspaceMemberOutput: + await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL) + + member = await self.database.get_workspace_member(workspace_user_id) + if not member or member.workspace_id != workspace_id: + raise domain.WorkspaceAccessDenied(workspace_id) + + global_permissions: set[domain.PermissionKey] = set() + scoped_assignments: set[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]] = set() + + for permission in input.permissions: + if permission.scopes: + for scope in permission.scopes: + scoped_assignments.add((permission.key, scope.type, scope.id)) + else: + global_permissions.add(permission.key) + + await self.database.set_workspace_user_permissions( + member.id, + global_permissions=global_permissions, + scoped_permissions=list(scoped_assignments), + ) + + updated_member = await self.database.get_workspace_member(member.id) + if not updated_member: + raise domain.WorkspaceAccessDenied(workspace_id) + + return workspace_member_to_dto(updated_member)