feat: права и инвайты
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from tortoise import BaseDBAsyncClient
|
from tortoise import BaseDBAsyncClient
|
||||||
|
|
||||||
|
# ruff: noqa
|
||||||
RUN_IN_TRANSACTION = True
|
RUN_IN_TRANSACTION = True
|
||||||
|
|
||||||
|
|
||||||
@@ -204,25 +205,25 @@ CREATE TABLE IF NOT EXISTS "workspace_user_permission" (
|
|||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"deleted_at" TIMESTAMPTZ,
|
"deleted_at" TIMESTAMPTZ,
|
||||||
"permission" VARCHAR(17) NOT NULL,
|
"permission" VARCHAR(16) NOT NULL,
|
||||||
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
|
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
|
||||||
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
|
CONSTRAINT "uid_workspace_u_workspa_86f574" UNIQUE ("workspace_user_id", "permission")
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_b9b7b0" ON "workspace_user_permission" ("workspace_user_id");
|
||||||
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics';
|
COMMENT ON COLUMN "workspace_user_permission"."permission" IS 'PROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nADMIN_FULL: admin_full';
|
||||||
CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
|
CREATE TABLE IF NOT EXISTS "workspace_user_permission_scope" (
|
||||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"deleted_at" TIMESTAMPTZ,
|
"deleted_at" TIMESTAMPTZ,
|
||||||
"id" UUID NOT NULL PRIMARY KEY,
|
"id" UUID NOT NULL PRIMARY KEY,
|
||||||
"permission" VARCHAR(17) NOT NULL,
|
"permission" VARCHAR(16) NOT NULL,
|
||||||
"scope_type" VARCHAR(9) NOT NULL,
|
"scope_type" VARCHAR(9) NOT NULL,
|
||||||
"scope_id" UUID NOT NULL,
|
"scope_id" UUID NOT NULL,
|
||||||
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
|
"workspace_user_id" UUID NOT NULL REFERENCES "workspace_user" ("id") ON DELETE CASCADE,
|
||||||
CONSTRAINT "uid_workspace_u_workspa_488ed3" UNIQUE ("workspace_user_id", "permission", "scope_type", "scope_id")
|
CONSTRAINT "uid_workspace_u_workspa_488ed3" UNIQUE ("workspace_user_id", "permission", "scope_type", "scope_id")
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id");
|
CREATE INDEX IF NOT EXISTS "idx_workspace_u_workspa_82a35a" ON "workspace_user_permission_scope" ("workspace_user_id");
|
||||||
COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'MANAGE_PROJECTS: manage_projects\nMANAGE_PLACEMENTS: manage_placements\nVIEW_ANALYTICS: view_analytics';
|
COMMENT ON COLUMN "workspace_user_permission_scope"."permission" IS 'PROJECTS_READ: projects_read\nPROJECTS_WRITE: projects_write\nPLACEMENTS_READ: placements_read\nPLACEMENTS_WRITE: placements_write\nANALYTICS_READ: analytics_read\nADMIN_FULL: admin_full';
|
||||||
COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project';
|
COMMENT ON COLUMN "workspace_user_permission_scope"."scope_type" IS 'WORKSPACE: workspace\nPROJECT: project';
|
||||||
CREATE TABLE IF NOT EXISTS "aerich" (
|
CREATE TABLE IF NOT EXISTS "aerich" (
|
||||||
"id" SERIAL NOT NULL PRIMARY KEY,
|
"id" SERIAL NOT NULL PRIMARY KEY,
|
||||||
@@ -238,72 +239,73 @@ async def downgrade(db: BaseDBAsyncClient) -> str:
|
|||||||
|
|
||||||
|
|
||||||
MODELS_STATE = (
|
MODELS_STATE = (
|
||||||
'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'
|
||||||
|
'IXWrBwut7SW8G0cI/f4/Nf+ymg=='
|
||||||
)
|
)
|
||||||
@@ -31,13 +31,16 @@ class Postgres(DatabaseBase):
|
|||||||
async def delete_workspace(self, workspace_id: uuid.UUID) -> None:
|
async def delete_workspace(self, workspace_id: uuid.UUID) -> None:
|
||||||
await domain.Workspace.filter(id=workspace_id).delete()
|
await domain.Workspace.filter(id=workspace_id).delete()
|
||||||
|
|
||||||
async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
async def add_user_to_workspace(self, workspace_id: uuid.UUID, user_id: uuid.UUID) -> domain.WorkspaceUser:
|
||||||
await domain.WorkspaceUser.create(
|
return await domain.WorkspaceUser.create(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
status=domain.WorkspaceUserStatus.ACTIVE,
|
status=domain.WorkspaceUserStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def update_workspace_user(self, workspace_user: domain.WorkspaceUser) -> None:
|
||||||
|
await workspace_user.save()
|
||||||
|
|
||||||
async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]:
|
async def get_user_workspaces(self, user_id: uuid.UUID) -> list[domain.WorkspaceUser]:
|
||||||
return (
|
return (
|
||||||
await domain.WorkspaceUser.filter(user_id=user_id)
|
await domain.WorkspaceUser.filter(user_id=user_id)
|
||||||
@@ -69,8 +72,81 @@ class Postgres(DatabaseBase):
|
|||||||
async def get_workspace_membership(
|
async def get_workspace_membership(
|
||||||
self, workspace_id: uuid.UUID, user_id: uuid.UUID
|
self, workspace_id: uuid.UUID, user_id: uuid.UUID
|
||||||
) -> domain.WorkspaceUser | None:
|
) -> domain.WorkspaceUser | None:
|
||||||
return await domain.WorkspaceUser.get_or_none(workspace_id=workspace_id, user_id=user_id).prefetch_related(
|
return (
|
||||||
'workspace'
|
await domain.WorkspaceUser.filter(workspace_id=workspace_id, user_id=user_id)
|
||||||
|
.prefetch_related('workspace', 'user', 'permissions', 'permission_scopes')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_workspace_members(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceUser]:
|
||||||
|
return (
|
||||||
|
await domain.WorkspaceUser.filter(workspace_id=workspace_id)
|
||||||
|
.prefetch_related('workspace', 'user', 'permissions', 'permission_scopes')
|
||||||
|
.order_by('created_at')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_workspace_member(self, workspace_user_id: uuid.UUID) -> domain.WorkspaceUser | None:
|
||||||
|
return (
|
||||||
|
await domain.WorkspaceUser.filter(id=workspace_user_id)
|
||||||
|
.prefetch_related('workspace', 'user', 'permissions', 'permission_scopes')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_workspace_invite(self, invite: domain.WorkspaceInvite) -> None:
|
||||||
|
await invite.save()
|
||||||
|
|
||||||
|
async def update_workspace_invite(self, invite: domain.WorkspaceInvite) -> None:
|
||||||
|
await invite.save()
|
||||||
|
|
||||||
|
async def get_workspace_invite(self, invite_id: uuid.UUID) -> domain.WorkspaceInvite | None:
|
||||||
|
return (
|
||||||
|
await domain.WorkspaceInvite.filter(id=invite_id)
|
||||||
|
.prefetch_related('workspace', 'user', 'invited_by')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_workspace_invite_by_user(
|
||||||
|
self, workspace_id: uuid.UUID, user_id: uuid.UUID
|
||||||
|
) -> domain.WorkspaceInvite | None:
|
||||||
|
return await domain.WorkspaceInvite.get_or_none(workspace_id=workspace_id, user_id=user_id)
|
||||||
|
|
||||||
|
async def get_workspace_invites(self, workspace_id: uuid.UUID) -> list[domain.WorkspaceInvite]:
|
||||||
|
return (
|
||||||
|
await domain.WorkspaceInvite.filter(workspace_id=workspace_id)
|
||||||
|
.prefetch_related('user', 'invited_by')
|
||||||
|
.order_by('created_at')
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_workspace_user_permissions(
|
||||||
|
self,
|
||||||
|
workspace_user_id: uuid.UUID,
|
||||||
|
global_permissions: set[domain.PermissionKey],
|
||||||
|
scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, uuid.UUID]],
|
||||||
|
) -> None:
|
||||||
|
await domain.WorkspaceUserPermission.filter(workspace_user_id=workspace_user_id).delete()
|
||||||
|
await domain.WorkspaceUserPermissionScope.filter(workspace_user_id=workspace_user_id).delete()
|
||||||
|
|
||||||
|
if global_permissions:
|
||||||
|
await domain.WorkspaceUserPermission.bulk_create(
|
||||||
|
[
|
||||||
|
domain.WorkspaceUserPermission(workspace_user_id=workspace_user_id, permission=permission)
|
||||||
|
for permission in global_permissions
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if scoped_permissions:
|
||||||
|
await domain.WorkspaceUserPermissionScope.bulk_create(
|
||||||
|
[
|
||||||
|
domain.WorkspaceUserPermissionScope(
|
||||||
|
workspace_user_id=workspace_user_id,
|
||||||
|
permission=permission,
|
||||||
|
scope_type=scope_type,
|
||||||
|
scope_id=scope_id,
|
||||||
|
)
|
||||||
|
for permission, scope_type, scope_id in scoped_permissions
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
async def create_login_token(self, login_token: domain.LoginToken) -> None:
|
async def create_login_token(self, login_token: domain.LoginToken) -> None:
|
||||||
@@ -87,6 +163,9 @@ class Postgres(DatabaseBase):
|
|||||||
|
|
||||||
raise ValueError('Either user_id or telegram_id must be provided')
|
raise ValueError('Either user_id or telegram_id must be provided')
|
||||||
|
|
||||||
|
async def get_user_by_username(self, username: str) -> domain.User | None:
|
||||||
|
return await domain.User.get_or_none(username__iexact=username)
|
||||||
|
|
||||||
async def get_login_token(self, token: str) -> domain.LoginToken | None:
|
async def get_login_token(self, token: str) -> domain.LoginToken | None:
|
||||||
return await domain.LoginToken.get_or_none(token=token)
|
return await domain.LoginToken.get_or_none(token=token)
|
||||||
|
|
||||||
@@ -163,13 +242,17 @@ class Postgres(DatabaseBase):
|
|||||||
async def update_project(self, project: domain.Project) -> None:
|
async def update_project(self, project: domain.Project) -> None:
|
||||||
await project.save()
|
await project.save()
|
||||||
|
|
||||||
async def get_workspace_projects(self, workspace_id: uuid.UUID) -> list[domain.Project]:
|
async def get_workspace_projects(
|
||||||
return (
|
self, workspace_id: uuid.UUID, allowed_project_ids: set[uuid.UUID] | None = None
|
||||||
await domain.Project.filter(workspace_id=workspace_id)
|
) -> list[domain.Project]:
|
||||||
.prefetch_related('channel')
|
query = domain.Project.filter(workspace_id=workspace_id).prefetch_related('channel')
|
||||||
.order_by('created_at')
|
|
||||||
.all()
|
if allowed_project_ids is not None:
|
||||||
)
|
if not allowed_project_ids:
|
||||||
|
return []
|
||||||
|
query = query.filter(id__in=list(allowed_project_ids))
|
||||||
|
|
||||||
|
return await query.order_by('created_at').all()
|
||||||
|
|
||||||
async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None:
|
async def get_active_purchase_plan(self, project_id: uuid.UUID) -> domain.PurchasePlan | None:
|
||||||
return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE)
|
return await domain.PurchasePlan.get_or_none(project_id=project_id, status=domain.PurchasePlanStatus.ACTIVE)
|
||||||
@@ -237,12 +320,20 @@ class Postgres(DatabaseBase):
|
|||||||
await creative.save()
|
await creative.save()
|
||||||
|
|
||||||
async def get_workspace_creatives(
|
async def get_workspace_creatives(
|
||||||
self, workspace_id: uuid.UUID, project_id: uuid.UUID | None = None, include_archived: bool = False
|
self,
|
||||||
|
workspace_id: uuid.UUID,
|
||||||
|
project_id: uuid.UUID | None = None,
|
||||||
|
include_archived: bool = False,
|
||||||
|
allowed_project_ids: set[uuid.UUID] | None = None,
|
||||||
) -> list[domain.Creative]:
|
) -> list[domain.Creative]:
|
||||||
query = domain.Creative.filter(workspace_id=workspace_id)
|
query = domain.Creative.filter(workspace_id=workspace_id)
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(project_id=project_id)
|
query = query.filter(project_id=project_id)
|
||||||
|
elif allowed_project_ids is not None:
|
||||||
|
if not allowed_project_ids:
|
||||||
|
return []
|
||||||
|
query = query.filter(project_id__in=list(allowed_project_ids))
|
||||||
|
|
||||||
if not include_archived:
|
if not include_archived:
|
||||||
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
query = query.filter(status=domain.CreativeStatus.ACTIVE)
|
||||||
@@ -270,11 +361,16 @@ class Postgres(DatabaseBase):
|
|||||||
placement_channel_id: uuid.UUID | None = None,
|
placement_channel_id: uuid.UUID | None = None,
|
||||||
creative_id: uuid.UUID | None = None,
|
creative_id: uuid.UUID | None = None,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
allowed_project_ids: set[uuid.UUID] | None = None,
|
||||||
) -> list[domain.Placement]:
|
) -> list[domain.Placement]:
|
||||||
query = domain.Placement.filter(workspace_id=workspace_id)
|
query = domain.Placement.filter(workspace_id=workspace_id)
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
query = query.filter(project_id=project_id)
|
query = query.filter(project_id=project_id)
|
||||||
|
elif allowed_project_ids is not None:
|
||||||
|
if not allowed_project_ids:
|
||||||
|
return []
|
||||||
|
query = query.filter(project_id__in=list(allowed_project_ids))
|
||||||
if placement_channel_id:
|
if placement_channel_id:
|
||||||
query = query.filter(placement_channel_id=placement_channel_id)
|
query = query.filter(placement_channel_id=placement_channel_id)
|
||||||
if creative_id:
|
if creative_id:
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from src.controller.http.placements import placements_router
|
|||||||
from src.controller.http.projects import projects_router
|
from src.controller.http.projects import projects_router
|
||||||
from src.controller.http.purchase_plans import purchase_plan_channels_router
|
from src.controller.http.purchase_plans import purchase_plan_channels_router
|
||||||
from src.controller.http.views import views_router
|
from src.controller.http.views import views_router
|
||||||
|
from src.controller.http.workspace_invites import workspace_invites_router
|
||||||
|
from src.controller.http.workspace_members import workspace_members_router
|
||||||
from src.controller.http.workspaces import workspaces_router
|
from src.controller.http.workspaces import workspaces_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -23,5 +25,7 @@ api_v1_router.include_router(placements_router)
|
|||||||
api_v1_router.include_router(views_router)
|
api_v1_router.include_router(views_router)
|
||||||
api_v1_router.include_router(analytics_router)
|
api_v1_router.include_router(analytics_router)
|
||||||
api_v1_router.include_router(workspaces_router)
|
api_v1_router.include_router(workspaces_router)
|
||||||
|
api_v1_router.include_router(workspace_members_router)
|
||||||
|
api_v1_router.include_router(workspace_invites_router)
|
||||||
|
|
||||||
api_router.include_router(api_v1_router)
|
api_router.include_router(api_v1_router)
|
||||||
|
|||||||
34
src/controller/http/workspace_invites.py
Normal file
34
src/controller/http/workspace_invites.py
Normal 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,
|
||||||
|
)
|
||||||
36
src/controller/http/workspace_members.py
Normal file
36
src/controller/http/workspace_members.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -10,4 +10,5 @@ from . import ( # noqa: E402, F401
|
|||||||
my_chat_member,
|
my_chat_member,
|
||||||
project_commands,
|
project_commands,
|
||||||
start_with_login,
|
start_with_login,
|
||||||
|
workspace_invites,
|
||||||
)
|
)
|
||||||
|
|||||||
27
src/controller/telegram_callback/workspace_invites.py
Normal file
27
src/controller/telegram_callback/workspace_invites.py
Normal 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()
|
||||||
@@ -7,6 +7,9 @@ __all__ = (
|
|||||||
'WorkspaceUserStatus',
|
'WorkspaceUserStatus',
|
||||||
'WorkspaceUserPermission',
|
'WorkspaceUserPermission',
|
||||||
'WorkspaceUserPermissionScope',
|
'WorkspaceUserPermissionScope',
|
||||||
|
'WorkspacePermissions',
|
||||||
|
'WorkspacePermissionContext',
|
||||||
|
'build_workspace_permission_context',
|
||||||
'PermissionKey',
|
'PermissionKey',
|
||||||
'PermissionScopeType',
|
'PermissionScopeType',
|
||||||
'Channel',
|
'Channel',
|
||||||
@@ -34,6 +37,9 @@ __all__ = (
|
|||||||
'UserNotFound',
|
'UserNotFound',
|
||||||
'WorkspaceNotFound',
|
'WorkspaceNotFound',
|
||||||
'WorkspaceAccessDenied',
|
'WorkspaceAccessDenied',
|
||||||
|
'WorkspaceInviteNotFound',
|
||||||
|
'WorkspaceInviteAlreadyExists',
|
||||||
|
'WorkspaceMemberAlreadyExists',
|
||||||
'LoginTokenNotFound',
|
'LoginTokenNotFound',
|
||||||
'LoginTokenExpired',
|
'LoginTokenExpired',
|
||||||
'LoginTokenAlreadyUsed',
|
'LoginTokenAlreadyUsed',
|
||||||
@@ -46,6 +52,7 @@ __all__ = (
|
|||||||
'CreativeNotFound',
|
'CreativeNotFound',
|
||||||
'CreativeInUse',
|
'CreativeInUse',
|
||||||
'PlacementNotFound',
|
'PlacementNotFound',
|
||||||
|
'UserByUsernameNotFound',
|
||||||
)
|
)
|
||||||
|
|
||||||
from .channel import Channel, ChannelVerificationStatus
|
from .channel import Channel, ChannelVerificationStatus
|
||||||
@@ -63,8 +70,12 @@ from .error import (
|
|||||||
ProjectNotFound,
|
ProjectNotFound,
|
||||||
PurchasePlanChannelNotFound,
|
PurchasePlanChannelNotFound,
|
||||||
PurchasePlanNotFound,
|
PurchasePlanNotFound,
|
||||||
|
UserByUsernameNotFound,
|
||||||
UserNotFound,
|
UserNotFound,
|
||||||
WorkspaceAccessDenied,
|
WorkspaceAccessDenied,
|
||||||
|
WorkspaceInviteAlreadyExists,
|
||||||
|
WorkspaceInviteNotFound,
|
||||||
|
WorkspaceMemberAlreadyExists,
|
||||||
WorkspaceNotFound,
|
WorkspaceNotFound,
|
||||||
)
|
)
|
||||||
from .login_token import LoginToken
|
from .login_token import LoginToken
|
||||||
@@ -91,3 +102,8 @@ from .workspace import (
|
|||||||
WorkspaceUserPermissionScope,
|
WorkspaceUserPermissionScope,
|
||||||
WorkspaceUserStatus,
|
WorkspaceUserStatus,
|
||||||
)
|
)
|
||||||
|
from .workspace_permissions import (
|
||||||
|
WorkspacePermissionContext,
|
||||||
|
WorkspacePermissions,
|
||||||
|
build_workspace_permission_context,
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ def UserNotFound(user_id: uuid.UUID | None = None) -> HTTPException:
|
|||||||
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'User {user_id} not found')
|
||||||
|
|
||||||
|
|
||||||
|
def UserByUsernameNotFound(username: str) -> HTTPException:
|
||||||
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'User @{username} not found')
|
||||||
|
|
||||||
|
|
||||||
def LoginTokenNotFound() -> HTTPException:
|
def LoginTokenNotFound() -> HTTPException:
|
||||||
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
|
return HTTPException(status.HTTP_404_NOT_FOUND, 'Login token not found')
|
||||||
|
|
||||||
@@ -84,3 +88,17 @@ def WorkspaceAccessDenied(workspace_id: uuid.UUID | None = None) -> HTTPExceptio
|
|||||||
if workspace_id is None:
|
if workspace_id is None:
|
||||||
return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied')
|
return HTTPException(status.HTTP_403_FORBIDDEN, 'Workspace access denied')
|
||||||
return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} access denied')
|
return HTTPException(status.HTTP_403_FORBIDDEN, f'Workspace {workspace_id} access denied')
|
||||||
|
|
||||||
|
|
||||||
|
def WorkspaceInviteNotFound(invite_id: uuid.UUID | None = None) -> HTTPException:
|
||||||
|
if invite_id is None:
|
||||||
|
return HTTPException(status.HTTP_404_NOT_FOUND, 'Workspace invite not found')
|
||||||
|
return HTTPException(status.HTTP_404_NOT_FOUND, f'Workspace invite {invite_id} not found')
|
||||||
|
|
||||||
|
|
||||||
|
def WorkspaceInviteAlreadyExists() -> HTTPException:
|
||||||
|
return HTTPException(status.HTTP_409_CONFLICT, 'Workspace invite already exists for this user')
|
||||||
|
|
||||||
|
|
||||||
|
def WorkspaceMemberAlreadyExists() -> HTTPException:
|
||||||
|
return HTTPException(status.HTTP_409_CONFLICT, 'User is already a workspace member')
|
||||||
|
|||||||
@@ -66,18 +66,26 @@ class WorkspaceInvite(TimestampedModel):
|
|||||||
'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True
|
'models.User', related_name='workspace_invites', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
workspace_id: uuid.UUID
|
||||||
|
invited_by_id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
table = 'workspace_invite'
|
table = 'workspace_invite'
|
||||||
unique_together = (('workspace_id', 'user_id'),)
|
unique_together = (('workspace_id', 'user_id'),)
|
||||||
|
|
||||||
|
|
||||||
class PermissionKey(str, enum.Enum):
|
class PermissionKey(enum.StrEnum):
|
||||||
MANAGE_PROJECTS = 'manage_projects'
|
PROJECTS_READ = 'projects_read'
|
||||||
MANAGE_PLACEMENTS = 'manage_placements'
|
PROJECTS_WRITE = 'projects_write'
|
||||||
VIEW_ANALYTICS = 'view_analytics'
|
PLACEMENTS_READ = 'placements_read'
|
||||||
|
PLACEMENTS_WRITE = 'placements_write'
|
||||||
|
ANALYTICS_READ = 'analytics_read'
|
||||||
|
ADMIN_FULL = 'admin_full'
|
||||||
|
|
||||||
|
|
||||||
class PermissionScopeType(str, enum.Enum):
|
class PermissionScopeType(enum.StrEnum):
|
||||||
WORKSPACE = 'workspace'
|
WORKSPACE = 'workspace'
|
||||||
PROJECT = 'project'
|
PROJECT = 'project'
|
||||||
|
|
||||||
|
|||||||
102
src/domain/workspace_permissions.py
Normal file
102
src/domain/workspace_permissions.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -54,6 +54,17 @@ __all__ = (
|
|||||||
'CreateWorkspaceInput',
|
'CreateWorkspaceInput',
|
||||||
'CreateWorkspaceOutput',
|
'CreateWorkspaceOutput',
|
||||||
'UpdateWorkspaceInput',
|
'UpdateWorkspaceInput',
|
||||||
|
'WorkspaceMemberOutput',
|
||||||
|
'WorkspaceMemberUserOutput',
|
||||||
|
'WorkspacePermissionOutput',
|
||||||
|
'WorkspacePermissionScopeOutput',
|
||||||
|
'GetWorkspaceMembersOutput',
|
||||||
|
'WorkspacePermissionInput',
|
||||||
|
'WorkspacePermissionScopeInput',
|
||||||
|
'UpdateWorkspaceMemberPermissionsInput',
|
||||||
|
'CreateWorkspaceInviteInput',
|
||||||
|
'WorkspaceInviteOutput',
|
||||||
|
'GetWorkspaceInvitesOutput',
|
||||||
)
|
)
|
||||||
|
|
||||||
from .analytics import (
|
from .analytics import (
|
||||||
@@ -117,8 +128,19 @@ from .views import (
|
|||||||
)
|
)
|
||||||
from .workspace import (
|
from .workspace import (
|
||||||
CreateWorkspaceInput,
|
CreateWorkspaceInput,
|
||||||
|
CreateWorkspaceInviteInput,
|
||||||
CreateWorkspaceOutput,
|
CreateWorkspaceOutput,
|
||||||
|
GetWorkspaceInvitesOutput,
|
||||||
|
GetWorkspaceMembersOutput,
|
||||||
GetWorkspacesOutput,
|
GetWorkspacesOutput,
|
||||||
UpdateWorkspaceInput,
|
UpdateWorkspaceInput,
|
||||||
|
UpdateWorkspaceMemberPermissionsInput,
|
||||||
|
WorkspaceInviteOutput,
|
||||||
|
WorkspaceMemberOutput,
|
||||||
WorkspaceMembershipOutput,
|
WorkspaceMembershipOutput,
|
||||||
|
WorkspaceMemberUserOutput,
|
||||||
|
WorkspacePermissionInput,
|
||||||
|
WorkspacePermissionOutput,
|
||||||
|
WorkspacePermissionScopeInput,
|
||||||
|
WorkspacePermissionScopeOutput,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import uuid
|
|||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
||||||
|
from src import domain
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceMembershipOutput(pydantic.BaseModel):
|
class WorkspaceMembershipOutput(pydantic.BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
@@ -21,3 +23,102 @@ class CreateWorkspaceInput(pydantic.BaseModel):
|
|||||||
|
|
||||||
class UpdateWorkspaceInput(pydantic.BaseModel):
|
class UpdateWorkspaceInput(pydantic.BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMemberUserOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
telegram_id: int
|
||||||
|
username: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacePermissionScopeOutput(pydantic.BaseModel):
|
||||||
|
type: domain.PermissionScopeType
|
||||||
|
id: uuid.UUID
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacePermissionOutput(pydantic.BaseModel):
|
||||||
|
key: domain.PermissionKey
|
||||||
|
scopes: list[WorkspacePermissionScopeOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMemberOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: domain.WorkspaceUserStatus
|
||||||
|
user: WorkspaceMemberUserOutput
|
||||||
|
permissions: list[WorkspacePermissionOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class GetWorkspaceMembersOutput(pydantic.BaseModel):
|
||||||
|
members: list[WorkspaceMemberOutput]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacePermissionScopeInput(pydantic.BaseModel):
|
||||||
|
type: domain.PermissionScopeType = pydantic.Field(
|
||||||
|
description='Тип области действия. Сейчас используется только "project".'
|
||||||
|
)
|
||||||
|
id: uuid.UUID = pydantic.Field(description='Идентификатор сущности в указанной области (например, project_id).')
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspacePermissionInput(pydantic.BaseModel):
|
||||||
|
key: domain.PermissionKey = pydantic.Field(
|
||||||
|
description=(
|
||||||
|
'Ключ права. Доступные значения: "projects_read", "projects_write", '
|
||||||
|
'"placements_read", "placements_write", "analytics_read", "admin_full".'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scopes: list[WorkspacePermissionScopeInput] = pydantic.Field(
|
||||||
|
default_factory=list,
|
||||||
|
description='Ограничения по областям. Пустой список означает глобальное право.',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateWorkspaceMemberPermissionsInput(pydantic.BaseModel):
|
||||||
|
permissions: list[WorkspacePermissionInput] = pydantic.Field(
|
||||||
|
default_factory=list,
|
||||||
|
description='Список прав, который полностью заменит текущий набор участника.',
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = pydantic.ConfigDict(
|
||||||
|
json_schema_extra={
|
||||||
|
'examples': [
|
||||||
|
{
|
||||||
|
'permissions': [
|
||||||
|
{'key': 'admin_full'},
|
||||||
|
{
|
||||||
|
'key': 'projects_write',
|
||||||
|
'scopes': [
|
||||||
|
{'type': 'project', 'id': '11111111-1111-1111-1111-111111111111'},
|
||||||
|
{'type': 'project', 'id': '22222222-2222-2222-2222-222222222222'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWorkspaceInviteInput(pydantic.BaseModel):
|
||||||
|
username: str = pydantic.Field(min_length=1)
|
||||||
|
|
||||||
|
@pydantic.field_validator('username')
|
||||||
|
@classmethod
|
||||||
|
def normalize_username(cls, value: str) -> str:
|
||||||
|
username = value.strip()
|
||||||
|
if username.startswith('@'):
|
||||||
|
username = username[1:]
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
raise ValueError('Username must not be empty')
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceInviteOutput(pydantic.BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
status: domain.WorkspaceInviteStatus
|
||||||
|
user: WorkspaceMemberUserOutput
|
||||||
|
invited_by: WorkspaceMemberUserOutput
|
||||||
|
|
||||||
|
|
||||||
|
class GetWorkspaceInvitesOutput(pydantic.BaseModel):
|
||||||
|
invites: list[WorkspaceInviteOutput]
|
||||||
|
|||||||
@@ -44,15 +44,26 @@ from .subscription.handle_subscription import handle_subscription
|
|||||||
from .subscription.handle_unsubscription import handle_unsubscription
|
from .subscription.handle_unsubscription import handle_unsubscription
|
||||||
from .views.get_views_history import get_views_history
|
from .views.get_views_history import get_views_history
|
||||||
from .workspace.create_workspace import create_workspace
|
from .workspace.create_workspace import create_workspace
|
||||||
|
from .workspace.create_workspace_invite import create_workspace_invite
|
||||||
from .workspace.delete_workspace import delete_workspace
|
from .workspace.delete_workspace import delete_workspace
|
||||||
|
from .workspace.get_workspace_invites import get_workspace_invites
|
||||||
|
from .workspace.get_workspace_members import get_workspace_members
|
||||||
from .workspace.get_workspaces import get_workspaces
|
from .workspace.get_workspaces import get_workspaces
|
||||||
|
from .workspace.tg_accept_workspace_invite import tg_accept_workspace_invite
|
||||||
from .workspace.update_workspace import update_workspace
|
from .workspace.update_workspace import update_workspace
|
||||||
|
from .workspace.update_workspace_member_permissions import update_workspace_member_permissions
|
||||||
|
|
||||||
|
|
||||||
class Database(typing.Protocol):
|
class Database(typing.Protocol):
|
||||||
def transaction(self) -> typing.AsyncContextManager[None]: ...
|
def transaction(self) -> typing.AsyncContextManager[None]: ...
|
||||||
|
|
||||||
async def get_user(self, user_id: UUID | None = None, telegram_id: int | None = None) -> domain.User | None: ...
|
async def get_user(
|
||||||
|
self,
|
||||||
|
user_id: UUID | None = None,
|
||||||
|
telegram_id: int | None = None,
|
||||||
|
) -> domain.User | None: ...
|
||||||
|
|
||||||
|
async def get_user_by_username(self, username: str) -> domain.User | None: ...
|
||||||
|
|
||||||
async def create_user(self, user: domain.User) -> None: ...
|
async def create_user(self, user: domain.User) -> None: ...
|
||||||
|
|
||||||
@@ -62,7 +73,16 @@ class Database(typing.Protocol):
|
|||||||
|
|
||||||
async def delete_workspace(self, workspace_id: UUID) -> None: ...
|
async def delete_workspace(self, workspace_id: UUID) -> None: ...
|
||||||
|
|
||||||
async def add_user_to_workspace(self, workspace_id: UUID, user_id: UUID) -> None: ...
|
async def add_user_to_workspace(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser: ...
|
||||||
|
|
||||||
|
async def set_workspace_user_permissions(
|
||||||
|
self,
|
||||||
|
workspace_user_id: UUID,
|
||||||
|
global_permissions: set[domain.PermissionKey],
|
||||||
|
scoped_permissions: list[tuple[domain.PermissionKey, domain.PermissionScopeType, UUID]],
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def update_workspace_user(self, workspace_user: domain.WorkspaceUser) -> None: ...
|
||||||
|
|
||||||
async def get_user_workspaces(self, user_id: UUID) -> list[domain.WorkspaceUser]: ...
|
async def get_user_workspaces(self, user_id: UUID) -> list[domain.WorkspaceUser]: ...
|
||||||
|
|
||||||
@@ -74,6 +94,22 @@ class Database(typing.Protocol):
|
|||||||
|
|
||||||
async def get_workspace_membership(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser | None: ...
|
async def get_workspace_membership(self, workspace_id: UUID, user_id: UUID) -> domain.WorkspaceUser | None: ...
|
||||||
|
|
||||||
|
async def get_workspace_members(self, workspace_id: UUID) -> list[domain.WorkspaceUser]: ...
|
||||||
|
|
||||||
|
async def get_workspace_member(self, workspace_user_id: UUID) -> domain.WorkspaceUser | None: ...
|
||||||
|
|
||||||
|
async def create_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: ...
|
||||||
|
|
||||||
|
async def update_workspace_invite(self, invite: domain.WorkspaceInvite) -> None: ...
|
||||||
|
|
||||||
|
async def get_workspace_invite(self, invite_id: UUID) -> domain.WorkspaceInvite | None: ...
|
||||||
|
|
||||||
|
async def get_workspace_invite_by_user(
|
||||||
|
self, workspace_id: UUID, user_id: UUID
|
||||||
|
) -> domain.WorkspaceInvite | None: ...
|
||||||
|
|
||||||
|
async def get_workspace_invites(self, workspace_id: UUID) -> list[domain.WorkspaceInvite]: ...
|
||||||
|
|
||||||
async def create_login_token(self, login_token: domain.LoginToken) -> None: ...
|
async def create_login_token(self, login_token: domain.LoginToken) -> None: ...
|
||||||
|
|
||||||
async def create_creative(self, creative: domain.Creative) -> None: ...
|
async def create_creative(self, creative: domain.Creative) -> None: ...
|
||||||
@@ -116,7 +152,9 @@ class Database(typing.Protocol):
|
|||||||
|
|
||||||
async def update_project(self, project: domain.Project) -> None: ...
|
async def update_project(self, project: domain.Project) -> None: ...
|
||||||
|
|
||||||
async def get_workspace_projects(self, workspace_id: UUID) -> list[domain.Project]: ...
|
async def get_workspace_projects(
|
||||||
|
self, workspace_id: UUID, allowed_project_ids: set[UUID] | None = None
|
||||||
|
) -> list[domain.Project]: ...
|
||||||
|
|
||||||
async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ...
|
async def get_active_purchase_plan(self, project_id: UUID) -> domain.PurchasePlan | None: ...
|
||||||
|
|
||||||
@@ -149,7 +187,11 @@ class Database(typing.Protocol):
|
|||||||
async def update_creative(self, creative: domain.Creative) -> None: ...
|
async def update_creative(self, creative: domain.Creative) -> None: ...
|
||||||
|
|
||||||
async def get_workspace_creatives(
|
async def get_workspace_creatives(
|
||||||
self, workspace_id: UUID, project_id: UUID | None = None, include_archived: bool = False
|
self,
|
||||||
|
workspace_id: UUID,
|
||||||
|
project_id: UUID | None = None,
|
||||||
|
include_archived: bool = False,
|
||||||
|
allowed_project_ids: set[UUID] | None = None,
|
||||||
) -> list[domain.Creative]: ...
|
) -> list[domain.Creative]: ...
|
||||||
|
|
||||||
async def delete_creative(self, creative_id: UUID) -> None: ...
|
async def delete_creative(self, creative_id: UUID) -> None: ...
|
||||||
@@ -167,6 +209,7 @@ class Database(typing.Protocol):
|
|||||||
placement_channel_id: UUID | None = None,
|
placement_channel_id: UUID | None = None,
|
||||||
creative_id: UUID | None = None,
|
creative_id: UUID | None = None,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
allowed_project_ids: set[UUID] | None = None,
|
||||||
) -> list[domain.Placement]: ...
|
) -> list[domain.Placement]: ...
|
||||||
|
|
||||||
async def delete_placement(self, placement_id: UUID) -> None: ...
|
async def delete_placement(self, placement_id: UUID) -> None: ...
|
||||||
@@ -239,6 +282,34 @@ class Usecase:
|
|||||||
|
|
||||||
return workspace
|
return workspace
|
||||||
|
|
||||||
|
async def ensure_workspace_permission(
|
||||||
|
self,
|
||||||
|
workspace_id: UUID,
|
||||||
|
user_id: UUID,
|
||||||
|
permission: domain.PermissionKey,
|
||||||
|
*,
|
||||||
|
for_project_id: UUID | None = None,
|
||||||
|
) -> domain.WorkspacePermissionContext:
|
||||||
|
membership = await self.database.get_workspace_membership(workspace_id, user_id)
|
||||||
|
if not membership or not membership.workspace:
|
||||||
|
raise domain.WorkspaceNotFound(workspace_id)
|
||||||
|
|
||||||
|
permissions = domain.WorkspacePermissions.from_membership(membership)
|
||||||
|
|
||||||
|
if for_project_id is not None:
|
||||||
|
has_permission = permissions.has_permission(
|
||||||
|
permission,
|
||||||
|
scope_type=domain.PermissionScopeType.PROJECT,
|
||||||
|
scope_id=for_project_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_permission = permissions.has_any(permission)
|
||||||
|
|
||||||
|
if not has_permission:
|
||||||
|
raise domain.WorkspaceAccessDenied(workspace_id)
|
||||||
|
|
||||||
|
return domain.build_workspace_permission_context(membership)
|
||||||
|
|
||||||
async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace:
|
async def get_or_create_personal_workspace(self, user: domain.User) -> domain.Workspace:
|
||||||
workspace = await self.database.get_default_workspace_for_user(user.id)
|
workspace = await self.database.get_default_workspace_for_user(user.id)
|
||||||
if workspace:
|
if workspace:
|
||||||
@@ -249,7 +320,12 @@ class Usecase:
|
|||||||
|
|
||||||
async with self.database.transaction():
|
async with self.database.transaction():
|
||||||
await self.database.create_workspace(workspace)
|
await self.database.create_workspace(workspace)
|
||||||
await self.database.add_user_to_workspace(workspace.id, user.id)
|
membership = await self.database.add_user_to_workspace(workspace.id, user.id)
|
||||||
|
await self.database.set_workspace_user_permissions(
|
||||||
|
membership.id,
|
||||||
|
global_permissions={domain.PermissionKey.ADMIN_FULL},
|
||||||
|
scoped_permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
return workspace
|
return workspace
|
||||||
|
|
||||||
@@ -301,3 +377,8 @@ class Usecase:
|
|||||||
create_workspace = create_workspace
|
create_workspace = create_workspace
|
||||||
update_workspace = update_workspace
|
update_workspace = update_workspace
|
||||||
delete_workspace = delete_workspace
|
delete_workspace = delete_workspace
|
||||||
|
get_workspace_members = get_workspace_members
|
||||||
|
update_workspace_member_permissions = update_workspace_member_permissions
|
||||||
|
create_workspace_invite = create_workspace_invite
|
||||||
|
get_workspace_invites = get_workspace_invites
|
||||||
|
tg_accept_workspace_invite = tg_accept_workspace_invite
|
||||||
|
|||||||
@@ -12,22 +12,42 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput:
|
async def get_channel_analytics(self: 'Usecase', input: dto.GetChannelAnalyticsInput) -> dto.GetChannelAnalyticsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||||
plan = await self.ensure_active_purchase_plan(project)
|
plan = await self.ensure_active_purchase_plan(project)
|
||||||
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
|
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
|
||||||
channels = [pc.channel for pc in plan_channels]
|
channels = [pc.channel for pc in plan_channels]
|
||||||
|
allowed_project_filter = None
|
||||||
else:
|
else:
|
||||||
|
allowed_project_filter = allowed_project_ids
|
||||||
|
if allowed_project_ids is None:
|
||||||
channels = await self.database.get_workspace_channels(input.workspace_id)
|
channels = await self.database.get_workspace_channels(input.workspace_id)
|
||||||
|
else:
|
||||||
|
channels = []
|
||||||
|
|
||||||
placements = await self.database.get_workspace_placements(
|
placements = await self.database.get_workspace_placements(
|
||||||
input.workspace_id, input.project_id, include_archived=False
|
input.workspace_id,
|
||||||
|
input.project_id,
|
||||||
|
include_archived=False,
|
||||||
|
allowed_project_ids=allowed_project_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if input.project_id is None and allowed_project_ids is not None:
|
||||||
|
channel_map: dict[UUID, domain.Channel] = {}
|
||||||
|
for placement in placements:
|
||||||
|
if placement.placement_channel:
|
||||||
|
channel_map[placement.placement_channel_id] = placement.placement_channel
|
||||||
|
channels = list(channel_map.values())
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChannelStats:
|
class ChannelStats:
|
||||||
total_cost: float = 0.0
|
total_cost: float = 0.0
|
||||||
|
|||||||
@@ -14,18 +14,30 @@ log = logging.getLogger(__name__)
|
|||||||
async def get_creatives_analytics(
|
async def get_creatives_analytics(
|
||||||
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
self: 'Usecase', input: dto.GetCreativesAnalyticsInput
|
||||||
) -> dto.GetCreativesAnalyticsOutput:
|
) -> dto.GetCreativesAnalyticsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||||
|
allowed_project_ids = None
|
||||||
|
|
||||||
creatives = await self.database.get_workspace_creatives(
|
creatives = await self.database.get_workspace_creatives(
|
||||||
input.workspace_id, input.project_id, include_archived=False
|
input.workspace_id,
|
||||||
|
input.project_id,
|
||||||
|
include_archived=False,
|
||||||
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
placements = await self.database.get_workspace_placements(
|
placements = await self.database.get_workspace_placements(
|
||||||
input.workspace_id, input.project_id, include_archived=False
|
input.workspace_id,
|
||||||
|
input.project_id,
|
||||||
|
include_archived=False,
|
||||||
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -24,15 +24,24 @@ def _calculate_cpm(cost: float | None, views: int | None) -> float | None:
|
|||||||
async def get_placements_analytics(
|
async def get_placements_analytics(
|
||||||
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
self: 'Usecase', input: dto.GetPlacementsAnalyticsInput
|
||||||
) -> dto.GetPlacementsAnalyticsOutput:
|
) -> dto.GetPlacementsAnalyticsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||||
|
allowed_project_ids = None
|
||||||
|
|
||||||
placements = await self.database.get_workspace_placements(
|
placements = await self.database.get_workspace_placements(
|
||||||
input.workspace_id, input.project_id, include_archived=False
|
input.workspace_id,
|
||||||
|
input.project_id,
|
||||||
|
include_archived=False,
|
||||||
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = [
|
result = [
|
||||||
|
|||||||
@@ -33,15 +33,24 @@ def _format_period(dt: 'datetime.datetime', grouping: dto.DateGrouping) -> str:
|
|||||||
async def get_spending_analytics(
|
async def get_spending_analytics(
|
||||||
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
self: 'Usecase', input: dto.GetSpendingAnalyticsInput
|
||||||
) -> dto.GetSpendingAnalyticsOutput:
|
) -> dto.GetSpendingAnalyticsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.ANALYTICS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.ANALYTICS_READ)
|
||||||
|
|
||||||
if input.project_id:
|
if input.project_id:
|
||||||
project = await self.database.get_project(input.workspace_id, input.project_id)
|
project = await self.database.get_project(input.workspace_id, input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.ANALYTICS_READ, project.id)
|
||||||
|
allowed_project_ids = None
|
||||||
|
|
||||||
placements = await self.database.get_workspace_placements(
|
placements = await self.database.get_workspace_placements(
|
||||||
input.workspace_id, input.project_id, include_archived=False
|
input.workspace_id,
|
||||||
|
input.project_id,
|
||||||
|
include_archived=False,
|
||||||
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
filtered = [
|
filtered = [
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ log = logging.getLogger(__name__)
|
|||||||
async def create_creative(
|
async def create_creative(
|
||||||
self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID, workspace_id: uuid.UUID
|
self: 'Usecase', input: dto.CreateCreativeInput, user_id: uuid.UUID, workspace_id: uuid.UUID
|
||||||
) -> dto.CreativeOutput:
|
) -> dto.CreativeOutput:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
await self.ensure_workspace_permission(
|
||||||
|
workspace_id,
|
||||||
|
user_id,
|
||||||
|
domain.PermissionKey.PROJECTS_WRITE,
|
||||||
|
for_project_id=input.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(workspace_id, project_id=input.project_id)
|
project = await self.database.get_project(workspace_id, project_id=input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
|
async def delete_creative(self: 'Usecase', input: dto.DeleteCreativeInput) -> None:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
||||||
if not creative:
|
if not creative:
|
||||||
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
|
log.warning('User %s attempted to delete unavailable creative %s', input.user_id, input.creative_id)
|
||||||
raise domain.CreativeNotFound(input.creative_id)
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
|
||||||
|
|
||||||
if creative.placements_count > 0:
|
if creative.placements_count > 0:
|
||||||
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
|
log.warning('Creative %s is used in placements and cannot be deleted', input.creative_id)
|
||||||
raise domain.CreativeInUse(input.creative_id)
|
raise domain.CreativeInUse(input.creative_id)
|
||||||
|
|||||||
@@ -10,12 +10,16 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
|
async def get_creative(self: 'Usecase', input: dto.GetCreativeInput) -> dto.CreativeOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
creative = await self.database.get_creative(input.workspace_id, input.creative_id)
|
||||||
if not creative:
|
if not creative:
|
||||||
raise domain.CreativeNotFound(input.creative_id)
|
raise domain.CreativeNotFound(input.creative_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, creative.project_id)
|
||||||
|
|
||||||
return dto.CreativeOutput(
|
return dto.CreativeOutput(
|
||||||
id=creative.id,
|
id=creative.id,
|
||||||
name=creative.name,
|
name=creative.name,
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import dto
|
from src import domain, dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
|
|
||||||
|
|
||||||
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput:
|
async def get_creatives(self: 'Usecase', input: dto.GetCreativesInput) -> dto.GetCreativesOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ)
|
||||||
|
|
||||||
|
if input.project_id is not None:
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PROJECTS_READ, input.project_id)
|
||||||
|
allowed_project_ids = None
|
||||||
|
|
||||||
creatives = await self.database.get_workspace_creatives(
|
creatives = await self.database.get_workspace_creatives(
|
||||||
input.workspace_id, input.project_id, input.include_archived
|
input.workspace_id,
|
||||||
|
input.project_id,
|
||||||
|
input.include_archived,
|
||||||
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
return dto.GetCreativesOutput(
|
return dto.GetCreativesOutput(
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ async def update_creative(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
) -> dto.CreativeOutput:
|
) -> dto.CreativeOutput:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
creative = await self.database.get_creative(workspace_id, creative_id)
|
creative = await self.database.get_creative(workspace_id, creative_id)
|
||||||
if not creative:
|
if not creative:
|
||||||
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
|
log.warning('User %s attempted to update unavailable creative %s', user_id, creative_id)
|
||||||
raise domain.CreativeNotFound(creative_id)
|
raise domain.CreativeNotFound(creative_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PROJECTS_WRITE, creative.project_id)
|
||||||
|
|
||||||
if input.name:
|
if input.name:
|
||||||
creative.name = input.name
|
creative.name = input.name
|
||||||
if input.text:
|
if input.text:
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ log = logging.getLogger(__name__)
|
|||||||
async def create_placement(
|
async def create_placement(
|
||||||
self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID
|
self: 'Usecase', input: dto.CreatePlacementInput, user_id: uuid.UUID, workspace_id: uuid.UUID
|
||||||
) -> dto.PlacementOutput:
|
) -> dto.PlacementOutput:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
await self.ensure_workspace_permission(
|
||||||
|
workspace_id,
|
||||||
|
user_id,
|
||||||
|
domain.PermissionKey.PLACEMENTS_WRITE,
|
||||||
|
for_project_id=input.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(workspace_id, project_id=input.project_id)
|
project = await self.database.get_project(workspace_id, project_id=input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None:
|
async def delete_placement(self: 'Usecase', input: dto.DeletePlacementInput) -> None:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
||||||
if not placement:
|
if not placement:
|
||||||
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
|
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
|
||||||
raise domain.PlacementNotFound(input.placement_id)
|
raise domain.PlacementNotFound(input.placement_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
|
||||||
|
|
||||||
if placement.status != domain.PlacementStatus.ACTIVE:
|
if placement.status != domain.PlacementStatus.ACTIVE:
|
||||||
await self.database.delete_placement(input.placement_id)
|
await self.database.delete_placement(input.placement_id)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
|
async def get_placement(self: 'Usecase', input: dto.GetPlacementInput) -> dto.PlacementOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
||||||
if not placement:
|
if not placement:
|
||||||
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
|
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
|
||||||
raise domain.PlacementNotFound(input.placement_id)
|
raise domain.PlacementNotFound(input.placement_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
|
||||||
|
|
||||||
return dto.PlacementOutput(
|
return dto.PlacementOutput(
|
||||||
id=placement.id,
|
id=placement.id,
|
||||||
project_id=placement.project_id,
|
project_id=placement.project_id,
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import dto
|
from src import domain, dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
|
|
||||||
|
|
||||||
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
|
async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.GetPlacementsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PLACEMENTS_READ)
|
||||||
|
|
||||||
|
if input.project_id is not None:
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, input.project_id)
|
||||||
|
allowed_project_ids = None
|
||||||
|
|
||||||
placements = await self.database.get_workspace_placements(
|
placements = await self.database.get_workspace_placements(
|
||||||
input.workspace_id,
|
input.workspace_id,
|
||||||
@@ -15,6 +23,7 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
|||||||
placement_channel_id=input.placement_channel_id,
|
placement_channel_id=input.placement_channel_id,
|
||||||
creative_id=input.creative_id,
|
creative_id=input.creative_id,
|
||||||
include_archived=input.include_archived,
|
include_archived=input.include_archived,
|
||||||
|
allowed_project_ids=allowed_project_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
return dto.GetPlacementsOutput(
|
return dto.GetPlacementsOutput(
|
||||||
|
|||||||
@@ -17,13 +17,17 @@ async def update_placement(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
) -> dto.PlacementOutput:
|
) -> dto.PlacementOutput:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
placement = await self.database.get_placement(workspace_id, placement_id)
|
placement = await self.database.get_placement(workspace_id, placement_id)
|
||||||
if not placement:
|
if not placement:
|
||||||
log.warning('Placement %s not found for user %s', placement_id, user_id)
|
log.warning('Placement %s not found for user %s', placement_id, user_id)
|
||||||
raise domain.PlacementNotFound(placement_id)
|
raise domain.PlacementNotFound(placement_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, placement.project_id)
|
||||||
|
|
||||||
if input.placement_date is not None:
|
if input.placement_date is not None:
|
||||||
placement.placement_date = input.placement_date
|
placement.placement_date = input.placement_date
|
||||||
if input.cost is not None:
|
if input.cost is not None:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from src import dto
|
from src import domain, dto
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
@@ -9,9 +9,15 @@ if TYPE_CHECKING:
|
|||||||
async def get_workspace_projects(
|
async def get_workspace_projects(
|
||||||
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
|
self: 'Usecase', input: dto.GetWorkspaceProjectsInput
|
||||||
) -> dto.GetWorkspaceProjectsOutput:
|
) -> dto.GetWorkspaceProjectsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PROJECTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
projects = await self.database.get_workspace_projects(input.workspace_id)
|
allowed_project_ids = context.allowed_project_ids(domain.PermissionKey.PROJECTS_READ)
|
||||||
|
|
||||||
|
projects = await self.database.get_workspace_projects(
|
||||||
|
input.workspace_id, allowed_project_ids=allowed_project_ids
|
||||||
|
)
|
||||||
|
|
||||||
return dto.GetWorkspaceProjectsOutput(
|
return dto.GetWorkspaceProjectsOutput(
|
||||||
projects=[
|
projects=[
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ async def attach_channel_to_purchase_plan(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
input: dto.AttachChannelToPurchasePlanInput,
|
input: dto.AttachChannelToPurchasePlanInput,
|
||||||
) -> dto.PurchasePlanChannelOutput:
|
) -> dto.PurchasePlanChannelOutput:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(project_id)
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
plan = await self.ensure_active_purchase_plan(project)
|
plan = await self.ensure_active_purchase_plan(project)
|
||||||
|
|
||||||
# Поиск канала по username
|
# Поиск канала по username
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ log = logging.getLogger(__name__)
|
|||||||
async def get_purchase_plan_channels(
|
async def get_purchase_plan_channels(
|
||||||
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
|
self: 'Usecase', input: dto.GetPurchasePlanChannelsInput
|
||||||
) -> dto.GetPurchasePlanChannelsOutput:
|
) -> dto.GetPurchasePlanChannelsOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
project = await self.database.get_project(input.workspace_id, project_id=input.project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(input.project_id)
|
raise domain.ProjectNotFound(input.project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
|
||||||
|
|
||||||
plan = await self.ensure_active_purchase_plan(project)
|
plan = await self.ensure_active_purchase_plan(project)
|
||||||
|
|
||||||
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
|
plan_channels = await self.database.get_purchase_plan_channels(plan.id)
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ async def remove_purchase_plan_channel(
|
|||||||
workspace_id: uuid.UUID,
|
workspace_id: uuid.UUID,
|
||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
) -> None:
|
) -> None:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(project_id)
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
plan = await self.ensure_active_purchase_plan(project)
|
plan = await self.ensure_active_purchase_plan(project)
|
||||||
|
|
||||||
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
|
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
|
||||||
|
|||||||
@@ -18,12 +18,16 @@ async def update_purchase_plan_channel(
|
|||||||
user_id: uuid.UUID,
|
user_id: uuid.UUID,
|
||||||
input: dto.UpdatePurchasePlanChannelInput,
|
input: dto.UpdatePurchasePlanChannelInput,
|
||||||
) -> dto.PurchasePlanChannelOutput:
|
) -> dto.PurchasePlanChannelOutput:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
workspace_id, user_id, domain.PermissionKey.PLACEMENTS_WRITE
|
||||||
|
)
|
||||||
|
|
||||||
project = await self.database.get_project(workspace_id, project_id=project_id)
|
project = await self.database.get_project(workspace_id, project_id=project_id)
|
||||||
if not project:
|
if not project:
|
||||||
raise domain.ProjectNotFound(project_id)
|
raise domain.ProjectNotFound(project_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_WRITE, project.id)
|
||||||
|
|
||||||
plan = await self.ensure_active_purchase_plan(project)
|
plan = await self.ensure_active_purchase_plan(project)
|
||||||
|
|
||||||
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
|
plan_channel = await self.database.get_purchase_plan_channel(channel_id, plan.id)
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput:
|
async def get_views_history(self: 'Usecase', input: dto.GetViewsHistoryInput) -> dto.GetViewsHistoryOutput:
|
||||||
await self.ensure_workspace_access(input.workspace_id, input.user_id)
|
context = await self.ensure_workspace_permission(
|
||||||
|
input.workspace_id, input.user_id, domain.PermissionKey.PLACEMENTS_READ
|
||||||
|
)
|
||||||
|
|
||||||
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
placement = await self.database.get_placement(input.workspace_id, input.placement_id)
|
||||||
if not placement:
|
if not placement:
|
||||||
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
|
log.warning('Placement %s not found for user %s', input.placement_id, input.user_id)
|
||||||
raise domain.PlacementNotFound(input.placement_id)
|
raise domain.PlacementNotFound(input.placement_id)
|
||||||
|
|
||||||
|
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, placement.project_id)
|
||||||
|
|
||||||
histories = await self.database.get_views_history(
|
histories = await self.database.get_views_history(
|
||||||
input.placement_id,
|
input.placement_id,
|
||||||
from_date=input.from_date,
|
from_date=input.from_date,
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ async def create_workspace(
|
|||||||
|
|
||||||
async with self.database.transaction():
|
async with self.database.transaction():
|
||||||
await self.database.create_workspace(workspace)
|
await self.database.create_workspace(workspace)
|
||||||
await self.database.add_user_to_workspace(workspace.id, user_id)
|
membership = await self.database.add_user_to_workspace(workspace.id, user_id)
|
||||||
|
await self.database.set_workspace_user_permissions(
|
||||||
|
membership.id,
|
||||||
|
global_permissions={domain.PermissionKey.ADMIN_FULL},
|
||||||
|
scoped_permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
return dto.CreateWorkspaceOutput(
|
return dto.CreateWorkspaceOutput(
|
||||||
id=workspace.id,
|
id=workspace.id,
|
||||||
|
|||||||
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
|
import uuid
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from src import domain
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
|
|
||||||
|
|
||||||
async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
async def delete_workspace(self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||||
await self.ensure_workspace_access(workspace_id, user_id)
|
await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
|
||||||
|
|
||||||
await self.database.delete_workspace(workspace_id)
|
await self.database.delete_workspace(workspace_id)
|
||||||
|
|||||||
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(
|
async def update_workspace(
|
||||||
self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput
|
self: 'Usecase', workspace_id: uuid.UUID, user_id: uuid.UUID, input: dto.UpdateWorkspaceInput
|
||||||
) -> dto.WorkspaceMembershipOutput:
|
) -> dto.WorkspaceMembershipOutput:
|
||||||
|
await self.ensure_workspace_permission(workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
|
||||||
|
|
||||||
membership = await self.database.get_workspace_membership(workspace_id, user_id)
|
membership = await self.database.get_workspace_membership(workspace_id, user_id)
|
||||||
if not membership:
|
if not membership:
|
||||||
raise domain.WorkspaceNotFound(workspace_id)
|
raise domain.WorkspaceNotFound(workspace_id)
|
||||||
|
|||||||
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