feat(workspace): restore core routes on sqlalchemy adapters
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
"""Add workspace core tables.
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-06-21
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"workspaces",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("name", sa.String(length=100), nullable=False),
|
||||
sa.Column("owner_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("subscription_plan", sa.String(length=20), nullable=False),
|
||||
sa.Column("subscription_status", sa.String(length=20), nullable=False),
|
||||
sa.Column("subscription_expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("max_projects", sa.Float(), nullable=False),
|
||||
sa.Column("max_storage_gb", sa.Float(), nullable=False),
|
||||
sa.Column("used_storage_gb", sa.Float(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_workspaces_owner_user_id"), "workspaces", ["owner_user_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workspace_members",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("invited_by", sa.String(length=36), nullable=True),
|
||||
sa.Column("joined_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),
|
||||
)
|
||||
op.create_index(op.f("ix_workspace_members_user_id"), "workspace_members", ["user_id"], unique=False)
|
||||
op.create_index(op.f("ix_workspace_members_workspace_id"), "workspace_members", ["workspace_id"], unique=False)
|
||||
|
||||
op.create_table(
|
||||
"workspace_invitations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("workspace_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("inviter_user_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("invitee_email", sa.String(length=255), nullable=False),
|
||||
sa.Column("role", sa.String(length=20), nullable=False),
|
||||
sa.Column("invitation_token", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=20), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_invitation_token"),
|
||||
"workspace_invitations",
|
||||
["invitation_token"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_invitee_email"), "workspace_invitations", ["invitee_email"], unique=False
|
||||
)
|
||||
op.create_index(op.f("ix_workspace_invitations_status"), "workspace_invitations", ["status"], unique=False)
|
||||
op.create_index(
|
||||
op.f("ix_workspace_invitations_workspace_id"), "workspace_invitations", ["workspace_id"], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_workspace_invitations_workspace_id"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_status"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_invitee_email"), table_name="workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_invitations_invitation_token"), table_name="workspace_invitations")
|
||||
op.drop_table("workspace_invitations")
|
||||
op.drop_index(op.f("ix_workspace_members_workspace_id"), table_name="workspace_members")
|
||||
op.drop_index(op.f("ix_workspace_members_user_id"), table_name="workspace_members")
|
||||
op.drop_table("workspace_members")
|
||||
op.drop_index(op.f("ix_workspaces_owner_user_id"), table_name="workspaces")
|
||||
op.drop_table("workspaces")
|
||||
@@ -9,6 +9,7 @@ from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||
from app.api.routes.project_management import router as project_management_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.upload import router as upload_router
|
||||
from app.api.routes.workspaces import router as workspaces_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
@@ -19,6 +20,10 @@ api_router.include_router(
|
||||
auth_router,
|
||||
tags=["认证"],
|
||||
)
|
||||
api_router.include_router(
|
||||
workspaces_router,
|
||||
tags=["工作空间"],
|
||||
)
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
"""
|
||||
Legacy workspace API route skeleton.
|
||||
Workspace API routes wired through the canonical API composition root.
|
||||
|
||||
This module depends on the removed DI container and is intentionally disabled until
|
||||
workspace use cases are wired through the canonical API composition root.
|
||||
Only the core create/list/detail endpoints are enabled here. Member invitation
|
||||
and subscription endpoints remain in application use cases but are mounted only
|
||||
after their persistence repositories are wired and smoke-tested.
|
||||
"""
|
||||
|
||||
raise RuntimeError(
|
||||
"apps.api.app.api.routes.workspaces is disabled: rebuild DI container before mounting workspace routes"
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository, get_workspace_member_repository, get_workspace_repository
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from pydantic import BaseModel
|
||||
|
||||
from apps.api.app.dependencies import get_container
|
||||
from apps.api.app.middleware.auth import (
|
||||
get_current_user,
|
||||
require_workspace_access,
|
||||
require_workspace_admin,
|
||||
require_workspace_owner,
|
||||
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
|
||||
from packages.application.workspace.list_workspaces_use_case import (
|
||||
GetWorkspaceDetailRequest,
|
||||
GetWorkspaceDetailUseCase,
|
||||
ListWorkspacesRequest,
|
||||
ListWorkspacesUseCase,
|
||||
)
|
||||
from packages.application.workspace import *
|
||||
from packages.domain.entities import User
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
from packages.ports.workspace_repository import WorkspaceRepository
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["Workspaces"])
|
||||
|
||||
|
||||
# ==================== Request/Response Models ====================
|
||||
|
||||
|
||||
class CreateWorkspaceRequestModel(BaseModel):
|
||||
name: str
|
||||
subscription_plan: str = "free"
|
||||
@@ -44,41 +38,55 @@ class WorkspaceResponseModel(BaseModel):
|
||||
max_storage_gb: int
|
||||
|
||||
|
||||
class InviteMemberRequestModel(BaseModel):
|
||||
email: EmailStr
|
||||
role: str # admin, member, viewer
|
||||
class WorkspaceSummaryModel(BaseModel):
|
||||
workspace_id: str
|
||||
name: str
|
||||
subscription_plan: str
|
||||
max_projects: int
|
||||
max_storage_gb: int
|
||||
member_count: int
|
||||
user_role: str
|
||||
|
||||
|
||||
class UpdateMemberRoleRequestModel(BaseModel):
|
||||
role: str
|
||||
class WorkspaceListResponseModel(BaseModel):
|
||||
workspaces: list[WorkspaceSummaryModel]
|
||||
|
||||
|
||||
class UpgradeSubscriptionRequestModel(BaseModel):
|
||||
new_plan: str # pro, enterprise
|
||||
|
||||
|
||||
# ==================== Workspace CRUD ====================
|
||||
class WorkspaceDetailResponseModel(BaseModel):
|
||||
workspace_id: str
|
||||
name: str
|
||||
owner_user_id: str
|
||||
subscription_plan: str
|
||||
subscription_status: str
|
||||
max_projects: int
|
||||
max_storage_gb: int
|
||||
used_storage_gb: float
|
||||
member_count: int
|
||||
user_role: str
|
||||
|
||||
|
||||
@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
request: CreateWorkspaceRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
"""创建工作空间"""
|
||||
container = get_container()
|
||||
use_case = container.get_create_workspace_use_case()
|
||||
|
||||
req = CreateWorkspaceRequest(
|
||||
name=request.name,
|
||||
owner_user_id=current_user.id,
|
||||
subscription_plan=request.subscription_plan,
|
||||
use_case = CreateWorkspaceUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
user_repository=user_repository,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
response, error = use_case.execute(
|
||||
CreateWorkspaceRequest(
|
||||
name=request.name,
|
||||
owner_user_id=authenticated_user.user.id,
|
||||
subscription_plan=request.subscription_plan,
|
||||
)
|
||||
)
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to create workspace")
|
||||
|
||||
return WorkspaceResponseModel(
|
||||
workspace_id=response.workspace_id,
|
||||
@@ -89,306 +97,62 @@ async def create_workspace(
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_workspaces(current_user: User = Depends(get_current_user)):
|
||||
"""获取用户的所有工作空间"""
|
||||
container = get_container()
|
||||
use_case = container.get_list_workspaces_use_case()
|
||||
@router.get("", response_model=WorkspaceListResponseModel)
|
||||
async def list_workspaces(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
):
|
||||
use_case = ListWorkspacesUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
)
|
||||
response, error = use_case.execute(ListWorkspacesRequest(user_id=authenticated_user.user.id))
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to list workspaces")
|
||||
|
||||
req = ListWorkspacesRequest(user_id=current_user.id)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"workspaces": [
|
||||
{
|
||||
"workspace_id": ws.workspace_id,
|
||||
"name": ws.name,
|
||||
"subscription_plan": ws.subscription_plan,
|
||||
"max_projects": ws.max_projects,
|
||||
"max_storage_gb": ws.max_storage_gb,
|
||||
"member_count": ws.member_count,
|
||||
"user_role": ws.user_role,
|
||||
}
|
||||
for ws in response.workspaces
|
||||
return WorkspaceListResponseModel(
|
||||
workspaces=[
|
||||
WorkspaceSummaryModel(
|
||||
workspace_id=workspace.workspace_id,
|
||||
name=workspace.name,
|
||||
subscription_plan=workspace.subscription_plan,
|
||||
max_projects=workspace.max_projects,
|
||||
max_storage_gb=workspace.max_storage_gb,
|
||||
member_count=workspace.member_count,
|
||||
user_role=workspace.user_role,
|
||||
)
|
||||
for workspace in response.workspaces
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{workspace_id}")
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel)
|
||||
async def get_workspace_detail(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
):
|
||||
"""获取工作空间详情"""
|
||||
container = get_container()
|
||||
use_case = container.get_get_workspace_detail_use_case()
|
||||
|
||||
req = GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=current_user.id)
|
||||
detail, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
|
||||
|
||||
return {
|
||||
"workspace_id": detail.workspace_id,
|
||||
"name": detail.name,
|
||||
"owner_user_id": detail.owner_user_id,
|
||||
"subscription_plan": detail.subscription_plan,
|
||||
"subscription_status": detail.subscription_status,
|
||||
"max_projects": detail.max_projects,
|
||||
"max_storage_gb": detail.max_storage_gb,
|
||||
"used_storage_gb": detail.used_storage_gb,
|
||||
"member_count": detail.member_count,
|
||||
"user_role": detail.user_role,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Member Management ====================
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/members/invite", status_code=status.HTTP_201_CREATED)
|
||||
async def invite_member(
|
||||
workspace_id: str,
|
||||
request: InviteMemberRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""邀请成员"""
|
||||
container = get_container()
|
||||
use_case = container.get_invite_member_use_case()
|
||||
|
||||
req = InviteMemberRequest(
|
||||
workspace_id=workspace_id,
|
||||
inviter_user_id=current_user.id,
|
||||
invitee_email=request.email,
|
||||
role=request.role,
|
||||
use_case = GetWorkspaceDetailUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"invitation_id": response.invitation_id,
|
||||
"invitee_email": response.invitee_email,
|
||||
"role": response.role,
|
||||
"expires_at": response.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members")
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取成员列表"""
|
||||
container = get_container()
|
||||
use_case = container.get_list_members_use_case()
|
||||
|
||||
req = ListMembersRequest(workspace_id=workspace_id, requester_user_id=current_user.id)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error)
|
||||
|
||||
return {
|
||||
"members": [
|
||||
{
|
||||
"member_id": m.member_id,
|
||||
"user_id": m.user_id,
|
||||
"username": m.username,
|
||||
"email": m.email,
|
||||
"display_name": m.display_name,
|
||||
"role": m.role,
|
||||
"invited_by": m.invited_by,
|
||||
"joined_at": m.joined_at.isoformat(),
|
||||
}
|
||||
for m in response.members
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""移除成员"""
|
||||
container = get_container()
|
||||
use_case = container.get_remove_member_use_case()
|
||||
|
||||
req = RemoveMemberRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=current_user.id,
|
||||
target_user_id=user_id,
|
||||
detail, error = use_case.execute(
|
||||
GetWorkspaceDetailRequest(workspace_id=workspace_id, user_id=authenticated_user.user.id)
|
||||
)
|
||||
if error or detail is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Workspace access denied")
|
||||
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/leave", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def leave_workspace(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""离开工作空间"""
|
||||
container = get_container()
|
||||
use_case = container.get_leave_workspace_use_case()
|
||||
|
||||
req = LeaveWorkspaceRequest(workspace_id=workspace_id, user_id=current_user.id)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}/members/{user_id}/role")
|
||||
async def update_member_role(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
request: UpdateMemberRoleRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""修改成员角色"""
|
||||
container = get_container()
|
||||
use_case = container.get_update_member_role_use_case()
|
||||
|
||||
req = UpdateMemberRoleRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=current_user.id,
|
||||
target_user_id=user_id,
|
||||
new_role=request.role,
|
||||
return WorkspaceDetailResponseModel(
|
||||
workspace_id=detail.workspace_id,
|
||||
name=detail.name,
|
||||
owner_user_id=detail.owner_user_id,
|
||||
subscription_plan=detail.subscription_plan,
|
||||
subscription_status=detail.subscription_status,
|
||||
max_projects=detail.max_projects,
|
||||
max_storage_gb=detail.max_storage_gb,
|
||||
used_storage_gb=detail.used_storage_gb,
|
||||
member_count=detail.member_count,
|
||||
user_role=detail.user_role,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"user_id": response.user_id,
|
||||
"old_role": response.old_role,
|
||||
"new_role": response.new_role,
|
||||
}
|
||||
|
||||
|
||||
# ==================== Subscription Management ====================
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/subscription/upgrade")
|
||||
async def upgrade_subscription(
|
||||
workspace_id: str,
|
||||
request: UpgradeSubscriptionRequestModel,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""升级订阅"""
|
||||
container = get_container()
|
||||
use_case = container.get_upgrade_subscription_use_case()
|
||||
|
||||
req = UpgradeSubscriptionRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=current_user.id,
|
||||
new_plan=request.new_plan,
|
||||
)
|
||||
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"workspace_id": response.workspace_id,
|
||||
"old_plan": response.old_plan,
|
||||
"new_plan": response.new_plan,
|
||||
"max_projects": response.max_projects,
|
||||
"max_storage_gb": response.max_storage_gb,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{workspace_id}/subscription/cancel")
|
||||
async def cancel_subscription(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""取消订阅"""
|
||||
container = get_container()
|
||||
use_case = container.get_cancel_subscription_use_case()
|
||||
|
||||
req = CancelSubscriptionRequest(workspace_id=workspace_id, requester_user_id=current_user.id)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {"message": "Subscription cancelled successfully"}
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/quota")
|
||||
async def get_quota_status(
|
||||
workspace_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取配额状态"""
|
||||
container = get_container()
|
||||
quota_checker = container.quota_checker
|
||||
|
||||
# 检查权限
|
||||
permission_checker = container.permission_checker
|
||||
has_access, _ = permission_checker.check_workspace_access(workspace_id, current_user.id)
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
status = quota_checker.get_quota_status(workspace_id)
|
||||
|
||||
if not status:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||
|
||||
return status
|
||||
|
||||
|
||||
# ==================== Invitation Acceptance ====================
|
||||
|
||||
|
||||
@router.post("/invitations/{token}/accept")
|
||||
async def accept_invitation(
|
||||
token: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""接受邀请"""
|
||||
container = get_container()
|
||||
use_case = container.get_accept_invitation_use_case()
|
||||
|
||||
req = AcceptInvitationRequest(invitation_token=token, user_id=current_user.id)
|
||||
response, error = use_case.execute(req)
|
||||
|
||||
if error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {
|
||||
"workspace_id": response.workspace_id,
|
||||
"workspace_name": response.workspace_name,
|
||||
"role": response.role,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/invitations/{token}/decline")
|
||||
async def decline_invitation(token: str):
|
||||
"""拒绝邀请"""
|
||||
container = get_container()
|
||||
use_case = container.get_decline_invitation_use_case()
|
||||
|
||||
req = DeclineInvitationRequest(invitation_token=token)
|
||||
success, error = use_case.execute(req)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error)
|
||||
|
||||
return {"message": "Invitation declined"}
|
||||
|
||||
@@ -23,7 +23,11 @@ from packages.adapters.sqlalchemy_impl.project_repository import (
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.session import build_session_factory
|
||||
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
from packages.ports.workspace_repository import WorkspaceRepository
|
||||
|
||||
_engine, _SessionLocal = build_session_factory(settings.DATABASE_URL)
|
||||
|
||||
@@ -82,3 +86,15 @@ def get_user_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> UserRepository:
|
||||
return SQLAlchemyUserRepository(session)
|
||||
|
||||
|
||||
def get_workspace_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceRepository:
|
||||
return SQLAlchemyWorkspaceRepository(session)
|
||||
|
||||
|
||||
def get_workspace_member_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceMemberRepository:
|
||||
return SQLAlchemyWorkspaceMemberRepository(session)
|
||||
|
||||
@@ -9,12 +9,14 @@ from __future__ import annotations
|
||||
|
||||
from app.auth import AuthenticatedUser
|
||||
from app.auth import get_current_user as get_authenticated_user
|
||||
from app.dependencies import get_user_repository
|
||||
from app.dependencies import get_user_repository, get_workspace_member_repository
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.domain.entities import User
|
||||
from packages.domain.permissions import PermissionChecker
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
optional_bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -38,23 +40,37 @@ async def get_current_user_optional(
|
||||
return authenticated_user.user
|
||||
|
||||
|
||||
def require_workspace_access(workspace_id: str, user: User = Depends(get_current_user)) -> tuple[str, str]:
|
||||
raise _workspace_dependency_not_ready(workspace_id, user.id)
|
||||
def require_workspace_access(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> tuple[str, str]:
|
||||
has_access, role = PermissionChecker(workspace_member_repository).check_workspace_access(workspace_id, user.id)
|
||||
if not has_access or role is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this workspace")
|
||||
return workspace_id, role
|
||||
|
||||
|
||||
def require_workspace_admin(workspace_id: str, user: User = Depends(get_current_user)) -> str:
|
||||
raise _workspace_dependency_not_ready(workspace_id, user.id)
|
||||
def require_workspace_admin(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> str:
|
||||
if not PermissionChecker(workspace_member_repository).check_is_admin_or_owner(workspace_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only workspace owner or admin can perform this action",
|
||||
)
|
||||
return workspace_id
|
||||
|
||||
|
||||
def require_workspace_owner(workspace_id: str, user: User = Depends(get_current_user)) -> str:
|
||||
raise _workspace_dependency_not_ready(workspace_id, user.id)
|
||||
|
||||
|
||||
def _workspace_dependency_not_ready(workspace_id: str, user_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail=(
|
||||
"Workspace authorization dependencies require SQLAlchemy workspace-member "
|
||||
f"repository wiring before use: workspace_id={workspace_id}, user_id={user_id}"
|
||||
),
|
||||
)
|
||||
def require_workspace_owner(
|
||||
workspace_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> str:
|
||||
if not PermissionChecker(workspace_member_repository).check_is_owner(workspace_id, user.id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Only workspace owner can perform this action"
|
||||
)
|
||||
return workspace_id
|
||||
|
||||
@@ -1407,6 +1407,290 @@
|
||||
"primary_key": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"workspace_invitations": {
|
||||
"columns": [
|
||||
{
|
||||
"index": false,
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "workspace_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "inviter_user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "invitee_email",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(255)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "role",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "invitation_token",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(255)",
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "status",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "expires_at",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "accepted_at",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "created_at",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"columns": [
|
||||
"invitation_token"
|
||||
],
|
||||
"name": "ix_workspace_invitations_invitation_token",
|
||||
"unique": true
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"invitee_email"
|
||||
],
|
||||
"name": "ix_workspace_invitations_invitee_email",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"status"
|
||||
],
|
||||
"name": "ix_workspace_invitations_status",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"workspace_id"
|
||||
],
|
||||
"name": "ix_workspace_invitations_workspace_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"workspace_members": {
|
||||
"columns": [
|
||||
{
|
||||
"index": false,
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "workspace_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "role",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "invited_by",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "joined_at",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"name": "ix_workspace_members_user_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"workspace_id"
|
||||
],
|
||||
"name": "ix_workspace_members_workspace_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"workspaces": {
|
||||
"columns": [
|
||||
{
|
||||
"index": false,
|
||||
"name": "id",
|
||||
"nullable": false,
|
||||
"primary_key": true,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "name",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(100)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "owner_user_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(36)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "subscription_plan",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "subscription_status",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(20)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "subscription_expires_at",
|
||||
"nullable": true,
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "max_projects",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "FLOAT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "max_storage_gb",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "FLOAT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "used_storage_gb",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "FLOAT",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "created_at",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
{
|
||||
"columns": [
|
||||
"owner_user_id"
|
||||
],
|
||||
"name": "ix_workspaces_owner_user_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, String, Text, create_engine
|
||||
from sqlalchemy import Boolean, Column, DateTime, Float, String, Text, UniqueConstraint, create_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
@@ -23,6 +23,48 @@ class UserModel(Base):
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkspaceModel(Base):
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
owner_user_id = Column(String(36), nullable=False, index=True)
|
||||
subscription_plan = Column(String(20), nullable=False, default="free")
|
||||
subscription_status = Column(String(20), nullable=False, default="active")
|
||||
subscription_expires_at = Column(DateTime, nullable=True)
|
||||
max_projects = Column(Float, nullable=False, default=3)
|
||||
max_storage_gb = Column(Float, nullable=False, default=10)
|
||||
used_storage_gb = Column(Float, nullable=False, default=0.0)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkspaceMemberModel(Base):
|
||||
__tablename__ = "workspace_members"
|
||||
__table_args__ = (UniqueConstraint("workspace_id", "user_id", name="uq_workspace_members_workspace_user"),)
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
workspace_id = Column(String(36), nullable=False, index=True)
|
||||
user_id = Column(String(36), nullable=False, index=True)
|
||||
role = Column(String(20), nullable=False)
|
||||
invited_by = Column(String(36), nullable=True)
|
||||
joined_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class WorkspaceInvitationModel(Base):
|
||||
__tablename__ = "workspace_invitations"
|
||||
|
||||
id = Column(String(36), primary_key=True)
|
||||
workspace_id = Column(String(36), nullable=False, index=True)
|
||||
inviter_user_id = Column(String(36), nullable=False)
|
||||
invitee_email = Column(String(255), nullable=False, index=True)
|
||||
role = Column(String(20), nullable=False)
|
||||
invitation_token = Column(String(255), nullable=False, unique=True, index=True)
|
||||
status = Column(String(20), nullable=False, default="pending", index=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
accepted_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class ProjectModel(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import WorkspaceMemberModel
|
||||
from packages.domain.entities import WorkspaceMember
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
|
||||
|
||||
class SQLAlchemyWorkspaceMemberRepository(WorkspaceMemberRepository):
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def save(self, member: WorkspaceMember) -> None:
|
||||
model = self.session.get(WorkspaceMemberModel, member.id)
|
||||
if model is None:
|
||||
model = (
|
||||
self.session.query(WorkspaceMemberModel)
|
||||
.filter(
|
||||
WorkspaceMemberModel.workspace_id == member.workspace_id,
|
||||
WorkspaceMemberModel.user_id == member.user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if model is None:
|
||||
model = WorkspaceMemberModel(id=member.id)
|
||||
self.session.add(model)
|
||||
|
||||
model.workspace_id = member.workspace_id
|
||||
model.user_id = member.user_id
|
||||
model.role = str(member.role)
|
||||
model.invited_by = member.invited_by
|
||||
model.joined_at = member.joined_at
|
||||
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
|
||||
def find_by_id(self, member_id: str) -> WorkspaceMember | None:
|
||||
return self._to_entity(self.session.get(WorkspaceMemberModel, member_id))
|
||||
|
||||
def find_by_workspace_and_user(self, workspace_id: str, user_id: str) -> WorkspaceMember | None:
|
||||
model = (
|
||||
self.session.query(WorkspaceMemberModel)
|
||||
.filter(WorkspaceMemberModel.workspace_id == workspace_id, WorkspaceMemberModel.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
return self._to_entity(model)
|
||||
|
||||
def find_by_user(self, user_id: str) -> list[WorkspaceMember]:
|
||||
models = (
|
||||
self.session.query(WorkspaceMemberModel)
|
||||
.filter(WorkspaceMemberModel.user_id == user_id)
|
||||
.order_by(WorkspaceMemberModel.joined_at.desc())
|
||||
.all()
|
||||
)
|
||||
return [self._to_entity(model) for model in models if model is not None]
|
||||
|
||||
def find_by_workspace(self, workspace_id: str) -> list[WorkspaceMember]:
|
||||
models = (
|
||||
self.session.query(WorkspaceMemberModel)
|
||||
.filter(WorkspaceMemberModel.workspace_id == workspace_id)
|
||||
.order_by(WorkspaceMemberModel.joined_at.asc())
|
||||
.all()
|
||||
)
|
||||
return [self._to_entity(model) for model in models if model is not None]
|
||||
|
||||
def count_by_workspace(self, workspace_id: str) -> int:
|
||||
return (
|
||||
self.session.query(WorkspaceMemberModel).filter(WorkspaceMemberModel.workspace_id == workspace_id).count()
|
||||
)
|
||||
|
||||
def delete(self, member_id: str) -> bool:
|
||||
model = self.session.get(WorkspaceMemberModel, member_id)
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _to_entity(model: WorkspaceMemberModel | None) -> WorkspaceMember | None:
|
||||
if model is None:
|
||||
return None
|
||||
return WorkspaceMember(
|
||||
id=model.id,
|
||||
workspace_id=model.workspace_id,
|
||||
user_id=model.user_id,
|
||||
role=model.role,
|
||||
invited_by=model.invited_by,
|
||||
joined_at=model.joined_at,
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import WorkspaceModel
|
||||
from packages.domain.entities import Workspace
|
||||
from packages.ports.workspace_repository import WorkspaceRepository
|
||||
|
||||
|
||||
class SQLAlchemyWorkspaceRepository(WorkspaceRepository):
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def save(self, workspace: Workspace) -> None:
|
||||
model = self.session.get(WorkspaceModel, workspace.id)
|
||||
if model is None:
|
||||
model = WorkspaceModel(id=workspace.id)
|
||||
self.session.add(model)
|
||||
|
||||
model.name = workspace.name
|
||||
model.owner_user_id = workspace.owner_user_id
|
||||
model.subscription_plan = workspace.subscription_plan
|
||||
model.subscription_status = workspace.subscription_status
|
||||
model.subscription_expires_at = workspace.subscription_expires_at
|
||||
model.max_projects = workspace.max_projects
|
||||
model.max_storage_gb = workspace.max_storage_gb
|
||||
model.used_storage_gb = workspace.used_storage_gb
|
||||
model.created_at = workspace.created_at
|
||||
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
|
||||
def find_by_id(self, workspace_id: str) -> Workspace | None:
|
||||
return self._to_entity(self.session.get(WorkspaceModel, workspace_id))
|
||||
|
||||
def delete(self, workspace_id: str) -> bool:
|
||||
model = self.session.get(WorkspaceModel, workspace_id)
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _to_entity(model: WorkspaceModel | None) -> Workspace | None:
|
||||
if model is None:
|
||||
return None
|
||||
return Workspace(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
owner_user_id=model.owner_user_id,
|
||||
subscription_plan=model.subscription_plan,
|
||||
subscription_status=model.subscription_status,
|
||||
subscription_expires_at=model.subscription_expires_at,
|
||||
max_projects=int(model.max_projects),
|
||||
max_storage_gb=int(model.max_storage_gb),
|
||||
used_storage_gb=float(model.used_storage_gb),
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -80,12 +80,34 @@ def test_legacy_middleware_optional_user_returns_user_with_valid_credentials():
|
||||
assert user.id == "user-1"
|
||||
|
||||
|
||||
def test_workspace_dependency_fails_closed_until_repository_is_wired():
|
||||
with pytest.raises(HTTPException) as error:
|
||||
require_workspace_access("workspace-1", _repo_with_user().find_by_id("user-1"))
|
||||
def test_workspace_dependency_allows_member_access():
|
||||
repo = _WorkspaceMemberRepositoryStub(role="member")
|
||||
|
||||
assert error.value.status_code == 501
|
||||
assert "SQLAlchemy workspace-member repository" in error.value.detail
|
||||
workspace_id, role = require_workspace_access("workspace-1", _repo_with_user().find_by_id("user-1"), repo)
|
||||
|
||||
assert workspace_id == "workspace-1"
|
||||
assert role == "member"
|
||||
|
||||
|
||||
def test_workspace_dependency_rejects_non_member():
|
||||
with pytest.raises(HTTPException) as error:
|
||||
require_workspace_access(
|
||||
"workspace-1", _repo_with_user().find_by_id("user-1"), _WorkspaceMemberRepositoryStub()
|
||||
)
|
||||
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
class _WorkspaceMemberRepositoryStub:
|
||||
def __init__(self, role=None):
|
||||
self.role = role
|
||||
|
||||
def find_by_workspace_and_user(self, workspace_id, user_id):
|
||||
if self.role is None:
|
||||
return None
|
||||
from packages.domain.entities import WorkspaceMember
|
||||
|
||||
return WorkspaceMember(id="member-1", workspace_id=workspace_id, user_id=user_id, role=self.role)
|
||||
|
||||
|
||||
async def _authenticated_user():
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.adapters.sqlalchemy_impl.user_repository import SQLAlchemyUserRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_member_repository import SQLAlchemyWorkspaceMemberRepository
|
||||
from packages.adapters.sqlalchemy_impl.workspace_repository import SQLAlchemyWorkspaceRepository
|
||||
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
|
||||
from packages.application.workspace.list_workspaces_use_case import ListWorkspacesRequest, ListWorkspacesUseCase
|
||||
from packages.domain.auth import password_hasher
|
||||
from packages.domain.entities import User
|
||||
|
||||
|
||||
def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories():
|
||||
session = _sqlite_session()
|
||||
user_repository = SQLAlchemyUserRepository(session)
|
||||
workspace_repository = SQLAlchemyWorkspaceRepository(session)
|
||||
workspace_member_repository = SQLAlchemyWorkspaceMemberRepository(session)
|
||||
user_repository.save(
|
||||
User(
|
||||
id="user-1",
|
||||
email="user@example.com",
|
||||
username="user",
|
||||
display_name="User",
|
||||
password_hash=password_hasher.hash_password("Password1"),
|
||||
)
|
||||
)
|
||||
|
||||
create_response, create_error = CreateWorkspaceUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
user_repository=user_repository,
|
||||
).execute(CreateWorkspaceRequest(name="Main Workspace", owner_user_id="user-1", subscription_plan="free"))
|
||||
|
||||
assert create_error is None
|
||||
assert create_response is not None
|
||||
assert create_response.name == "Main Workspace"
|
||||
|
||||
list_response, list_error = ListWorkspacesUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
).execute(ListWorkspacesRequest(user_id="user-1"))
|
||||
|
||||
assert list_error is None
|
||||
assert list_response is not None
|
||||
assert len(list_response.workspaces) == 1
|
||||
assert list_response.workspaces[0].workspace_id == create_response.workspace_id
|
||||
assert list_response.workspaces[0].user_role == "owner"
|
||||
assert list_response.workspaces[0].member_count == 1
|
||||
|
||||
|
||||
def _sqlite_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
return session_factory()
|
||||
Reference in New Issue
Block a user