feat(workspace): restore member management routes
This commit is contained in:
@@ -7,18 +7,29 @@ after their persistence repositories are wired and smoke-tested.
|
||||
"""
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_user_repository, get_workspace_member_repository, get_workspace_repository
|
||||
from app.dependencies import (
|
||||
get_user_repository,
|
||||
get_workspace_invitation_repository,
|
||||
get_workspace_member_repository,
|
||||
get_workspace_repository,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.application.workspace.accept_invitation_use_case import AcceptInvitationRequest, AcceptInvitationUseCase
|
||||
from packages.application.workspace.create_workspace_use_case import CreateWorkspaceRequest, CreateWorkspaceUseCase
|
||||
from packages.application.workspace.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
|
||||
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
|
||||
from packages.application.workspace.list_workspaces_use_case import (
|
||||
GetWorkspaceDetailRequest,
|
||||
GetWorkspaceDetailUseCase,
|
||||
ListWorkspacesRequest,
|
||||
ListWorkspacesUseCase,
|
||||
)
|
||||
from packages.application.workspace.remove_member_use_case import RemoveMemberRequest, RemoveMemberUseCase
|
||||
from packages.application.workspace.update_member_role_use_case import UpdateMemberRoleRequest, UpdateMemberRoleUseCase
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
from packages.ports.workspace_repository import WorkspaceRepository
|
||||
|
||||
@@ -65,6 +76,54 @@ class WorkspaceDetailResponseModel(BaseModel):
|
||||
user_role: str
|
||||
|
||||
|
||||
class InviteMemberRequestModel(BaseModel):
|
||||
email: EmailStr
|
||||
role: str
|
||||
|
||||
|
||||
class InviteMemberResponseModel(BaseModel):
|
||||
invitation_id: str
|
||||
invitee_email: str
|
||||
role: str
|
||||
expires_at: str
|
||||
|
||||
|
||||
class MemberResponseModel(BaseModel):
|
||||
member_id: str
|
||||
user_id: str
|
||||
username: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
invited_by: str | None
|
||||
joined_at: str
|
||||
|
||||
|
||||
class MemberListResponseModel(BaseModel):
|
||||
members: list[MemberResponseModel]
|
||||
|
||||
|
||||
class UpdateMemberRoleRequestModel(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
class UpdateMemberRoleResponseModel(BaseModel):
|
||||
user_id: str
|
||||
old_role: str
|
||||
new_role: str
|
||||
|
||||
|
||||
class AcceptInvitationResponseModel(BaseModel):
|
||||
workspace_id: str
|
||||
workspace_name: str
|
||||
role: str
|
||||
|
||||
|
||||
class _NoopInvitationEmailService:
|
||||
def send_workspace_invitation_email(self, **kwargs):
|
||||
return False, "Email delivery is disabled for workspace compatibility routes"
|
||||
|
||||
|
||||
@router.post("", response_model=WorkspaceResponseModel, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workspace(
|
||||
request: CreateWorkspaceRequestModel,
|
||||
@@ -127,6 +186,31 @@ async def list_workspaces(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/invitations/{invitation_token}/accept", response_model=AcceptInvitationResponseModel)
|
||||
async def accept_invitation(
|
||||
invitation_token: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
response, error = AcceptInvitationUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
workspace_invitation_repository=workspace_invitation_repository,
|
||||
user_repository=user_repository,
|
||||
).execute(AcceptInvitationRequest(invitation_token=invitation_token, 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 accept invitation")
|
||||
|
||||
return AcceptInvitationResponseModel(
|
||||
workspace_id=response.workspace_id,
|
||||
workspace_name=response.workspace_name,
|
||||
role=response.role,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceDetailResponseModel)
|
||||
async def get_workspace_detail(
|
||||
workspace_id: str,
|
||||
@@ -156,3 +240,130 @@ async def get_workspace_detail(
|
||||
member_count=detail.member_count,
|
||||
user_role=detail.user_role,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workspace_id}/members/invite", response_model=InviteMemberResponseModel, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def invite_member(
|
||||
workspace_id: str,
|
||||
request: InviteMemberRequestModel,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
workspace_invitation_repository: WorkspaceInvitationRepository = Depends(get_workspace_invitation_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
):
|
||||
use_case = InviteMemberUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
workspace_invitation_repository=workspace_invitation_repository,
|
||||
user_repository=user_repository,
|
||||
email_service=_NoopInvitationEmailService(),
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
InviteMemberRequest(
|
||||
workspace_id=workspace_id,
|
||||
inviter_user_id=authenticated_user.user.id,
|
||||
invitee_email=request.email,
|
||||
role=request.role,
|
||||
)
|
||||
)
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to invite member")
|
||||
|
||||
return InviteMemberResponseModel(
|
||||
invitation_id=response.invitation_id,
|
||||
invitee_email=response.invitee_email,
|
||||
role=response.role,
|
||||
expires_at=response.expires_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{workspace_id}/members", response_model=MemberListResponseModel)
|
||||
async def list_members(
|
||||
workspace_id: str,
|
||||
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),
|
||||
):
|
||||
use_case = ListMembersUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
user_repository=user_repository,
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
ListMembersRequest(workspace_id=workspace_id, requester_user_id=authenticated_user.user.id)
|
||||
)
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=error or "Failed to list members")
|
||||
|
||||
return MemberListResponseModel(
|
||||
members=[
|
||||
MemberResponseModel(
|
||||
member_id=member.member_id,
|
||||
user_id=member.user_id,
|
||||
username=member.username,
|
||||
email=member.email,
|
||||
display_name=member.display_name,
|
||||
role=member.role,
|
||||
invited_by=member.invited_by,
|
||||
joined_at=member.joined_at.isoformat(),
|
||||
)
|
||||
for member in response.members
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{workspace_id}/members/{user_id}/role", response_model=UpdateMemberRoleResponseModel)
|
||||
async def update_member_role(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
request: UpdateMemberRoleRequestModel,
|
||||
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 = UpdateMemberRoleUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
)
|
||||
response, error = use_case.execute(
|
||||
UpdateMemberRoleRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=authenticated_user.user.id,
|
||||
target_user_id=user_id,
|
||||
new_role=request.role,
|
||||
)
|
||||
)
|
||||
if error or response is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to update member role")
|
||||
|
||||
return UpdateMemberRoleResponseModel(
|
||||
user_id=response.user_id,
|
||||
old_role=response.old_role,
|
||||
new_role=response.new_role,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{workspace_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
workspace_id: str,
|
||||
user_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
workspace_repository: WorkspaceRepository = Depends(get_workspace_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
):
|
||||
success, error = RemoveMemberUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
).execute(
|
||||
RemoveMemberRequest(
|
||||
workspace_id=workspace_id,
|
||||
requester_user_id=authenticated_user.user.id,
|
||||
target_user_id=user_id,
|
||||
)
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error or "Failed to remove member")
|
||||
|
||||
@@ -23,9 +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_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
|
||||
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_invitation_repository import WorkspaceInvitationRepository
|
||||
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
|
||||
from packages.ports.workspace_repository import WorkspaceRepository
|
||||
|
||||
@@ -98,3 +100,9 @@ def get_workspace_member_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceMemberRepository:
|
||||
return SQLAlchemyWorkspaceMemberRepository(session)
|
||||
|
||||
|
||||
def get_workspace_invitation_repository(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> WorkspaceInvitationRepository:
|
||||
return SQLAlchemyWorkspaceInvitationRepository(session)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import WorkspaceInvitationModel
|
||||
from packages.domain.entities import WorkspaceInvitation
|
||||
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
|
||||
|
||||
|
||||
class SQLAlchemyWorkspaceInvitationRepository(WorkspaceInvitationRepository):
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def save(self, invitation: WorkspaceInvitation) -> None:
|
||||
model = self.session.get(WorkspaceInvitationModel, invitation.id)
|
||||
if model is None:
|
||||
model = WorkspaceInvitationModel(id=invitation.id)
|
||||
self.session.add(model)
|
||||
|
||||
model.workspace_id = invitation.workspace_id
|
||||
model.inviter_user_id = invitation.inviter_user_id
|
||||
model.invitee_email = invitation.invitee_email.strip().lower()
|
||||
model.role = str(invitation.role)
|
||||
model.invitation_token = invitation.invitation_token
|
||||
model.status = str(invitation.status)
|
||||
model.expires_at = invitation.expires_at
|
||||
model.accepted_at = invitation.accepted_at
|
||||
model.created_at = invitation.created_at
|
||||
|
||||
self.session.commit()
|
||||
self.session.refresh(model)
|
||||
|
||||
def find_by_id(self, invitation_id: str) -> WorkspaceInvitation | None:
|
||||
return self._to_entity(self.session.get(WorkspaceInvitationModel, invitation_id))
|
||||
|
||||
def find_by_token(self, token: str) -> WorkspaceInvitation | None:
|
||||
model = (
|
||||
self.session.query(WorkspaceInvitationModel)
|
||||
.filter(WorkspaceInvitationModel.invitation_token == token)
|
||||
.first()
|
||||
)
|
||||
return self._to_entity(model)
|
||||
|
||||
def find_pending_by_workspace_and_email(self, workspace_id: str, email: str) -> WorkspaceInvitation | None:
|
||||
model = (
|
||||
self.session.query(WorkspaceInvitationModel)
|
||||
.filter(
|
||||
WorkspaceInvitationModel.workspace_id == workspace_id,
|
||||
WorkspaceInvitationModel.invitee_email == email.strip().lower(),
|
||||
WorkspaceInvitationModel.status == "pending",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return self._to_entity(model)
|
||||
|
||||
def delete(self, invitation_id: str) -> bool:
|
||||
model = self.session.get(WorkspaceInvitationModel, invitation_id)
|
||||
if model is None:
|
||||
return False
|
||||
self.session.delete(model)
|
||||
self.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _to_entity(model: WorkspaceInvitationModel | None) -> WorkspaceInvitation | None:
|
||||
if model is None:
|
||||
return None
|
||||
return WorkspaceInvitation(
|
||||
id=model.id,
|
||||
workspace_id=model.workspace_id,
|
||||
inviter_user_id=model.inviter_user_id,
|
||||
invitee_email=model.invitee_email,
|
||||
role=model.role,
|
||||
invitation_token=model.invitation_token,
|
||||
status=model.status,
|
||||
expires_at=model.expires_at,
|
||||
accepted_at=model.accepted_at,
|
||||
created_at=model.created_at,
|
||||
)
|
||||
@@ -74,7 +74,10 @@ class AcceptInvitationUseCase:
|
||||
return None, f"Invitation has already been {invitation.status}"
|
||||
|
||||
# 4. 检查是否过期
|
||||
if invitation.expires_at and datetime.now(timezone.utc) > invitation.expires_at:
|
||||
expires_at = invitation.expires_at
|
||||
if expires_at and expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at and datetime.now(timezone.utc) > expires_at:
|
||||
# 更新状态为过期
|
||||
invitation.status = InvitationStatus.EXPIRED
|
||||
self.workspace_invitation_repository.save(invitation)
|
||||
|
||||
@@ -137,6 +137,30 @@ class TestAcceptInvitationUseCase:
|
||||
assert valid_invitation.status == InvitationStatus.ACCEPTED
|
||||
assert valid_invitation.accepted_at is not None
|
||||
|
||||
def test_accept_invitation_success_with_naive_database_datetime(
|
||||
self,
|
||||
use_case,
|
||||
mock_workspace_repo,
|
||||
mock_member_repo,
|
||||
mock_invitation_repo,
|
||||
mock_user_repo,
|
||||
test_workspace,
|
||||
test_user,
|
||||
valid_invitation,
|
||||
):
|
||||
"""测试数据库返回 naive datetime 时仍可接受邀请"""
|
||||
valid_invitation.expires_at = (datetime.now(timezone.utc) + timedelta(days=7)).replace(tzinfo=None)
|
||||
mock_invitation_repo.find_by_token.return_value = valid_invitation
|
||||
mock_user_repo.find_by_id.return_value = test_user
|
||||
mock_workspace_repo.find_by_id.return_value = test_workspace
|
||||
mock_member_repo.find_by_workspace_and_user.return_value = None
|
||||
|
||||
response, error = use_case.execute(AcceptInvitationRequest(invitation_token="valid-token", user_id="user-123"))
|
||||
|
||||
assert error is None
|
||||
assert response is not None
|
||||
assert response.workspace_id == "workspace-123"
|
||||
|
||||
def test_accept_invitation_invalid_token(self, use_case, mock_invitation_repo):
|
||||
"""测试无效令牌"""
|
||||
mock_invitation_repo.find_by_token.return_value = None
|
||||
|
||||
@@ -3,19 +3,23 @@ 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_invitation_repository import SQLAlchemyWorkspaceInvitationRepository
|
||||
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.invite_member_use_case import InviteMemberRequest, InviteMemberUseCase
|
||||
from packages.application.workspace.list_members_use_case import ListMembersRequest, ListMembersUseCase
|
||||
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():
|
||||
def test_workspace_create_list_invite_and_members_with_sqlalchemy_repositories():
|
||||
session = _sqlite_session()
|
||||
user_repository = SQLAlchemyUserRepository(session)
|
||||
workspace_repository = SQLAlchemyWorkspaceRepository(session)
|
||||
workspace_member_repository = SQLAlchemyWorkspaceMemberRepository(session)
|
||||
workspace_invitation_repository = SQLAlchemyWorkspaceInvitationRepository(session)
|
||||
user_repository.save(
|
||||
User(
|
||||
id="user-1",
|
||||
@@ -48,6 +52,44 @@ def test_workspace_create_and_list_use_cases_with_sqlalchemy_repositories():
|
||||
assert list_response.workspaces[0].user_role == "owner"
|
||||
assert list_response.workspaces[0].member_count == 1
|
||||
|
||||
invite_response, invite_error = InviteMemberUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
workspace_invitation_repository=workspace_invitation_repository,
|
||||
user_repository=user_repository,
|
||||
email_service=_NoopEmailService(),
|
||||
).execute(
|
||||
InviteMemberRequest(
|
||||
workspace_id=create_response.workspace_id,
|
||||
inviter_user_id="user-1",
|
||||
invitee_email="Invitee@Example.com",
|
||||
role="member",
|
||||
)
|
||||
)
|
||||
|
||||
assert invite_error is None
|
||||
assert invite_response is not None
|
||||
assert invite_response.invitee_email == "invitee@example.com"
|
||||
assert workspace_invitation_repository.find_pending_by_workspace_and_email(
|
||||
create_response.workspace_id, "invitee@example.com"
|
||||
)
|
||||
|
||||
members_response, members_error = ListMembersUseCase(
|
||||
workspace_repository=workspace_repository,
|
||||
workspace_member_repository=workspace_member_repository,
|
||||
user_repository=user_repository,
|
||||
).execute(ListMembersRequest(workspace_id=create_response.workspace_id, requester_user_id="user-1"))
|
||||
|
||||
assert members_error is None
|
||||
assert members_response is not None
|
||||
assert len(members_response.members) == 1
|
||||
assert members_response.members[0].role == "owner"
|
||||
|
||||
|
||||
class _NoopEmailService:
|
||||
def send_workspace_invitation_email(self, **kwargs):
|
||||
return False, "disabled"
|
||||
|
||||
|
||||
def _sqlite_session():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
|
||||
Reference in New Issue
Block a user