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

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