feat: права и инвайты
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
76
src/usecase/workspace/create_workspace_invite.py
Normal file
76
src/usecase/workspace/create_workspace_invite.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
22
src/usecase/workspace/get_workspace_invites.py
Normal file
22
src/usecase/workspace/get_workspace_invites.py
Normal 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]
|
||||
)
|
||||
22
src/usecase/workspace/get_workspace_members.py
Normal file
22
src/usecase/workspace/get_workspace_members.py
Normal 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]
|
||||
)
|
||||
57
src/usecase/workspace/mappers.py
Normal file
57
src/usecase/workspace/mappers.py
Normal 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,
|
||||
),
|
||||
)
|
||||
74
src/usecase/workspace/tg_accept_workspace_invite.py
Normal file
74
src/usecase/workspace/tg_accept_workspace_invite.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
46
src/usecase/workspace/update_workspace_member_permissions.py
Normal file
46
src/usecase/workspace/update_workspace_member_permissions.py
Normal 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)
|
||||
Reference in New Issue
Block a user