feat: права и инвайты

This commit is contained in:
Artem Tsyrulnikov
2025-12-14 13:49:25 +03:00
parent f3e09a08eb
commit 4c3873c727
43 changed files with 1111 additions and 127 deletions

View File

@@ -1,5 +1,6 @@
from tortoise import BaseDBAsyncClient from tortoise import BaseDBAsyncClient
# ruff: noqa
RUN_IN_TRANSACTION = True RUN_IN_TRANSACTION = True
@@ -204,25 +205,25 @@ CREATE TABLE IF NOT EXISTS "workspace_user_permission" (
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ, "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, "workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission") 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"); 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" ( CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted_at" TIMESTAMPTZ, "deleted_at" TIMESTAMPTZ,
"id" UUID NOT NULL PRIMARY KEY, "id" UUID NOT NULL PRIMARY KEY,
"permission" VARCHAR(17) NOT NULL, "permission" VARCHAR(16) NOT NULL,
"scope_type" VARCHAR(9) NOT NULL, "scope_type" VARCHAR(9) NOT NULL,
"scope_id" UUID NOT NULL, "scope_id" UUID NOT NULL,
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE, "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") 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"); 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'; COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project';
CREATE TABLE IF NOT EXISTS "aerich" ( CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY, "id" SERIAL NOT NULL PRIMARY KEY,
@@ -238,72 +239,73 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
MODELS_STATE = ( MODELS_STATE = (
'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'
'IXWrBwut7SW8G0cI/f4/Nf+ymg=='
) )

View File

@@ -31,13 +31,16 @@ class Postgres(DatabaseBase):
async def delete_workspace(self, workspace_id: uuid.UUID) -> None: async def delete_workspace(self, workspace_id: uuid.UUID) -> None:
await domain.Workspace.filter(id=workspace_id).delete() await domain.Workspace.filter(id=workspace_id).delete()
async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser:
await domain.WorkspaceUser.create( return await domain.WorkspaceUser.create(
workspace_id=workspace_id, workspace_id=workspace_id,
user_id=user_id, user_id=user_id,
status=domain.WorkspaceUserStatus.ACTIVE, 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]: async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]:
return ( return (
await domain.WorkspaceUser.filter(user_id=user_id) await domain.WorkspaceUser.filter(user_id=user_id)
@@ -69,10 +72,83 @@ class Postgres(DatabaseBase):
async def get_workspace_membership( async def get_workspace_membership(
self, workspace_id: uuid.UUID, user_id: uuid.UUID self, workspace_id: uuid.UUID, user_id: uuid.UUID
) -> domain.WorkspaceUser | None: ) -> domain.WorkspaceUser | None:
return await domain.WorkspaceUser.get_or_none(workspace_id=workspace_id, user_id=user_id).prefetch_related( return (
'workspace' 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: async def create_login_token(self, login_token: domain.LoginToken) -> None:
await login_token.save() await login_token.save()
@@ -87,6 +163,9 @@ class Postgres(DatabaseBase):
raise ValueError('Either user_id or telegram_id must be provided') 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: async def get_login_token(self, token: str) -> domain.LoginToken | None:
return await domain.LoginToken.get_or_none(token=token) 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: async def update_project(self, project: domain.Project) -> None:
await project.save() await project.save()
async def get_workspace_projects(self, workspace_id: uuid.UUID) -> list[domain.Project]: async def get_workspace_projects(
return ( self, workspace_id: uuid.UUID, allowed_project_ids: set[uuid.UUID] | None = None
await domain.Project.filter(workspace_id=workspace_id) ) -> list[domain.Project]:
.prefetch_related('channel') query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel')
.order_by('created_at')
.all() 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: 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) 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() await creative.save()
async def get_workspace_creatives( 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]: ) -> list[domain.Creative]:
query = domain.Creative.filter(workspace_id=workspace_id) query = domain.Creative.filter(workspace_id=workspace_id)
if project_id: if project_id:
query = query.filter(project_id=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: if not include_archived:
query = query.filter(status=domain.CreativeStatus.ACTIVE) query = query.filter(status=domain.CreativeStatus.ACTIVE)
@@ -270,11 +361,16 @@ class Postgres(DatabaseBase):
placement_channel_id: uuid.UUID | None = None, placement_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,
allowed_project_ids: set[uuid.UUID] | None = None,
) -> list[domain.Placement]: ) -> list[domain.Placement]:
query = domain.Placement.filter(workspace_id=workspace_id) query = domain.Placement.filter(workspace_id=workspace_id)
if project_id: if project_id:
query = query.filter(project_id=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: if placement_channel_id:
query = query.filter(placement_channel_id=placement_channel_id) query = query.filter(placement_channel_id=placement_channel_id)
if creative_id: if creative_id:

View File

@@ -8,6 +8,8 @@ from src.controller.http.placements import placements_router
from src.controller.http.projects import projects_router from src.controller.http.projects import projects_router
from src.controller.http.purchase_plans import purchase_plan_channels_router from src.controller.http.purchase_plans import purchase_plan_channels_router
from src.controller.http.views import views_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 from src.controller.http.workspaces import workspaces_router
api_router = APIRouter() 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(views_router)
api_v1_router.include_router(analytics_router) api_v1_router.include_router(analytics_router)
api_v1_router.include_router(workspaces_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) api_router.include_router(api_v1_router)

View File

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

View File

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

View File

@@ -10,4 +10,5 @@ from . import ( # noqa: E402, F401
my_chat_member, my_chat_member,
project_commands, project_commands,
start_with_login, start_with_login,
workspace_invites,
) )

View File

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

View File

@@ -7,6 +7,9 @@ __all__ = (
'WorkspaceUserStatus', 'WorkspaceUserStatus',
'WorkspaceUserPermission', 'WorkspaceUserPermission',
'WorkspaceUserPermissionScope', 'WorkspaceUserPermissionScope',
'WorkspacePermissions',
'WorkspacePermissionContext',
'build_workspace_permission_context',
'PermissionKey', 'PermissionKey',
'PermissionScopeType', 'PermissionScopeType',
'Channel', 'Channel',
@@ -34,6 +37,9 @@ __all__ = (
'UserNotFound', 'UserNotFound',
'WorkspaceNotFound', 'WorkspaceNotFound',
'WorkspaceAccessDenied', 'WorkspaceAccessDenied',
'WorkspaceInviteNotFound',
'WorkspaceInviteAlreadyExists',
'WorkspaceMemberAlreadyExists',
'LoginTokenNotFound', 'LoginTokenNotFound',
'LoginTokenExpired', 'LoginTokenExpired',
'LoginTokenAlreadyUsed', 'LoginTokenAlreadyUsed',
@@ -46,6 +52,7 @@ __all__ = (
'CreativeNotFound', 'CreativeNotFound',
'CreativeInUse', 'CreativeInUse',
'PlacementNotFound', 'PlacementNotFound',
'UserByUsernameNotFound',
) )
from .channel import Channel, ChannelVerificationStatus from .channel import Channel, ChannelVerificationStatus
@@ -63,8 +70,12 @@ from .error import (
ProjectNotFound, ProjectNotFound,
PurchasePlanChannelNotFound, PurchasePlanChannelNotFound,
PurchasePlanNotFound, PurchasePlanNotFound,
UserByUsernameNotFound,
UserNotFound, UserNotFound,
WorkspaceAccessDenied, WorkspaceAccessDenied,
WorkspaceInviteAlreadyExists,
WorkspaceInviteNotFound,
WorkspaceMemberAlreadyExists,
WorkspaceNotFound, WorkspaceNotFound,
) )
from .login_token import LoginToken from .login_token import LoginToken
@@ -91,3 +102,8 @@ from .workspace import (
WorkspaceUserPermissionScope, WorkspaceUserPermissionScope,
WorkspaceUserStatus, WorkspaceUserStatus,
) )
from .workspace_permissions import (
WorkspacePermissionContext,
WorkspacePermissions,
build_workspace_permission_context,
)

View File

@@ -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') 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: def LoginTokenNotFound() -> HTTPException:
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found') 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: if workspace_id is None:
return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied') return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied')
return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} 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')

View File

@@ -66,18 +66,26 @@ class WorkspaceInvite(TimestampedModel):
'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True '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: class Meta:
table = 'workspace_invite' table = 'workspace_invite'
unique_together = (('workspace_id', 'user_id'),) unique_together = (('workspace_id', 'user_id'),)
class PermissionKey(str, enum.Enum): class PermissionKey(enum.StrEnum):
MANAGE_PROJECTS = 'manage_projects' PROJECTS_READ = 'projects_read'
MANAGE_PLACEMENTS = 'manage_placements' PROJECTS_WRITE = 'projects_write'
VIEW_ANALYTICS = 'view_analytics' 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' WORKSPACE = 'workspace'
PROJECT = 'project' PROJECT = 'project'

View File

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

View File

@@ -54,6 +54,17 @@ __all__ = (
'CreateWorkspaceInput', 'CreateWorkspaceInput',
'CreateWorkspaceOutput', 'CreateWorkspaceOutput',
'UpdateWorkspaceInput', 'UpdateWorkspaceInput',
'WorkspaceMemberOutput',
'WorkspaceMemberUserOutput',
'WorkspacePermissionOutput',
'WorkspacePermissionScopeOutput',
'GetWorkspaceMembersOutput',
'WorkspacePermissionInput',
'WorkspacePermissionScopeInput',
'UpdateWorkspaceMemberPermissionsInput',
'CreateWorkspaceInviteInput',
'WorkspaceInviteOutput',
'GetWorkspaceInvitesOutput',
) )
from .analytics import ( from .analytics import (
@@ -117,8 +128,19 @@ from .views import (
) )
from .workspace import ( from .workspace import (
CreateWorkspaceInput, CreateWorkspaceInput,
CreateWorkspaceInviteInput,
CreateWorkspaceOutput, CreateWorkspaceOutput,
GetWorkspaceInvitesOutput,
GetWorkspaceMembersOutput,
GetWorkspacesOutput, GetWorkspacesOutput,
UpdateWorkspaceInput, UpdateWorkspaceInput,
UpdateWorkspaceMemberPermissionsInput,
WorkspaceInviteOutput,
WorkspaceMemberOutput,
WorkspaceMembershipOutput, WorkspaceMembershipOutput,
WorkspaceMemberUserOutput,
WorkspacePermissionInput,
WorkspacePermissionOutput,
WorkspacePermissionScopeInput,
WorkspacePermissionScopeOutput,
) )

View File

@@ -2,6 +2,8 @@ import uuid
import pydantic import pydantic
from src import domain
class WorkspaceMembershipOutput(pydantic.BaseModel): class WorkspaceMembershipOutput(pydantic.BaseModel):
id: uuid.UUID id: uuid.UUID
@@ -21,3 +23,102 @@ class CreateWorkspaceInput(pydantic.BaseModel):
class UpdateWorkspaceInput(pydantic.BaseModel): class UpdateWorkspaceInput(pydantic.BaseModel):
name: str | None = None 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]

View File

@@ -44,15 +44,26 @@ from .subscription.handle_subscription import handle_subscription
from .subscription.handle_unsubscription import handle_unsubscription from .subscription.handle_unsubscription import handle_unsubscription
from .views.get_views_history import get_views_history from .views.get_views_history import get_views_history
from .workspace.create_workspace import create_workspace 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.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.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 import update_workspace
from .workspace.update_workspace_member_permissions import update_workspace_member_permissions
class Database(typing.Protocol): class Database(typing.Protocol):
def transaction(self) -> typing.AsyncContextManager[None]: ... 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: ... 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 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]: ... 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_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_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: ...
@@ -116,7 +152,9 @@ class Database(typing.Protocol):
async def update_project(self, project: domain.Project) -> None: ... 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: ... 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 update_creative(self, creative: domain.Creative) -> None: ...
async def get_workspace_creatives( 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]: ... ) -> list[domain.Creative]: ...
async def delete_creative(self, creative_id: UUID) -> None: ... async def delete_creative(self, creative_id: UUID) -> None: ...
@@ -167,6 +209,7 @@ class Database(typing.Protocol):
placement_channel_id: UUID | None = None, placement_channel_id: UUID | None = None,
creative_id: UUID | None = None, creative_id: UUID | None = None,
include_archived: bool = False, include_archived: bool = False,
allowed_project_ids: set[UUID] | None = None,
) -> list[domain.Placement]: ... ) -> list[domain.Placement]: ...
async def delete_placement(self, placement_id: UUID) -> None: ... async def delete_placement(self, placement_id: UUID) -> None: ...
@@ -239,6 +282,34 @@ class Usecase:
return workspace 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: async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace:
workspace = await self.database.get_default_workspace_for_user(user.id) workspace = await self.database.get_default_workspace_for_user(user.id)
if workspace: if workspace:
@@ -249,7 +320,12 @@ class Usecase:
async with self.database.transaction(): async with self.database.transaction():
await self.database.create_workspace(workspace) 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 return workspace
@@ -301,3 +377,8 @@ class Usecase:
create_workspace = create_workspace create_workspace = create_workspace
update_workspace = update_workspace update_workspace = update_workspace
delete_workspace = delete_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

View File

@@ -12,22 +12,42 @@ log = logging.getLogger(__name__)
async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput: 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: if input.project_id:
project = await self.database.get_project(input.workspace_id, input.project_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) 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 = await self.ensure_active_purchase_plan(project)
plan_channels = await self.database.get_purchase_plan_channels(plan.id) plan_channels = await self.database.get_purchase_plan_channels(plan.id)
channels = [pc.channel for pc in plan_channels] channels = [pc.channel for pc in plan_channels]
allowed_project_filter = None
else: 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( 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 @dataclass
class ChannelStats: class ChannelStats:
total_cost: float = 0.0 total_cost: float = 0.0

View File

@@ -14,18 +14,30 @@ 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) 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: if input.project_id:
project = await self.database.get_project(input.workspace_id, input.project_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) 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( 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( 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 @dataclass

View File

@@ -24,15 +24,24 @@ 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) 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: if input.project_id:
project = await self.database.get_project(input.workspace_id, input.project_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) 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( 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 = [ result = [

View File

@@ -33,15 +33,24 @@ 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) 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: if input.project_id:
project = await self.database.get_project(input.workspace_id, input.project_id) project = await self.database.get_project(input.workspace_id, input.project_id)
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) 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( 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 = [ filtered = [

View File

@@ -13,7 +13,12 @@ log = logging.getLogger(__name__)
async def create_creative( async def create_creative(
self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID, workspace_id: uuid.UUID self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.CreativeOutput: ) -> 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) project = await self.database.get_project(workspace_id, project_id=input.project_id)
if not project: if not project:

View File

@@ -10,13 +10,17 @@ log = logging.getLogger(__name__)
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None: 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) 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)
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
if creative.placements_count > 0: if creative.placements_count > 0:
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id) log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
raise domain.CreativeInUse(input.creative_id) raise domain.CreativeInUse(input.creative_id)

View File

@@ -10,12 +10,16 @@ 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:
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) creative = await self.database.get_creative(input.workspace_id, input.creative_id)
if not creative: if not creative:
raise domain.CreativeNotFound(input.creative_id) raise domain.CreativeNotFound(input.creative_id)
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
return dto.CreativeOutput( return dto.CreativeOutput(
id=creative.id, id=creative.id,
name=creative.name, name=creative.name,

View File

@@ -1,16 +1,27 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import dto from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput: 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( 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( return dto.GetCreativesOutput(

View File

@@ -17,13 +17,17 @@ async def update_creative(
user_id: uuid.UUID, user_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
) -> dto.CreativeOutput: ) -> 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) 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)
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
if input.name: if input.name:
creative.name = input.name creative.name = input.name
if input.text: if input.text:

View File

@@ -13,7 +13,12 @@ log = logging.getLogger(__name__)
async def create_placement( async def create_placement(
self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID
) -> dto.PlacementOutput: ) -> 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) project = await self.database.get_project(workspace_id, project_id=input.project_id)
if not project: if not project:

View File

@@ -10,13 +10,17 @@ log = logging.getLogger(__name__)
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None: 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) 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)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
if placement.status != domain.PlacementStatus.ACTIVE: if placement.status != domain.PlacementStatus.ACTIVE:
await self.database.delete_placement(input.placement_id) await self.database.delete_placement(input.placement_id)
return return

View File

@@ -10,13 +10,17 @@ 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:
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) 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)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
return dto.PlacementOutput( return dto.PlacementOutput(
id=placement.id, id=placement.id,
project_id=placement.project_id, project_id=placement.project_id,

View File

@@ -1,13 +1,21 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import dto from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput: 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( placements = await self.database.get_workspace_placements(
input.workspace_id, input.workspace_id,
@@ -15,6 +23,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
placement_channel_id=input.placement_channel_id, placement_channel_id=input.placement_channel_id,
creative_id=input.creative_id, creative_id=input.creative_id,
include_archived=input.include_archived, include_archived=input.include_archived,
allowed_project_ids=allowed_project_ids,
) )
return dto.GetPlacementsOutput( return dto.GetPlacementsOutput(

View File

@@ -17,13 +17,17 @@ async def update_placement(
user_id: uuid.UUID, user_id: uuid.UUID,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
) -> dto.PlacementOutput: ) -> 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) 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)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
if input.placement_date is not None: if input.placement_date is not None:
placement.placement_date = input.placement_date placement.placement_date = input.placement_date
if input.cost is not None: if input.cost is not None:

View File

@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import dto from src import domain, dto
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
@@ -9,9 +9,15 @@ if TYPE_CHECKING:
async def get_workspace_projects( async def get_workspace_projects(
self: 'Usecase', input: dto.GetWorkspaceProjectsInput self: 'Usecase', input: dto.GetWorkspaceProjectsInput
) -> dto.GetWorkspaceProjectsOutput: ) -> 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( return dto.GetWorkspaceProjectsOutput(
projects=[ projects=[

View File

@@ -17,12 +17,16 @@ async def attach_channel_to_purchase_plan(
user_id: uuid.UUID, user_id: uuid.UUID,
input: dto.AttachChannelToPurchasePlanInput, input: dto.AttachChannelToPurchasePlanInput,
) -> dto.PurchasePlanChannelOutput: ) -> 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) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:
raise domain.ProjectNotFound(project_id) raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project) plan = await self.ensure_active_purchase_plan(project)
# Поиск канала по username # Поиск канала по username

View File

@@ -12,12 +12,16 @@ log = logging.getLogger(__name__)
async def get_purchase_plan_channels( async def get_purchase_plan_channels(
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
) -> dto.GetPurchasePlanChannelsOutput: ) -> 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) project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
if not project: if not project:
raise domain.ProjectNotFound(input.project_id) 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 = await self.ensure_active_purchase_plan(project)
plan_channels = await self.database.get_purchase_plan_channels(plan.id) plan_channels = await self.database.get_purchase_plan_channels(plan.id)

View File

@@ -17,12 +17,16 @@ async def remove_purchase_plan_channel(
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
user_id: uuid.UUID, user_id: uuid.UUID,
) -> None: ) -> 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) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:
raise domain.ProjectNotFound(project_id) raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project) plan = await self.ensure_active_purchase_plan(project)
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)

View File

@@ -18,12 +18,16 @@ async def update_purchase_plan_channel(
user_id: uuid.UUID, user_id: uuid.UUID,
input: dto.UpdatePurchasePlanChannelInput, input: dto.UpdatePurchasePlanChannelInput,
) -> dto.PurchasePlanChannelOutput: ) -> 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) project = await self.database.get_project(workspace_id, project_id=project_id)
if not project: if not project:
raise domain.ProjectNotFound(project_id) raise domain.ProjectNotFound(project_id)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
plan = await self.ensure_active_purchase_plan(project) plan = await self.ensure_active_purchase_plan(project)
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id) plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)

View File

@@ -10,13 +10,17 @@ 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:
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) 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)
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
histories = await self.database.get_views_history( histories = await self.database.get_views_history(
input.placement_id, input.placement_id,
from_date=input.from_date, from_date=input.from_date,

View File

@@ -20,7 +20,12 @@ async def create_workspace(
async with self.database.transaction(): async with self.database.transaction():
await self.database.create_workspace(workspace) 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( return dto.CreateWorkspaceOutput(
id=workspace.id, id=workspace.id,

View File

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

View File

@@ -1,11 +1,13 @@
import uuid import uuid
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from src import domain
if TYPE_CHECKING: if TYPE_CHECKING:
from .. import Usecase from .. import Usecase
async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None: 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) await self.database.delete_workspace(workspace_id)

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ if TYPE_CHECKING:
async def update_workspace( async def update_workspace(
self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput
) -> dto.WorkspaceMembershipOutput: ) -> 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) membership = await self.database.get_workspace_membership(workspace_id, user_id)
if not membership: if not membership:
raise domain.WorkspaceNotFound(workspace_id) raise domain.WorkspaceNotFound(workspace_id)

View File

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