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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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)