feat(workspace): restore core routes on sqlalchemy adapters
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 14s
Deploy / Deploy Staging (push) Successful in 1m53s
Deploy / Deploy Production (push) Has been skipped

This commit is contained in:
Xiaoxia AI
2026-06-21 09:22:09 +08:00
parent 2e863c8b43
commit 38b5b60160
11 changed files with 806 additions and 361 deletions
@@ -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")
+5
View File
@@ -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",
+102 -338
View File
@@ -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"}
+16
View File
@@ -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)
+33 -17
View File
@@ -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
+284
View File
@@ -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"
]
}
}
}
+43 -1
View File
@@ -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,
)
+27 -5
View File
@@ -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()