refactor: remove all workspace_id references from codebase
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled

- Remove workspace_id from Pydantic models in project_management routes
- Remove workspace_id from SQLAlchemy and SQLite project management repos
- Remove workspace_id from worker tasks (storage keys, entity creation)
- Remove workspace_id from video dedup and title usage modules
- Remove workspace_id from generation and ingest worker tasks
- Clean workspace_id from all test files and scripts
- Remove workspace-specific test files (list_workspaces, workspace repos)

Task: #14 workspace_id 残留清理
This commit is contained in:
API文档维护Agent
2026-06-27 22:52:09 +08:00
parent 8d4677ddee
commit e3fb518ab2
84 changed files with 56 additions and 2986 deletions
+2 -8
View File
@@ -1,18 +1,15 @@
from typing import Any
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.asset_diagnosis import AssetGapItem, AssetSmartViewItem, ProjectAssetDiagnosisResponse
from fastapi import APIRouter, Depends, HTTPException
from packages.domain import Asset, AssetLibraryKind, AssetStatus
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -37,7 +34,7 @@ def _readiness_label(score: int) -> str:
return "暂不建议生成"
def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
def _build_diagnosis(project_id: str, assets: list[Asset]) -> ProjectAssetDiagnosisResponse:
ready_assets = [asset for asset in assets if asset.status == AssetStatus.READY]
video_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VIDEO]
image_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.IMAGE]
@@ -175,7 +172,6 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) ->
]
return ProjectAssetDiagnosisResponse(
workspace_id=workspace_id,
project_id=project_id,
readiness_score=score,
readiness_label=_readiness_label(score),
@@ -201,16 +197,14 @@ def get_project_asset_diagnosis(
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectAssetDiagnosisResponse:
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
libraries = asset_library_repository.list_by_project(project_id)
assets: list[Asset] = []
for library in libraries:
assets.extend(asset_repository.list_by_library(library.id))
return _build_diagnosis(project.workspace_id, project_id, assets)
return _build_diagnosis(project_id, assets)
+4 -37
View File
@@ -21,7 +21,6 @@ from app.dependencies import (
get_asset_library_repository,
get_ingest_job_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.chunked_upload import (
ChunkedUploadCompleteRequest,
@@ -34,7 +33,6 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.params import File
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -92,19 +90,7 @@ def _atomic_check_and_record(upload_id: str, chunk_index: int) -> bool:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def _require_workspace_member(
workspace_id: str,
authenticated_user: AuthenticatedUser,
workspace_member_repository: WorkspaceMemberRepository,
) -> None:
"""Verify user has workspace permission"""
member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied")
def _require_project_and_library(
workspace_id: str,
project_id: str,
library_id: str,
project_repository: Any,
@@ -112,11 +98,11 @@ def _require_project_and_library(
) -> None:
"""Verify project and asset library exist"""
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None or project.workspace_id != workspace_id:
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.list_by_project(project_id)
if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries):
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
@@ -193,7 +179,6 @@ async def init_chunked_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ChunkedUploadInitResponse:
"""Initialize chunked upload"""
settings = get_settings()
@@ -205,17 +190,13 @@ async def init_chunked_upload(
detail=f"File exceeds maximum size ({MAX_FILE_SIZE // (1024 * 1024 * 1024)}GB)",
)
# Validate project exists and get workspace_id
# Validate project exists
project = GetProjectUseCase(project_repository).execute(request.project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
workspace_id = project.workspace_id
# Verify permission and asset library
_require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
# Verify asset library
_require_project_and_library(
workspace_id,
request.project_id,
request.library_id,
project_repository,
@@ -253,7 +234,6 @@ async def init_chunked_upload(
"content_type": request.content_type,
"project_id": request.project_id,
"library_id": request.library_id,
"workspace_id": workspace_id,
"status": "pending",
"created_at": now.isoformat(),
"expires_at": expires_at.isoformat(),
@@ -275,7 +255,6 @@ async def upload_chunk(
chunk_index: int,
chunk: UploadFile = File(..., description="Chunk data"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> dict[str, Any]:
"""Upload a single chunk"""
# Load metadata
@@ -296,9 +275,6 @@ async def upload_chunk(
detail=f"Invalid chunk index. Must be between 0 and {meta['total_chunks'] - 1}",
)
# Verify permission
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
# Atomic check and record to prevent race conditions
if not _atomic_check_and_record(upload_id, chunk_index):
return {"message": "Chunk already uploaded", "chunk_index": chunk_index}
@@ -350,14 +326,10 @@ async def upload_chunk(
async def get_upload_status(
upload_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ChunkedUploadStatusResponse:
"""Get upload status (for resume)"""
meta = _load_upload_meta(upload_id)
# Verify permission
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
return ChunkedUploadStatusResponse(
upload_id=upload_id,
filename=meta["filename"],
@@ -377,7 +349,6 @@ async def complete_chunked_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ChunkedUploadCompleteResponse:
@@ -385,9 +356,6 @@ async def complete_chunked_upload(
# Load metadata
meta = _load_upload_meta(upload_id)
# Verify permission
_require_workspace_member(meta["workspace_id"], authenticated_user, workspace_member_repository)
# Verify project ID and library ID
if request.project_id != meta["project_id"] or request.library_id != meta["library_id"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Project or library ID mismatch")
@@ -448,7 +416,6 @@ async def complete_chunked_upload(
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id=meta["workspace_id"],
project_id=meta["project_id"],
library_id=meta["library_id"],
storage_key=storage_key,
+10 -28
View File
@@ -1,13 +1,11 @@
from datetime import datetime, timezone
from uuid import uuid4
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_asset_repository,
get_db_session,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.edit_plan import (
AutoGenerateEditPlanRequest,
@@ -22,20 +20,18 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, EditPlanModel, EditTemplateModel
from packages.domain import AssetStatus
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter(prefix="/projects/{project_id}/edit-plans", tags=["剪辑计划"])
def _ensure_project(project_id: str, workspace_id: str, user: AuthenticatedUser, project_repository, member_repository):
def _ensure_project(project_id: str, project_repository):
project = project_repository.find_by_id(project_id)
if project is None or project.workspace_id != workspace_id:
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
require_workspace_member(workspace_id, user, member_repository)
return project
def _default_template(session: Session, workspace_id: str, project_id: str, user_id: str) -> EditTemplateModel:
def _default_template(session: Session, project_id: str, user_id: str) -> EditTemplateModel:
template = (
session.query(EditTemplateModel)
.filter(
@@ -49,7 +45,6 @@ def _default_template(session: Session, workspace_id: str, project_id: str, user
return template
template = EditTemplateModel(
id=uuid4().hex,
workspace_id=workspace_id,
project_id=project_id,
name="基础节奏模板",
description="自动选择可用视频素材,按上传顺序生成三段式剪辑计划。",
@@ -65,7 +60,6 @@ def _default_template(session: Session, workspace_id: str, project_id: str, user
def _to_template_response(template: EditTemplateModel) -> EditTemplateResponse:
return EditTemplateResponse(
id=template.id,
workspace_id=template.workspace_id,
project_id=template.project_id,
name=template.name,
description=template.description,
@@ -81,7 +75,6 @@ def _to_plan_response(
) -> EditPlanResponse:
return EditPlanResponse(
id=plan.id,
workspace_id=plan.workspace_id,
project_id=plan.project_id,
template_id=plan.template_id,
asset_library_id=plan.asset_library_id,
@@ -110,14 +103,12 @@ def _to_plan_response(
@router.get("/templates/", response_model=list[EditTemplateResponse])
def list_edit_templates(
project_id: str,
workspace_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> list[EditTemplateResponse]:
_ensure_project(project_id, workspace_id, authenticated_user, project_repository, workspace_member_repository)
template = _default_template(session, workspace_id, project_id, authenticated_user.user.id)
_ensure_project(project_id, project_repository)
template = _default_template(session, project_id, authenticated_user.user.id)
templates = (
session.query(EditTemplateModel)
.filter(EditTemplateModel.project_id == project_id, EditTemplateModel.is_active.is_(True))
@@ -133,19 +124,16 @@ def create_edit_plan(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
_ensure_project(
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
)
_ensure_project(project_id, project_repository)
template = (
session.query(EditTemplateModel).filter(EditTemplateModel.id == request.template_id).first()
if request.template_id
else None
)
if template is None:
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
template = _default_template(session, project_id, authenticated_user.user.id)
assets = [
asset
for asset in asset_repository.list_by_library(request.asset_library_id)
@@ -158,7 +146,6 @@ def create_edit_plan(
]
plan = EditPlanModel(
id=uuid4().hex,
workspace_id=request.workspace_id,
project_id=project_id,
template_id=template.id,
asset_library_id=request.asset_library_id,
@@ -194,7 +181,6 @@ def auto_generate_edit_plan(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
"""
@@ -207,9 +193,7 @@ def auto_generate_edit_plan(
- voice_over: person 类素材为主播口播,其余穿插为 B-roll
- voice_pip: 结合 voice_over 和 pip,第一个高质量 person 素材为主画面
"""
_ensure_project(
project_id, request.workspace_id, authenticated_user, project_repository, workspace_member_repository
)
_ensure_project(project_id, project_repository)
# 获取素材库中的所有素材
assets = asset_repository.list_by_library(request.asset_library_id)
@@ -238,12 +222,11 @@ def auto_generate_edit_plan(
else None
)
if template is None:
template = _default_template(session, request.workspace_id, project_id, authenticated_user.user.id)
template = _default_template(session, project_id, authenticated_user.user.id)
# 创建剪辑计划
plan = EditPlanModel(
id=uuid4().hex,
workspace_id=request.workspace_id,
project_id=project_id,
template_id=template.id,
asset_library_id=request.asset_library_id,
@@ -286,7 +269,6 @@ def get_edit_plan(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository=Depends(get_project_repository),
asset_repository=Depends(get_asset_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
session: Session = Depends(get_db_session),
) -> EditPlanResponse:
plan = (
@@ -294,7 +276,7 @@ def get_edit_plan(
)
if plan is None:
raise HTTPException(status_code=404, detail="Edit plan not found")
_ensure_project(project_id, plan.workspace_id, authenticated_user, project_repository, workspace_member_repository)
_ensure_project(project_id, project_repository)
clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.edit_plan_id == plan.id)
+1 -12
View File
@@ -1,9 +1,8 @@
from typing import Any
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import get_generated_video_repository, get_project_repository, get_workspace_member_repository
from app.dependencies import get_generated_video_repository, get_project_repository
from app.schemas.generated_video import (
GeneratedVideoDownloadUrlResponse,
GeneratedVideoResponse,
@@ -17,7 +16,6 @@ from packages.application import (
GetGeneratedVideoUseCase,
ListGeneratedVideosUseCase,
)
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -25,7 +23,6 @@ router = APIRouter()
def _to_generated_video_response(item, download_url: str | None = None) -> GeneratedVideoResponse:
return GeneratedVideoResponse(
id=item.id,
workspace_id=item.workspace_id,
project_id=item.project_id,
generation_task_id=item.generation_task_id,
name=item.name,
@@ -49,13 +46,11 @@ def list_generated_videos(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
project_repository: Any = Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> ListGeneratedVideosResponse:
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
use_case = ListGeneratedVideosUseCase(generated_video_repository)
items = use_case.execute(project_id)
# Generate download URLs for each video
@@ -71,14 +66,12 @@ def get_generated_video(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
use_case = GetGeneratedVideoUseCase(generated_video_repository)
item = use_case.execute(video_id)
if item is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
download_url = storage_service.get_download_url(item.file_url)
return _to_generated_video_response(item, download_url=download_url)
@@ -89,13 +82,11 @@ def update_generated_video_review_status(
request: UpdateGeneratedVideoReviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
video.review_status = request.review_status
updated = generated_video_repository.update(video)
download_url = storage_service.get_download_url(updated.file_url)
@@ -107,13 +98,11 @@ def get_generated_video_download_url(
video_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generated_video_repository: Any = Depends(get_generated_video_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> GeneratedVideoDownloadUrlResponse:
video = generated_video_repository.get(video_id)
if video is None:
raise HTTPException(status_code=404, detail=f"GeneratedVideo {video_id} not found")
require_workspace_member(video.workspace_id, authenticated_user, workspace_member_repository)
use_case = GetGeneratedVideoDownloadUrlUseCase(generated_video_repository)
file_url = use_case.execute(video_id)
if file_url is None:
+2 -8
View File
@@ -1,8 +1,2 @@
# Compatibility module - workspace concept has been removed
# This is a stub to maintain backward compatibility
def require_workspace_member(workspace_id, authenticated_user, workspace_member_repository):
"""Stub function for backward compatibility.
Workspace concept has been removed.
"""
pass
# Compatibility module - workspace concept has been removed.
# All permission checks are handled at the project level (see packages.domain.permissions).
@@ -51,7 +51,6 @@ def get_issue_repo():
class CreateTaskRequest(BaseModel):
project_id: str
workspace_id: str = ""
name: str
description: str = ""
priority: TaskPriority = TaskPriority.MEDIUM
@@ -62,7 +61,6 @@ class CreateTaskRequest(BaseModel):
class TaskResponse(BaseModel):
id: str
project_id: str
workspace_id: str = ""
name: str
description: str
status: TaskStatus
@@ -96,7 +94,6 @@ class UpdateTaskProgressRequest(BaseModel):
class CreateMilestoneRequest(BaseModel):
project_id: str
workspace_id: str = ""
name: str
description: str = ""
@@ -104,7 +101,6 @@ class CreateMilestoneRequest(BaseModel):
class MilestoneResponse(BaseModel):
id: str
project_id: str
workspace_id: str = ""
name: str
description: str
target_date: datetime | None
@@ -117,7 +113,6 @@ class MilestoneResponse(BaseModel):
class CreateTaskIssueRequest(BaseModel):
task_id: str
project_id: str
workspace_id: str = ""
title: str
description: str = ""
created_by_user_id: str = ""
@@ -127,7 +122,6 @@ class TaskIssueResponse(BaseModel):
id: str
task_id: str
project_id: str
workspace_id: str = ""
title: str
description: str
resolved: bool
@@ -149,7 +143,6 @@ def create_task(
use_case = CreateTaskUseCase(task_repo)
task = use_case.execute(
project_id=req.project_id,
workspace_id=req.workspace_id,
name=req.name,
description=req.description,
priority=req.priority,
@@ -159,7 +152,6 @@ def create_task(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -189,7 +181,6 @@ def list_tasks(
TaskResponse(
id=t.id,
project_id=t.project_id,
workspace_id=t.workspace_id,
name=t.name,
description=t.description,
status=t.status,
@@ -223,7 +214,6 @@ def get_task(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -262,7 +252,6 @@ def update_task(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -295,7 +284,6 @@ def update_task_status(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -328,7 +316,6 @@ def update_task_progress(
return TaskResponse(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status,
@@ -358,14 +345,12 @@ def create_milestone(
use_case = CreateMilestoneUseCase(milestone_repo)
milestone = use_case.execute(
project_id=req.project_id,
workspace_id=req.workspace_id,
name=req.name,
description=req.description,
)
return MilestoneResponse(
id=milestone.id,
project_id=milestone.project_id,
workspace_id=milestone.workspace_id,
name=milestone.name,
description=milestone.description,
target_date=milestone.target_date,
@@ -388,7 +373,6 @@ def list_milestones(
MilestoneResponse(
id=m.id,
project_id=m.project_id,
workspace_id=m.workspace_id,
name=m.name,
description=m.description,
target_date=m.target_date,
@@ -414,7 +398,6 @@ def create_issue(
issue = use_case.execute(
task_id=req.task_id,
project_id=req.project_id,
workspace_id=req.workspace_id,
title=req.title,
description=req.description,
created_by_user_id=req.created_by_user_id,
@@ -423,7 +406,6 @@ def create_issue(
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
@@ -447,7 +429,6 @@ def list_issues(
id=i.id,
task_id=i.task_id,
project_id=i.project_id,
workspace_id=i.workspace_id,
title=i.title,
description=i.description,
resolved=i.resolved,
@@ -475,7 +456,6 @@ def resolve_issue(
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
+2 -16
View File
@@ -1,11 +1,9 @@
from typing import Any
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import (
get_project_repository,
get_project_title_repository,
get_workspace_member_repository,
)
from app.schemas.project_title import (
CreateProjectTitleRequest,
@@ -15,15 +13,12 @@ from app.schemas.project_title import (
)
from fastapi import APIRouter, Depends, HTTPException, status
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
def _to_response(item) -> ProjectTitleResponse:
return ProjectTitleResponse(
id=item.id,
workspace_id=item.workspace_id,
project_id=item.project_id,
text=item.text,
category=item.category,
@@ -49,10 +44,8 @@ def list_project_titles(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ListProjectTitlesResponse:
project = _get_project_or_404(project_id, project_repository)
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
_get_project_or_404(project_id, project_repository)
return ListProjectTitlesResponse(
items=[_to_response(item) for item in title_repository.list_by_project(project_id, active_only)]
)
@@ -65,14 +58,9 @@ def create_project_title(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTitleResponse:
project = _get_project_or_404(project_id, project_repository)
if project.workspace_id != request.workspace_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="workspace_id does not match project")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
_get_project_or_404(project_id, project_repository)
item = title_repository.create(
workspace_id=project.workspace_id,
project_id=project_id,
text=request.text,
category=request.category,
@@ -88,12 +76,10 @@ def update_project_title(
request: UpdateProjectTitleRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
title_repository: Any = Depends(get_project_title_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTitleResponse:
item = title_repository.get(title_id)
if item is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project title not found")
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
if request.text is not None:
item.text = request.text.strip()
if request.category is not None:
+4 -14
View File
@@ -1,13 +1,12 @@
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_project_repository, get_workspace_member_repository
from app.dependencies import get_project_repository
from app.schemas.project import (
CreateProjectRequest,
ListProjectsResponse,
ProjectResponse,
)
from app.api.routes.permissions import require_workspace_member
from fastapi import APIRouter, Depends, HTTPException, status
from packages.application import (
@@ -16,7 +15,6 @@ from packages.application import (
GetProjectUseCase,
ListProjectsUseCase,
)
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -24,7 +22,6 @@ router = APIRouter()
def _to_project_response(item) -> ProjectResponse:
return ProjectResponse(
id=item.id,
workspace_id=item.workspace_id,
name=item.name,
description=item.description,
)
@@ -35,26 +32,21 @@ def get_project(
project_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectResponse:
use_case = GetProjectUseCase(project_repository)
project = use_case.execute(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
return _to_project_response(project)
@router.get("", response_model=ListProjectsResponse)
def list_projects(
workspace_id: str = "",
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ListProjectsResponse:
require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
use_case = ListProjectsUseCase(project_repository)
projects = use_case.execute(workspace_id)
projects = use_case.execute(authenticated_user.user.id)
return ListProjectsResponse(items=[_to_project_response(item) for item in projects])
@@ -63,15 +55,13 @@ def create_project(
request: CreateProjectRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectResponse:
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
use_case = CreateProjectUseCase(project_repository)
project = use_case.execute(
CreateProjectCommand(
workspace_id=request.workspace_id,
name=request.name,
description=request.description,
)
),
owner_user_id=authenticated_user.user.id,
)
return _to_project_response(project)
-14
View File
@@ -1,13 +1,11 @@
from typing import Any
from app.api.routes.permissions import require_workspace_member
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.dependencies import (
get_generation_task_repository,
get_ingest_job_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.task_center import ListProjectTasksResponse, ProjectTaskResponse
from fastapi import APIRouter, Depends, HTTPException
@@ -18,7 +16,6 @@ from packages.application import (
SubmitIngestJobCommand,
SubmitIngestJobUseCase,
)
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -68,12 +65,10 @@ def list_project_tasks(
project_repository: Any = Depends(get_project_repository),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ListProjectTasksResponse:
project = project_repository.find_by_id(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
require_workspace_member(project.workspace_id, authenticated_user, workspace_member_repository)
items: list[ProjectTaskResponse] = []
for job in ingest_job_repository.list_by_project(project_id):
@@ -81,7 +76,6 @@ def list_project_tasks(
ProjectTaskResponse(
id=f"ingest:{job.id}",
task_type="ingest",
workspace_id=job.workspace_id,
project_id=job.project_id,
status=job.status.value,
progress=100.0 if job.status.value == "completed" else 0.0,
@@ -99,7 +93,6 @@ def list_project_tasks(
ProjectTaskResponse(
id=f"generation:{task.id}",
task_type="generation",
workspace_id=task.workspace_id,
project_id=task.project_id,
status=task.status.value,
progress=task.progress,
@@ -123,19 +116,16 @@ def retry_project_task(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
ingest_job_repository: Any = Depends(get_ingest_job_repository),
generation_task_repository: Any = Depends(get_generation_task_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
) -> ProjectTaskResponse:
if task_type == "generation":
task = generation_task_repository.get(source_id)
if task is None:
raise HTTPException(status_code=404, detail="Generation task not found")
require_workspace_member(task.workspace_id, authenticated_user, workspace_member_repository)
if task.status.value != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = CreateGenerationTaskUseCase(generation_task_repository)
retried = use_case.execute(
CreateGenerationTaskCommand(
workspace_id=task.workspace_id,
project_id=task.project_id,
asset_library_id=task.asset_library_id,
strategy_id=task.strategy_id,
@@ -148,7 +138,6 @@ def retry_project_task(
return ProjectTaskResponse(
id=f"generation:{retried.id}",
task_type="generation",
workspace_id=retried.workspace_id,
project_id=retried.project_id,
status=retried.status.value,
progress=retried.progress,
@@ -161,13 +150,11 @@ def retry_project_task(
job = ingest_job_repository.get(source_id)
if job is None:
raise HTTPException(status_code=404, detail="Ingest job not found")
require_workspace_member(job.workspace_id, authenticated_user, workspace_member_repository)
if job.status.value != "failed":
raise HTTPException(status_code=409, detail="Only failed tasks can be retried")
use_case = SubmitIngestJobUseCase(ingest_job_repository)
retried = use_case.execute(
SubmitIngestJobCommand(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
storage_key=job.storage_key,
@@ -177,7 +164,6 @@ def retry_project_task(
return ProjectTaskResponse(
id=f"ingest:{retried.id}",
task_type="ingest",
workspace_id=retried.workspace_id,
project_id=retried.project_id,
status=retried.status.value,
progress=0,
+4 -21
View File
@@ -2,7 +2,6 @@ from typing import Any
from uuid import uuid4
from app.auth import AuthenticatedUser, get_current_user
from app.api.routes.permissions import require_workspace_member
from app.config import get_settings
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
@@ -10,7 +9,6 @@ from app.dependencies import (
get_asset_library_repository,
get_ingest_job_repository,
get_project_repository,
get_workspace_member_repository,
)
from app.schemas.upload import (
DirectUploadCompleteRequest,
@@ -25,7 +23,6 @@ from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
router = APIRouter()
@@ -64,23 +61,21 @@ def _validate_mime_type(content_type: str | None) -> str:
def _require_project_and_library(
workspace_id: str,
project_id: str,
library_id: str,
project_repository: Any,
asset_library_repository: Any,
) -> None:
project = GetProjectUseCase(project_repository).execute(project_id)
if project is None or project.workspace_id != workspace_id:
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
libraries = asset_library_repository.list_by_project(project_id)
if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries):
if not any(item.id == library_id for item in libraries):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
def _submit_ingest_job(
workspace_id: str,
project_id: str,
library_id: str,
storage_key: str,
@@ -89,7 +84,6 @@ def _submit_ingest_job(
use_case = SubmitIngestJobUseCase(ingest_job_repository)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id=workspace_id,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
@@ -105,7 +99,6 @@ async def prepare_direct_upload(
authenticated_user: AuthenticatedUser = Depends(get_current_user),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadPrepareResponse:
"""创建浏览器直传 OSS 的短期表单签名。"""
@@ -119,10 +112,8 @@ async def prepare_direct_upload(
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(request.content_type)
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
_require_project_and_library(
request.workspace_id,
request.project_id,
request.library_id,
project_repository,
@@ -159,13 +150,10 @@ async def complete_direct_upload(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> DirectUploadCompleteResponse:
"""确认浏览器直传完成并创建导入任务。"""
require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
_require_project_and_library(
request.workspace_id,
request.project_id,
request.library_id,
project_repository,
@@ -178,7 +166,6 @@ async def complete_direct_upload(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
job = _submit_ingest_job(
workspace_id=request.workspace_id,
project_id=request.project_id,
library_id=request.library_id,
storage_key=normalized_key,
@@ -200,15 +187,12 @@ async def upload_asset(
ingest_job_repository: Any = Depends(get_ingest_job_repository),
project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository),
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
storage_service: OSSStorageService = Depends(get_storage_service),
) -> UploadAssetResponse:
"""上传素材文件并触发导入流水线。"""
workspace_id = form_data.workspace_id
project_id = form_data.project_id
library_id = form_data.library_id
require_workspace_member(workspace_id, authenticated_user, workspace_member_repository)
_require_project_and_library(workspace_id, project_id, library_id, project_repository, asset_library_repository)
_require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
# P2-5: 服务端验证 MIME 类型
validated_content_type = _validate_mime_type(file.content_type)
@@ -224,7 +208,6 @@ async def upload_asset(
)
job = _submit_ingest_job(
workspace_id=workspace_id,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
-369
View File
@@ -1,369 +0,0 @@
"""
Workspace API routes 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.
"""
from app.auth import AuthenticatedUser, get_current_user
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, 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
router = APIRouter(prefix="/workspaces", tags=["Workspaces"])
class CreateWorkspaceRequestModel(BaseModel):
name: str
subscription_plan: str = "free"
class WorkspaceResponseModel(BaseModel):
workspace_id: str
name: str
subscription_plan: str
max_projects: int
max_storage_gb: int
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 WorkspaceListResponseModel(BaseModel):
workspaces: list[WorkspaceSummaryModel]
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
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,
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 = CreateWorkspaceUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
user_repository=user_repository,
)
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,
name=response.name,
subscription_plan=response.subscription_plan,
max_projects=response.max_projects,
max_storage_gb=response.max_storage_gb,
)
@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")
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.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,
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 = GetWorkspaceDetailUseCase(
workspace_repository=workspace_repository,
workspace_member_repository=workspace_member_repository,
)
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")
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,
)
@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")
-27
View File
@@ -38,15 +38,9 @@ from packages.adapters.sqlalchemy_impl.project_title_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.asset_repository import AssetRepository
from packages.ports.asset_library_repository import AssetLibraryRepository
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
from packages.ports.classification_job_repository import ClassificationJobRepository
from packages.ports.generation_task_repository import GenerationTaskRepository
from packages.ports.generated_video_repository import GeneratedVideoRepository
@@ -129,27 +123,6 @@ def get_user_repository(
return SQLAlchemyUserRepository(session)
def get_workspace_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceRepository:
"""Provide the SQLAlchemy workspace repository implementation."""
return SQLAlchemyWorkspaceRepository(session)
def get_workspace_member_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceMemberRepository:
"""Provide the SQLAlchemy workspace member repository implementation."""
return SQLAlchemyWorkspaceMemberRepository(session)
def get_workspace_invitation_repository(
session: Session = Depends(get_db_session),
) -> WorkspaceInvitationRepository:
"""Provide the SQLAlchemy workspace invitation repository implementation."""
return SQLAlchemyWorkspaceInvitationRepository(session)
def get_auth_session_store() -> SessionStore | NoopSessionStore:
"""Provide the session store based on configuration."""
if not settings.ENABLE_REDIS_SESSIONS:
+2 -40
View File
@@ -9,14 +9,12 @@ 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, get_workspace_member_repository
from fastapi import Depends, HTTPException, status
from app.dependencies import get_user_repository
from fastapi import Depends
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,39 +36,3 @@ async def get_current_user_optional(
except HTTPException:
return None
return authenticated_user.user
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),
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),
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
-1
View File
@@ -6,7 +6,6 @@ class UploadAssetRequest(BaseModel):
"""素材上传请求(multipart form"""
project_id: str = Field(..., min_length=1, description="项目 ID")
library_id: str = Field(..., min_length=1, description="素材库 ID")
workspace_id: str = Field(default="", description="工作空间 ID(已废弃,可传空)")
class UploadAssetResponse(BaseModel):
storage_key: str
+2 -3
View File
@@ -93,8 +93,8 @@ def generate_video(task_id: str) -> dict:
session.commit()
# 6. 上传到 OSS
storage_key = f"workspaces/{task.workspace_id}/projects/{task.project_id}/generated/{task.id}/{output_filename}"
thumbnail_key = f"workspaces/{task.workspace_id}/projects/{task.project_id}/generated/{task.id}/thumbnail.jpg"
storage_key = f"projects/{task.project_id}/generated/{task.id}/{output_filename}"
thumbnail_key = f"projects/{task.project_id}/generated/{task.id}/thumbnail.jpg"
storage_service.upload_file(result.output_path, storage_key)
storage_service.upload_file(result.thumbnail_path, thumbnail_key)
@@ -108,7 +108,6 @@ def generate_video(task_id: str) -> dict:
# 7. 创建 GeneratedVideo 记录
video = GeneratedVideo.create(
workspace_id=task.workspace_id,
project_id=task.project_id,
generation_task_id=task.id,
name=output_filename,
+1 -1
View File
@@ -154,7 +154,7 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict:
local_path = os.path.join(temp_dir, f"{generated_video_id}.mp4")
storage_key = video.file_url.split("/")[-1]
storage_service.download_file(f"workspaces/{video.workspace_id}/projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path)
storage_service.download_file(f"projects/{video.project_id}/generated/{generated_video_id}/{generated_video_id}.mp4", local_path)
fingerprint = deduplicator.compute_fingerprint(local_path)
+1 -1
View File
@@ -7,7 +7,7 @@ def mark_title_used_for_generation(db, task) -> None:
if not task.strategy_id:
return
title = db.query(ProjectTitleModel).filter(ProjectTitleModel.id == task.strategy_id).first()
if title is None or title.workspace_id != task.workspace_id or title.project_id != task.project_id:
if title is None or title.project_id != task.project_id:
return
title.usage_count = int(title.usage_count or 0) + 1
title.updated_at = datetime.now(timezone.utc)
+1 -2
View File
@@ -240,7 +240,6 @@ def generate_video(self, task_id: str) -> dict:
gen_task = task_repo.get(task_id)
if gen_task is None:
return {"status": "failed", "error": f"generation task {task_id} not found"}
workspace_id = gen_task.workspace_id
project_id = gen_task.project_id
asset_library_id = gen_task.asset_library_id
voice_library_id = gen_task.voice_library_id or ""
@@ -254,7 +253,7 @@ def generate_video(self, task_id: str) -> dict:
editing_mode = GenerationMode.ONE_TAKE
output_name = f"generated-{task_id}.mp4"
storage_key = f"generated/workspaces/{workspace_id}/projects/{project_id}/tasks/{task_id}/{output_name}"
storage_key = f"generated/projects/{project_id}/tasks/{task_id}/{output_name}"
try:
with tempfile.TemporaryDirectory(prefix="xiaoxia-generation-") as temp_dir:
-1
View File
@@ -151,7 +151,6 @@ def ingest_asset(job_id: str) -> dict:
# Create Asset
asset = Asset.create(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
name=filename,
@@ -1,69 +0,0 @@
"""
WorkspaceInvitation 仓储 In-Memory 实现
"""
from typing import Dict, Optional
from packages.domain.entities import InvitationStatus, WorkspaceInvitation
from packages.ports.workspace_invitation_repository import WorkspaceInvitationRepository
class InMemoryWorkspaceInvitationRepository(WorkspaceInvitationRepository):
"""WorkspaceInvitation 仓储内存实现"""
def __init__(self):
self._invitations: Dict[str, WorkspaceInvitation] = {}
self._token_index: Dict[str, str] = {} # token -> invitation_id
self._workspace_email_index: Dict[tuple[str, str], str] = {} # (workspace_id, email) -> invitation_id
def save(self, invitation: WorkspaceInvitation) -> None:
"""保存邀请"""
self._invitations[invitation.id] = invitation
self._token_index[invitation.invitation_token] = invitation.id
# 只为 pending 状态的邀请建立索引
if invitation.status == InvitationStatus.PENDING:
key = (invitation.workspace_id, invitation.invitee_email.lower())
self._workspace_email_index[key] = invitation.id
else:
# 如果状态改变,清理索引
key = (invitation.workspace_id, invitation.invitee_email.lower())
self._workspace_email_index.pop(key, None)
def find_by_id(self, invitation_id: str) -> Optional[WorkspaceInvitation]:
"""根据 ID 查找邀请"""
return self._invitations.get(invitation_id)
def find_by_token(self, token: str) -> Optional[WorkspaceInvitation]:
"""根据令牌查找邀请"""
invitation_id = self._token_index.get(token)
if invitation_id:
return self._invitations.get(invitation_id)
return None
def find_pending_by_workspace_and_email(
self,
workspace_id: str,
email: str,
) -> Optional[WorkspaceInvitation]:
"""查找 workspace 和邮箱的待处理邀请"""
key = (workspace_id, email.lower())
invitation_id = self._workspace_email_index.get(key)
if invitation_id:
return self._invitations.get(invitation_id)
return None
def delete(self, invitation_id: str) -> bool:
"""删除邀请"""
invitation = self._invitations.get(invitation_id)
if not invitation:
return False
# 清理索引
self._token_index.pop(invitation.invitation_token, None)
key = (invitation.workspace_id, invitation.invitee_email.lower())
self._workspace_email_index.pop(key, None)
# 删除邀请
del self._invitations[invitation_id]
return True
@@ -1,86 +0,0 @@
"""
WorkspaceMember 仓储 In-Memory 实现
"""
from typing import Dict, List, Optional
from packages.domain.entities import WorkspaceMember
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
class InMemoryWorkspaceMemberRepository(WorkspaceMemberRepository):
"""WorkspaceMember 仓储内存实现"""
def __init__(self):
self._members: Dict[str, WorkspaceMember] = {}
self._workspace_user_index: Dict[tuple[str, str], str] = {} # (workspace_id, user_id) -> member_id
self._user_index: Dict[str, List[str]] = {} # user_id -> [member_ids]
self._workspace_index: Dict[str, List[str]] = {} # workspace_id -> [member_ids]
def save(self, member: WorkspaceMember) -> None:
"""保存成员"""
self._members[member.id] = member
# 更新索引
key = (member.workspace_id, member.user_id)
self._workspace_user_index[key] = member.id
if member.user_id not in self._user_index:
self._user_index[member.user_id] = []
if member.id not in self._user_index[member.user_id]:
self._user_index[member.user_id].append(member.id)
if member.workspace_id not in self._workspace_index:
self._workspace_index[member.workspace_id] = []
if member.id not in self._workspace_index[member.workspace_id]:
self._workspace_index[member.workspace_id].append(member.id)
def find_by_id(self, member_id: str) -> Optional[WorkspaceMember]:
"""根据 ID 查找成员"""
return self._members.get(member_id)
def find_by_workspace_and_user(
self,
workspace_id: str,
user_id: str,
) -> Optional[WorkspaceMember]:
"""根据 workspace 和 user 查找成员"""
key = (workspace_id, user_id)
member_id = self._workspace_user_index.get(key)
if member_id:
return self._members.get(member_id)
return None
def find_by_user(self, user_id: str) -> List[WorkspaceMember]:
"""查找用户的所有成员记录"""
member_ids = self._user_index.get(user_id, [])
return [self._members[mid] for mid in member_ids if mid in self._members]
def find_by_workspace(self, workspace_id: str) -> List[WorkspaceMember]:
"""查找 workspace 的所有成员"""
member_ids = self._workspace_index.get(workspace_id, [])
return [self._members[mid] for mid in member_ids if mid in self._members]
def count_by_workspace(self, workspace_id: str) -> int:
"""统计 workspace 的成员数量"""
return len(self._workspace_index.get(workspace_id, []))
def delete(self, member_id: str) -> bool:
"""删除成员"""
member = self._members.get(member_id)
if not member:
return False
# 清理索引
key = (member.workspace_id, member.user_id)
self._workspace_user_index.pop(key, None)
if member.user_id in self._user_index:
self._user_index[member.user_id].remove(member_id)
if member.workspace_id in self._workspace_index:
self._workspace_index[member.workspace_id].remove(member_id)
# 删除成员
del self._members[member_id]
return True
@@ -1,30 +0,0 @@
"""
Workspace 仓储 In-Memory 实现
"""
from typing import Dict, Optional
from packages.domain.entities import Workspace
from packages.ports.workspace_repository import WorkspaceRepository
class InMemoryWorkspaceRepository(WorkspaceRepository):
"""Workspace 仓储内存实现"""
def __init__(self):
self._workspaces: Dict[str, Workspace] = {}
def save(self, workspace: Workspace) -> None:
"""保存 Workspace"""
self._workspaces[workspace.id] = workspace
def find_by_id(self, workspace_id: str) -> Optional[Workspace]:
"""根据 ID 查找 Workspace"""
return self._workspaces.get(workspace_id)
def delete(self, workspace_id: str) -> bool:
"""删除 Workspace"""
if workspace_id in self._workspaces:
del self._workspaces[workspace_id]
return True
return False
@@ -24,7 +24,6 @@ class SQLAlchemyTaskRepository(TaskRepository):
model = TaskModel(
id=task.id,
project_id=task.project_id,
workspace_id=task.workspace_id,
name=task.name,
description=task.description,
status=task.status.value,
@@ -90,7 +89,6 @@ class SQLAlchemyTaskRepository(TaskRepository):
return Task(
id=model.id,
project_id=model.project_id,
workspace_id=model.workspace_id,
name=model.name,
description=model.description,
status=TaskStatus(model.status),
@@ -118,7 +116,6 @@ class SQLAlchemyMilestoneRepository(MilestoneRepository):
model = MilestoneModel(
id=milestone.id,
project_id=milestone.project_id,
workspace_id=milestone.workspace_id,
name=milestone.name,
description=milestone.description,
target_date=milestone.target_date,
@@ -164,7 +161,6 @@ class SQLAlchemyMilestoneRepository(MilestoneRepository):
return Milestone(
id=model.id,
project_id=model.project_id,
workspace_id=model.workspace_id,
name=model.name,
description=model.description,
target_date=model.target_date,
@@ -186,7 +182,6 @@ class SQLAlchemyTaskIssueRepository(TaskIssueRepository):
id=issue.id,
task_id=issue.task_id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
title=issue.title,
description=issue.description,
resolved=issue.resolved,
@@ -236,7 +231,6 @@ class SQLAlchemyTaskIssueRepository(TaskIssueRepository):
id=model.id,
task_id=model.task_id,
project_id=model.project_id,
workspace_id=model.workspace_id,
title=model.title,
description=model.description,
resolved=model.resolved,
@@ -20,7 +20,6 @@ class SQLAlchemyProjectTitleRepository:
def create(
self,
*,
workspace_id: str,
project_id: str,
text: str,
category: str,
@@ -30,7 +29,6 @@ class SQLAlchemyProjectTitleRepository:
now = datetime.now(timezone.utc)
item = ProjectTitleModel(
id=uuid4().hex,
workspace_id=workspace_id,
project_id=project_id,
text=text.strip(),
category=category,
@@ -1,5 +0,0 @@
"""Stub adapter for workspace invitation repository - workspace concept removed."""
class SQLAlchemyWorkspaceInvitationRepository:
def __init__(self, session):
self.session = session
@@ -1,23 +0,0 @@
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sqlalchemy.orm import Session
from packages.ports.workspace_member_repository import WorkspaceMemberRepository
class SQLAlchemyWorkspaceMemberRepository(WorkspaceMemberRepository):
def __init__(self, session=None):
self.session = session
def save(self, member) -> None:
raise NotImplementedError("Workspace feature removed")
def find_by_id(self, member_id: str):
return None
def find_by_workspace_and_user(self, workspace_id: str, user_id: str):
return None
def find_by_user(self, user_id: str):
return []
def find_by_workspace(self, workspace_id: str):
return []
def count_by_workspace(self, workspace_id: str) -> int:
return 0
def delete(self, member_id: str) -> bool:
return False
@@ -1,5 +0,0 @@
"""Stub adapter for workspace repository - workspace concept removed."""
class SQLAlchemyWorkspaceRepository:
def __init__(self, session):
self.session = session
@@ -38,7 +38,6 @@ class SQLiteTaskRepository:
priority=(TaskPriority(row["priority"]) if row["priority"] else TaskPriority.MEDIUM),
progress=0, # tracker.db 没有 progress 字段
project_id=row["phase"] or "xiaoxia-saas",
workspace_id="xiaoxia-workspace",
assignee_user_id=row["assigned_to"] or "",
created_at=(datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now()),
updated_at=datetime.now(),
@@ -73,7 +72,6 @@ class SQLiteTaskRepository:
priority=(TaskPriority(row["priority"]) if row["priority"] else TaskPriority.MEDIUM),
progress=0,
project_id=row["phase"] or "xiaoxia-saas",
workspace_id="xiaoxia-workspace",
assignee_user_id=row["assigned_to"] or "",
created_at=(datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now()),
updated_at=datetime.now(),
@@ -148,7 +146,6 @@ class SQLiteMilestoneRepository:
description=row["description"] or "",
target_date=row["end_date"] or "",
project_id=row["phase"] or "xiaoxia-saas",
workspace_id="xiaoxia-workspace",
created_at=(datetime.fromisoformat(row["start_date"]) if row["start_date"] else datetime.now()),
)
)
+4 -7
View File
@@ -8,7 +8,7 @@ JWT 处理器委托层
from packages.application.auth.jwt_handler import JWTHandler, get_jwt_handler
jwt_handler = JWTHandler(secret_key="<YOUR_SECRET_KEY>")
token = jwt_handler.create_access_token(user_id="user123", workspace_id="ws456", role="admin")
token = jwt_handler.create_access_token(user_id="user123", role="admin")
payload = jwt_handler.verify_access_token(token)
"""
@@ -45,25 +45,22 @@ class JWTHandler:
def create_access_token(
self,
user_id: str,
workspace_id: str,
role: str,
role: str = "",
additional_claims: Optional[Dict[str, Any]] = None,
) -> str:
"""
创建 access_token
Args:
user_id: 用户 ID
workspace_id: 工作空间 ID
role: 用户角色
additional_claims: 额外的声明信息
Returns:
JWT Token 字符串
"""
return self._service.create_access_token(
user_id=user_id,
workspace_id=workspace_id,
role=role,
additional_claims=additional_claims,
)
+1 -4
View File
@@ -75,8 +75,7 @@ class JWTService:
def create_access_token(
self,
user_id: str,
workspace_id: str,
role: str,
role: str = "",
additional_claims: Optional[Dict[str, Any]] = None,
) -> str:
"""
@@ -84,7 +83,6 @@ class JWTService:
Args:
user_id: 用户 ID
workspace_id: 工作空间 ID
role: 用户角色(admin/user/guest
additional_claims: 额外的声明信息
@@ -98,7 +96,6 @@ class JWTService:
payload = {
"sub": user_id, # subject (用户ID)
"workspace_id": workspace_id,
"role": role,
"type": TokenType.ACCESS,
"iat": now, # issued at
@@ -13,7 +13,6 @@ class CreateTaskUseCase:
def execute(
self,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
priority: TaskPriority = TaskPriority.MEDIUM,
@@ -22,7 +21,6 @@ class CreateTaskUseCase:
) -> Task:
task = Task.create(
project_id=project_id,
workspace_id=workspace_id,
name=name,
description=description,
priority=priority,
@@ -79,13 +77,11 @@ class CreateMilestoneUseCase:
def execute(
self,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
) -> Milestone:
milestone = Milestone.create(
project_id=project_id,
workspace_id=workspace_id,
name=name,
description=description,
)
@@ -112,7 +108,6 @@ class CreateTaskIssueUseCase:
self,
task_id: str,
project_id: str,
workspace_id: str,
title: str,
description: str = "",
created_by_user_id: str = "",
@@ -120,7 +115,6 @@ class CreateTaskIssueUseCase:
issue = TaskIssue.create(
task_id=task_id,
project_id=project_id,
workspace_id=workspace_id,
title=title,
description=description,
created_by_user_id=created_by_user_id,
@@ -1,89 +0,0 @@
"""Workspace 相关 Use Cases"""
from packages.application.workspace.accept_invitation_use_case import (
AcceptInvitationRequest,
AcceptInvitationResponse,
AcceptInvitationUseCase,
DeclineInvitationRequest,
DeclineInvitationUseCase,
)
from packages.application.workspace.create_workspace_use_case import (
CreateWorkspaceRequest,
CreateWorkspaceResponse,
CreateWorkspaceUseCase,
)
from packages.application.workspace.invite_member_use_case import (
InviteMemberRequest,
InviteMemberResponse,
InviteMemberUseCase,
)
from packages.application.workspace.list_members_use_case import (
ListMembersRequest,
ListMembersResponse,
ListMembersUseCase,
MemberInfo,
)
from packages.application.workspace.list_workspaces_use_case import (
GetWorkspaceDetailRequest,
GetWorkspaceDetailUseCase,
ListWorkspacesRequest,
ListWorkspacesResponse,
ListWorkspacesUseCase,
WorkspaceDetailInfo,
WorkspaceInfo,
)
from packages.application.workspace.remove_member_use_case import (
LeaveWorkspaceRequest,
LeaveWorkspaceUseCase,
RemoveMemberRequest,
RemoveMemberUseCase,
)
from packages.application.workspace.subscription_use_case import (
CancelSubscriptionRequest,
CancelSubscriptionUseCase,
UpgradeSubscriptionRequest,
UpgradeSubscriptionResponse,
UpgradeSubscriptionUseCase,
)
from packages.application.workspace.update_member_role_use_case import (
UpdateMemberRoleRequest,
UpdateMemberRoleResponse,
UpdateMemberRoleUseCase,
)
__all__ = [
"CreateWorkspaceUseCase",
"CreateWorkspaceRequest",
"CreateWorkspaceResponse",
"InviteMemberUseCase",
"InviteMemberRequest",
"InviteMemberResponse",
"AcceptInvitationUseCase",
"AcceptInvitationRequest",
"AcceptInvitationResponse",
"DeclineInvitationUseCase",
"DeclineInvitationRequest",
"RemoveMemberUseCase",
"RemoveMemberRequest",
"LeaveWorkspaceUseCase",
"LeaveWorkspaceRequest",
"UpdateMemberRoleUseCase",
"UpdateMemberRoleRequest",
"UpdateMemberRoleResponse",
"ListWorkspacesUseCase",
"ListWorkspacesRequest",
"ListWorkspacesResponse",
"GetWorkspaceDetailUseCase",
"GetWorkspaceDetailRequest",
"WorkspaceInfo",
"WorkspaceDetailInfo",
"ListMembersUseCase",
"ListMembersRequest",
"ListMembersResponse",
"MemberInfo",
"UpgradeSubscriptionUseCase",
"UpgradeSubscriptionRequest",
"UpgradeSubscriptionResponse",
"CancelSubscriptionUseCase",
"CancelSubscriptionRequest",
]
@@ -1,195 +0,0 @@
"""
接受/拒绝邀请 Use Case
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from packages.domain.entities import InvitationStatus, WorkspaceMember
class AcceptInvitationRequest:
"""接受邀请请求"""
def __init__(self, invitation_token: str, user_id: str):
self.invitation_token = invitation_token
self.user_id = user_id
class AcceptInvitationResponse:
"""接受邀请响应"""
def __init__(
self,
workspace_id: str,
workspace_name: str,
role: str,
):
self.workspace_id = workspace_id
self.workspace_name = workspace_name
self.role = role
class AcceptInvitationUseCase:
"""接受邀请用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
workspace_invitation_repository,
user_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
self.workspace_invitation_repository = workspace_invitation_repository
self.user_repository = user_repository
def execute(self, request: AcceptInvitationRequest) -> tuple[Optional[AcceptInvitationResponse], Optional[str]]:
"""
执行接受邀请
Args:
request: 接受请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.invitation_token:
return None, "Invitation token is required"
if not request.user_id:
return None, "User ID is required"
# 2. 查找邀请
invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token)
if not invitation:
return None, "Invalid invitation token"
# 3. 检查邀请状态
if invitation.status != InvitationStatus.PENDING:
return None, f"Invitation has already been {invitation.status}"
# 4. 检查是否过期
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)
return None, "Invitation has expired"
# 5. 验证用户存在
user = self.user_repository.find_by_id(request.user_id)
if not user:
return None, "User not found"
# 6. 验证用户邮箱匹配
if user.email.lower() != invitation.invitee_email.lower():
return None, "This invitation is for a different email address"
# 7. 验证 Workspace 存在
workspace = self.workspace_repository.find_by_id(invitation.workspace_id)
if not workspace:
return None, "Workspace not found"
# 8. 检查用户是否已经是成员
existing_member = self.workspace_member_repository.find_by_workspace_and_user(
invitation.workspace_id,
request.user_id,
)
if existing_member:
# 已经是成员,标记邀请为已接受
invitation.status = InvitationStatus.ACCEPTED
invitation.accepted_at = datetime.now(timezone.utc)
self.workspace_invitation_repository.save(invitation)
return (
AcceptInvitationResponse(
workspace_id=workspace.id,
workspace_name=workspace.name,
role=existing_member.role,
),
None,
)
# 9. 创建成员记录
member = WorkspaceMember(
id=uuid4().hex,
workspace_id=invitation.workspace_id,
user_id=request.user_id,
role=invitation.role,
invited_by=invitation.inviter_user_id,
joined_at=datetime.now(timezone.utc),
)
self.workspace_member_repository.save(member)
# 10. 更新邀请状态
invitation.status = InvitationStatus.ACCEPTED
invitation.accepted_at = datetime.now(timezone.utc)
self.workspace_invitation_repository.save(invitation)
# 11. 返回响应
return (
AcceptInvitationResponse(
workspace_id=workspace.id,
workspace_name=workspace.name,
role=member.role,
),
None,
)
except Exception as e:
return None, f"Failed to accept invitation: {str(e)}"
class DeclineInvitationRequest:
"""拒绝邀请请求"""
def __init__(self, invitation_token: str):
self.invitation_token = invitation_token
class DeclineInvitationUseCase:
"""拒绝邀请用例"""
def __init__(self, workspace_invitation_repository):
self.workspace_invitation_repository = workspace_invitation_repository
def execute(self, request: DeclineInvitationRequest) -> tuple[bool, Optional[str]]:
"""
执行拒绝邀请
Args:
request: 拒绝请求
Returns:
(是否成功, 错误信息)
"""
try:
# 1. 验证输入
if not request.invitation_token:
return False, "Invitation token is required"
# 2. 查找邀请
invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token)
if not invitation:
return False, "Invalid invitation token"
# 3. 检查邀请状态
if invitation.status != InvitationStatus.PENDING:
return False, f"Invitation has already been {invitation.status}"
# 4. 更新状态为已拒绝
invitation.status = InvitationStatus.DECLINED
self.workspace_invitation_repository.save(invitation)
return True, None
except Exception as e:
return False, f"Failed to decline invitation: {str(e)}"
@@ -1,138 +0,0 @@
"""
创建 Workspace Use Case
"""
from datetime import datetime, timezone
from typing import Optional
from uuid import uuid4
from packages.domain.entities import Workspace, WorkspaceMember, WorkspaceMemberRole
class CreateWorkspaceRequest:
"""创建工作空间请求"""
def __init__(
self,
name: str,
owner_user_id: str,
subscription_plan: str = "free",
):
self.name = name.strip()
self.owner_user_id = owner_user_id
self.subscription_plan = subscription_plan
class CreateWorkspaceResponse:
"""创建工作空间响应"""
def __init__(
self,
workspace_id: str,
name: str,
subscription_plan: str,
max_projects: int,
max_storage_gb: int,
):
self.workspace_id = workspace_id
self.name = name
self.subscription_plan = subscription_plan
self.max_projects = max_projects
self.max_storage_gb = max_storage_gb
class CreateWorkspaceUseCase:
"""创建工作空间用例"""
# 订阅计划配额配置
PLAN_QUOTAS = {
"free": {"max_projects": 3, "max_storage_gb": 10},
"pro": {"max_projects": 999999, "max_storage_gb": 100}, # 999999 表示无限
"enterprise": {"max_projects": 999999, "max_storage_gb": 1000},
}
def __init__(
self,
workspace_repository,
workspace_member_repository,
user_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
self.user_repository = user_repository
def execute(self, request: CreateWorkspaceRequest) -> tuple[Optional[CreateWorkspaceResponse], Optional[str]]:
"""
执行创建工作空间
Args:
request: 创建请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.name:
return None, "Workspace name is required"
if len(request.name) > 100:
return None, "Workspace name is too long (max 100 characters)"
if not request.owner_user_id:
return None, "Owner user ID is required"
# 2. 验证用户存在
owner = self.user_repository.find_by_id(request.owner_user_id)
if not owner:
return None, "Owner user not found"
# 3. 验证订阅计划
if request.subscription_plan not in self.PLAN_QUOTAS:
return None, f"Invalid subscription plan: {request.subscription_plan}"
# 4. 获取配额
quota = self.PLAN_QUOTAS[request.subscription_plan]
# 5. 创建 Workspace
workspace = Workspace(
id=uuid4().hex,
name=request.name,
owner_user_id=request.owner_user_id,
subscription_plan=request.subscription_plan,
subscription_status="active",
max_projects=quota["max_projects"],
max_storage_gb=quota["max_storage_gb"],
used_storage_gb=0.0,
created_at=datetime.now(timezone.utc),
)
# 6. 保存 Workspace
self.workspace_repository.save(workspace)
# 7. 创建 Owner 成员记录
owner_member = WorkspaceMember(
id=uuid4().hex,
workspace_id=workspace.id,
user_id=request.owner_user_id,
role=WorkspaceMemberRole.OWNER,
invited_by=None, # Owner 不需要邀请
joined_at=datetime.now(timezone.utc),
)
self.workspace_member_repository.save(owner_member)
# 8. 返回响应
return (
CreateWorkspaceResponse(
workspace_id=workspace.id,
name=workspace.name,
subscription_plan=workspace.subscription_plan,
max_projects=workspace.max_projects,
max_storage_gb=workspace.max_storage_gb,
),
None,
)
except Exception as e:
return None, f"Failed to create workspace: {str(e)}"
@@ -1,194 +0,0 @@
"""
邀请成员到 Workspace Use Case
"""
import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import uuid4
from packages.adapters.smtp import get_email_service
from packages.domain.entities import (
InvitationStatus,
WorkspaceInvitation,
WorkspaceMemberRole,
)
class InviteMemberRequest:
"""邀请成员请求"""
def __init__(
self,
workspace_id: str,
inviter_user_id: str,
invitee_email: str,
role: str,
):
self.workspace_id = workspace_id
self.inviter_user_id = inviter_user_id
self.invitee_email = invitee_email.strip().lower()
self.role = role
class InviteMemberResponse:
"""邀请成员响应"""
def __init__(
self,
invitation_id: str,
invitee_email: str,
role: str,
expires_at: datetime,
):
self.invitation_id = invitation_id
self.invitee_email = invitee_email
self.role = role
self.expires_at = expires_at
class InviteMemberUseCase:
"""邀请成员用例"""
VALID_ROLES = [
WorkspaceMemberRole.ADMIN,
WorkspaceMemberRole.MEMBER,
WorkspaceMemberRole.VIEWER,
]
def __init__(
self,
workspace_repository,
workspace_member_repository,
workspace_invitation_repository,
user_repository,
base_url: str = "http://localhost:3000",
invitation_expire_days: int = 7,
email_service=None,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
self.workspace_invitation_repository = workspace_invitation_repository
self.user_repository = user_repository
self.base_url = base_url
self.invitation_expire_days = invitation_expire_days
self.email_service = email_service or get_email_service()
def execute(self, request: InviteMemberRequest) -> tuple[Optional[InviteMemberResponse], Optional[str]]:
"""
执行邀请成员
Args:
request: 邀请请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return None, "Workspace ID is required"
if not request.inviter_user_id:
return None, "Inviter user ID is required"
if not request.invitee_email:
return None, "Invitee email is required"
if not request.role:
return None, "Role is required"
# 2. 验证角色(不能邀请 owner)
if request.role not in self.VALID_ROLES:
return None, f"Invalid role: {request.role}. Cannot invite as owner."
# 3. 验证 Workspace 存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return None, "Workspace not found"
# 4. 验证邀请人是成员且有权限(owner 或 admin
inviter_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.inviter_user_id,
)
if not inviter_member:
return None, "You are not a member of this workspace"
if inviter_member.role not in [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
]:
return None, "Only owners and admins can invite members"
# 5. 检查被邀请人是否已经是成员
invitee_user = self.user_repository.find_by_email(request.invitee_email)
if invitee_user:
existing_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
invitee_user.id,
)
if existing_member:
return None, "User is already a member of this workspace"
# 6. 检查是否已有待处理的邀请
existing_invitation = self.workspace_invitation_repository.find_pending_by_workspace_and_email(
request.workspace_id,
request.invitee_email,
)
if existing_invitation:
return None, "An invitation has already been sent to this email"
# 7. 生成邀请令牌
invitation_token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(days=self.invitation_expire_days)
# 8. 创建邀请记录
invitation = WorkspaceInvitation(
id=uuid4().hex,
workspace_id=request.workspace_id,
inviter_user_id=request.inviter_user_id,
invitee_email=request.invitee_email,
role=request.role,
invitation_token=invitation_token,
status=InvitationStatus.PENDING,
expires_at=expires_at,
created_at=datetime.now(timezone.utc),
)
# 9. 保存邀请
self.workspace_invitation_repository.save(invitation)
# 10. 发送邀请邮件
invitation_url = f"{self.base_url}/invitations/{invitation_token}/accept"
try:
inviter = self.user_repository.find_by_id(request.inviter_user_id)
inviter_name = inviter.display_name if inviter else "Someone"
success, error = self.email_service.send_workspace_invitation_email(
to_email=request.invitee_email,
inviter_name=inviter_name,
workspace_name=workspace.name,
role=request.role,
invitation_url=invitation_url,
)
if not success:
print(f"Failed to send invitation email: {error}")
except Exception as e:
print(f"Email service error: {e}")
# 11. 返回响应
return (
InviteMemberResponse(
invitation_id=invitation.id,
invitee_email=invitation.invitee_email,
role=invitation.role,
expires_at=invitation.expires_at,
),
None,
)
except Exception as e:
return None, f"Failed to invite member: {str(e)}"
@@ -1,118 +0,0 @@
"""
获取成员列表 Use Case
"""
from datetime import datetime
from typing import List, Optional
class MemberInfo:
"""成员信息"""
def __init__(
self,
member_id: str,
user_id: str,
username: str,
email: str,
display_name: str,
role: str,
invited_by: Optional[str],
joined_at: datetime,
):
self.member_id = member_id
self.user_id = user_id
self.username = username
self.email = email
self.display_name = display_name
self.role = role
self.invited_by = invited_by
self.joined_at = joined_at
class ListMembersRequest:
"""获取成员列表请求"""
def __init__(self, workspace_id: str, requester_user_id: str):
self.workspace_id = workspace_id
self.requester_user_id = requester_user_id
class ListMembersResponse:
"""获取成员列表响应"""
def __init__(self, members: List[MemberInfo]):
self.members = members
class ListMembersUseCase:
"""获取成员列表用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
user_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
self.user_repository = user_repository
def execute(self, request: ListMembersRequest) -> tuple[Optional[ListMembersResponse], Optional[str]]:
"""
执行获取成员列表
Args:
request: 请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return None, "Workspace ID is required"
if not request.requester_user_id:
return None, "Requester user ID is required"
# 2. 验证工作空间存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return None, "Workspace not found"
# 3. 验证请求者是成员(只有成员才能查看成员列表)
requester_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.requester_user_id,
)
if not requester_member:
return None, "You are not a member of this workspace"
# 4. 获取所有成员
members = self.workspace_member_repository.find_by_workspace(request.workspace_id)
# 5. 获取每个成员的用户信息
member_infos = []
for member in members:
user = self.user_repository.find_by_id(member.user_id)
if not user:
continue # 跳过不存在的用户
member_info = MemberInfo(
member_id=member.id,
user_id=user.id,
username=user.username,
email=user.email,
display_name=user.display_name,
role=member.role,
invited_by=member.invited_by,
joined_at=member.joined_at,
)
member_infos.append(member_info)
# 6. 返回响应
return ListMembersResponse(members=member_infos), None
except Exception as e:
return None, f"Failed to list members: {str(e)}"
@@ -1,218 +0,0 @@
"""
获取工作空间列表和详情 Use Case
"""
from datetime import datetime
from typing import List, Optional
class WorkspaceInfo:
"""工作空间信息"""
def __init__(
self,
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,
created_at: datetime,
):
self.workspace_id = workspace_id
self.name = name
self.owner_user_id = owner_user_id
self.subscription_plan = subscription_plan
self.subscription_status = subscription_status
self.max_projects = max_projects
self.max_storage_gb = max_storage_gb
self.used_storage_gb = used_storage_gb
self.member_count = member_count
self.user_role = user_role
self.created_at = created_at
class ListWorkspacesRequest:
"""获取工作空间列表请求"""
def __init__(self, user_id: str):
self.user_id = user_id
class ListWorkspacesResponse:
"""获取工作空间列表响应"""
def __init__(self, workspaces: List[WorkspaceInfo]):
self.workspaces = workspaces
class ListWorkspacesUseCase:
"""获取工作空间列表用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(self, request: ListWorkspacesRequest) -> tuple[Optional[ListWorkspacesResponse], Optional[str]]:
"""
执行获取工作空间列表
Args:
request: 请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.user_id:
return None, "User ID is required"
# 2. 获取用户所有的成员记录
memberships = self.workspace_member_repository.find_by_user(request.user_id)
# 3. 获取每个工作空间的信息
workspace_infos = []
for membership in memberships:
workspace = self.workspace_repository.find_by_id(membership.workspace_id)
if not workspace:
continue
# 获取成员数量
member_count = self.workspace_member_repository.count_by_workspace(membership.workspace_id)
workspace_info = WorkspaceInfo(
workspace_id=workspace.id,
name=workspace.name,
owner_user_id=workspace.owner_user_id,
subscription_plan=workspace.subscription_plan,
subscription_status=workspace.subscription_status,
max_projects=workspace.max_projects,
max_storage_gb=workspace.max_storage_gb,
used_storage_gb=workspace.used_storage_gb,
member_count=member_count,
user_role=membership.role,
created_at=workspace.created_at,
)
workspace_infos.append(workspace_info)
# 4. 返回响应
return ListWorkspacesResponse(workspaces=workspace_infos), None
except Exception as e:
return None, f"Failed to list workspaces: {str(e)}"
class GetWorkspaceDetailRequest:
"""获取工作空间详情请求"""
def __init__(self, workspace_id: str, user_id: str):
self.workspace_id = workspace_id
self.user_id = user_id
class WorkspaceDetailInfo:
"""工作空间详情信息"""
def __init__(
self,
workspace_id: str,
name: str,
owner_user_id: str,
subscription_plan: str,
subscription_status: str,
subscription_expires_at: Optional[datetime],
max_projects: int,
max_storage_gb: int,
used_storage_gb: float,
member_count: int,
user_role: str,
created_at: datetime,
):
self.workspace_id = workspace_id
self.name = name
self.owner_user_id = owner_user_id
self.subscription_plan = subscription_plan
self.subscription_status = subscription_status
self.subscription_expires_at = subscription_expires_at
self.max_projects = max_projects
self.max_storage_gb = max_storage_gb
self.used_storage_gb = used_storage_gb
self.member_count = member_count
self.user_role = user_role
self.created_at = created_at
class GetWorkspaceDetailUseCase:
"""获取工作空间详情用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(self, request: GetWorkspaceDetailRequest) -> tuple[Optional[WorkspaceDetailInfo], Optional[str]]:
"""
执行获取工作空间详情
Args:
request: 请求
Returns:
(详情信息, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return None, "Workspace ID is required"
if not request.user_id:
return None, "User ID is required"
# 2. 验证工作空间存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return None, "Workspace not found"
# 3. 验证用户是成员
membership = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.user_id,
)
if not membership:
return None, "You are not a member of this workspace"
# 4. 获取成员数量
member_count = self.workspace_member_repository.count_by_workspace(request.workspace_id)
# 5. 构建详情信息
detail_info = WorkspaceDetailInfo(
workspace_id=workspace.id,
name=workspace.name,
owner_user_id=workspace.owner_user_id,
subscription_plan=workspace.subscription_plan,
subscription_status=workspace.subscription_status,
subscription_expires_at=workspace.subscription_expires_at,
max_projects=workspace.max_projects,
max_storage_gb=workspace.max_storage_gb,
used_storage_gb=workspace.used_storage_gb,
member_count=member_count,
user_role=membership.role,
created_at=workspace.created_at,
)
return detail_info, None
except Exception as e:
return None, f"Failed to get workspace detail: {str(e)}"
@@ -1,171 +0,0 @@
"""
移除成员 Use Case
"""
from typing import Optional
from packages.domain.entities import WorkspaceMemberRole
class RemoveMemberRequest:
"""移除成员请求"""
def __init__(
self,
workspace_id: str,
requester_user_id: str,
target_user_id: str,
):
self.workspace_id = workspace_id
self.requester_user_id = requester_user_id
self.target_user_id = target_user_id
class RemoveMemberUseCase:
"""移除成员用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(self, request: RemoveMemberRequest) -> tuple[bool, Optional[str]]:
"""
执行移除成员
Args:
request: 移除请求
Returns:
(是否成功, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return False, "Workspace ID is required"
if not request.requester_user_id:
return False, "Requester user ID is required"
if not request.target_user_id:
return False, "Target user ID is required"
# 2. 验证 Workspace 存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return False, "Workspace not found"
# 3. 验证请求者是成员且有权限
requester_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.requester_user_id,
)
if not requester_member:
return False, "You are not a member of this workspace"
if requester_member.role not in [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
]:
return False, "Only owners and admins can remove members"
# 4. 验证目标成员存在
target_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.target_user_id,
)
if not target_member:
return False, "Target user is not a member of this workspace"
# 5. 不能移除自己(应该用离开 workspace 的功能)
if request.requester_user_id == request.target_user_id:
return False, "Cannot remove yourself. Use leave workspace instead."
# 6. 不能移除 owner
if target_member.role == WorkspaceMemberRole.OWNER:
return False, "Cannot remove the workspace owner"
# 7. Admin 不能移除另一个 Admin(只有 owner 可以)
if requester_member.role == WorkspaceMemberRole.ADMIN and target_member.role == WorkspaceMemberRole.ADMIN:
return False, "Admins cannot remove other admins"
# 8. 删除成员记录
success = self.workspace_member_repository.delete(target_member.id)
if not success:
return False, "Failed to remove member"
return True, None
except Exception as e:
return False, f"Failed to remove member: {str(e)}"
class LeaveWorkspaceRequest:
"""离开 Workspace 请求"""
def __init__(self, workspace_id: str, user_id: str):
self.workspace_id = workspace_id
self.user_id = user_id
class LeaveWorkspaceUseCase:
"""离开 Workspace 用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(self, request: LeaveWorkspaceRequest) -> tuple[bool, Optional[str]]:
"""
执行离开 Workspace
Args:
request: 离开请求
Returns:
(是否成功, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return False, "Workspace ID is required"
if not request.user_id:
return False, "User ID is required"
# 2. 验证 Workspace 存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return False, "Workspace not found"
# 3. 验证用户是成员
member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.user_id,
)
if not member:
return False, "You are not a member of this workspace"
# 4. Owner 不能离开(需要先转移 ownership 或删除 workspace
if member.role == WorkspaceMemberRole.OWNER:
return (
False,
"Owner cannot leave workspace. Transfer ownership or delete workspace first.",
)
# 5. 删除成员记录
success = self.workspace_member_repository.delete(member.id)
if not success:
return False, "Failed to leave workspace"
return True, None
except Exception as e:
return False, f"Failed to leave workspace: {str(e)}"
@@ -1,223 +0,0 @@
"""
Subscription 管理 Use Case
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from packages.domain.entities import WorkspaceMemberRole
class UpgradeSubscriptionRequest:
"""升级订阅请求"""
def __init__(
self,
workspace_id: str,
requester_user_id: str,
new_plan: str,
):
self.workspace_id = workspace_id
self.requester_user_id = requester_user_id
self.new_plan = new_plan
class UpgradeSubscriptionResponse:
"""升级订阅响应"""
def __init__(
self,
workspace_id: str,
old_plan: str,
new_plan: str,
max_projects: int,
max_storage_gb: int,
):
self.workspace_id = workspace_id
self.old_plan = old_plan
self.new_plan = new_plan
self.max_projects = max_projects
self.max_storage_gb = max_storage_gb
class UpgradeSubscriptionUseCase:
"""升级订阅用例"""
# 订阅计划配额
PLAN_QUOTAS = {
"free": {"max_projects": 3, "max_storage_gb": 10, "price": 0},
"pro": {"max_projects": 999999, "max_storage_gb": 100, "price": 99},
"enterprise": {"max_projects": 999999, "max_storage_gb": 1000, "price": 999},
}
# 计划等级
PLAN_LEVELS = {
"free": 0,
"pro": 1,
"enterprise": 2,
}
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(
self, request: UpgradeSubscriptionRequest
) -> tuple[Optional[UpgradeSubscriptionResponse], Optional[str]]:
"""
执行升级订阅
Args:
request: 升级请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return None, "Workspace ID is required"
if not request.requester_user_id:
return None, "Requester user ID is required"
if not request.new_plan:
return None, "New plan is required"
# 2. 验证新计划有效
if request.new_plan not in self.PLAN_QUOTAS:
return None, f"Invalid plan: {request.new_plan}"
# 3. 验证工作空间存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return None, "Workspace not found"
# 4. 验证权限(只有 Owner 可以管理订阅)
member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.requester_user_id,
)
if not member:
return None, "You are not a member of this workspace"
if member.role != WorkspaceMemberRole.OWNER:
return None, "Only workspace owner can manage subscription"
# 5. 检查是否是升级(不能降级到免费计划,需要用取消订阅)
current_level = self.PLAN_LEVELS.get(workspace.subscription_plan, 0)
new_level = self.PLAN_LEVELS.get(request.new_plan, 0)
if new_level < current_level:
return (
None,
"Cannot downgrade plan. Use cancel subscription to return to free plan.",
)
if new_level == current_level:
return None, f"Workspace is already on {request.new_plan} plan"
# 6. 更新订阅
old_plan = workspace.subscription_plan
quota = self.PLAN_QUOTAS[request.new_plan]
workspace.subscription_plan = request.new_plan
workspace.subscription_status = "active"
workspace.max_projects = quota["max_projects"]
workspace.max_storage_gb = quota["max_storage_gb"]
# 设置过期时间(假设按月订阅)
workspace.subscription_expires_at = datetime.now(timezone.utc) + timedelta(days=30)
self.workspace_repository.save(workspace)
# 7. 返回响应
return (
UpgradeSubscriptionResponse(
workspace_id=workspace.id,
old_plan=old_plan,
new_plan=workspace.subscription_plan,
max_projects=workspace.max_projects,
max_storage_gb=workspace.max_storage_gb,
),
None,
)
except Exception as e:
return None, f"Failed to upgrade subscription: {str(e)}"
class CancelSubscriptionRequest:
"""取消订阅请求"""
def __init__(self, workspace_id: str, requester_user_id: str):
self.workspace_id = workspace_id
self.requester_user_id = requester_user_id
class CancelSubscriptionUseCase:
"""取消订阅用例"""
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(self, request: CancelSubscriptionRequest) -> tuple[bool, Optional[str]]:
"""
执行取消订阅
Args:
request: 取消请求
Returns:
(是否成功, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return False, "Workspace ID is required"
if not request.requester_user_id:
return False, "Requester user ID is required"
# 2. 验证工作空间存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return False, "Workspace not found"
# 3. 验证权限(只有 Owner 可以管理订阅)
member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.requester_user_id,
)
if not member:
return False, "You are not a member of this workspace"
if member.role != WorkspaceMemberRole.OWNER:
return False, "Only workspace owner can manage subscription"
# 4. 检查当前计划
if workspace.subscription_plan == "free":
return False, "Workspace is already on free plan"
# 5. 降级到 free 计划
workspace.subscription_plan = "free"
workspace.subscription_status = "active"
workspace.subscription_expires_at = None
workspace.max_projects = 3
workspace.max_storage_gb = 10
self.workspace_repository.save(workspace)
return True, None
except Exception as e:
return False, f"Failed to cancel subscription: {str(e)}"
@@ -1,142 +0,0 @@
"""
修改成员角色 Use Case
"""
from typing import Optional
from packages.domain.entities import WorkspaceMemberRole
class UpdateMemberRoleRequest:
"""修改成员角色请求"""
def __init__(
self,
workspace_id: str,
requester_user_id: str,
target_user_id: str,
new_role: str,
):
self.workspace_id = workspace_id
self.requester_user_id = requester_user_id
self.target_user_id = target_user_id
self.new_role = new_role
class UpdateMemberRoleResponse:
"""修改成员角色响应"""
def __init__(self, user_id: str, old_role: str, new_role: str):
self.user_id = user_id
self.old_role = old_role
self.new_role = new_role
class UpdateMemberRoleUseCase:
"""修改成员角色用例"""
VALID_ROLES = [
WorkspaceMemberRole.ADMIN,
WorkspaceMemberRole.MEMBER,
WorkspaceMemberRole.VIEWER,
]
def __init__(
self,
workspace_repository,
workspace_member_repository,
):
self.workspace_repository = workspace_repository
self.workspace_member_repository = workspace_member_repository
def execute(self, request: UpdateMemberRoleRequest) -> tuple[Optional[UpdateMemberRoleResponse], Optional[str]]:
"""
执行修改成员角色
Args:
request: 修改请求
Returns:
(响应, 错误信息)
"""
try:
# 1. 验证输入
if not request.workspace_id:
return None, "Workspace ID is required"
if not request.requester_user_id:
return None, "Requester user ID is required"
if not request.target_user_id:
return None, "Target user ID is required"
if not request.new_role:
return None, "New role is required"
# 2. 验证新角色(不能修改为 owner)
if request.new_role not in self.VALID_ROLES:
return (
None,
f"Invalid role: {request.new_role}. Cannot change to owner.",
)
# 3. 验证 Workspace 存在
workspace = self.workspace_repository.find_by_id(request.workspace_id)
if not workspace:
return None, "Workspace not found"
# 4. 验证请求者是成员且有权限(只有 owner 和 admin 可以修改角色)
requester_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.requester_user_id,
)
if not requester_member:
return None, "You are not a member of this workspace"
if requester_member.role not in [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
]:
return None, "Only owners and admins can change member roles"
# 5. 验证目标成员存在
target_member = self.workspace_member_repository.find_by_workspace_and_user(
request.workspace_id,
request.target_user_id,
)
if not target_member:
return None, "Target user is not a member of this workspace"
# 6. 不能修改自己的角色
if request.requester_user_id == request.target_user_id:
return None, "Cannot change your own role"
# 7. 不能修改 owner 的角色
if target_member.role == WorkspaceMemberRole.OWNER:
return None, "Cannot change the owner's role"
# 8. Admin 不能修改另一个 Admin 的角色(只有 owner 可以)
if requester_member.role == WorkspaceMemberRole.ADMIN and target_member.role == WorkspaceMemberRole.ADMIN:
return None, "Admins cannot change other admins' roles"
# 9. 检查角色是否相同
if target_member.role == request.new_role:
return None, f"User already has the {request.new_role} role"
# 10. 更新角色
old_role = target_member.role
target_member.role = request.new_role
self.workspace_member_repository.save(target_member)
# 11. 返回响应
return (
UpdateMemberRoleResponse(
user_id=request.target_user_id,
old_role=old_role,
new_role=request.new_role,
),
None,
)
except Exception as e:
return None, f"Failed to update member role: {str(e)}"
-5
View File
@@ -43,7 +43,6 @@ class AssetClassification(StrEnum):
@dataclass(slots=True)
class ClassificationJob:
id: str
workspace_id: str
project_id: str
asset_id: str
status: ClassificationJobStatus = ClassificationJobStatus.PENDING
@@ -56,19 +55,15 @@ class ClassificationJob:
@classmethod
def create(
cls,
workspace_id: str,
project_id: str,
asset_id: str,
) -> "ClassificationJob":
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not asset_id.strip():
raise ValueError("asset_id 不能为空")
return cls(
id=uuid4().hex,
workspace_id=workspace_id.strip(),
project_id=project_id.strip(),
asset_id=asset_id.strip(),
)
-1
View File
@@ -51,7 +51,6 @@ class Project:
owner_user_id: str
name: str
description: str = ""
workspace_id: str = "" # 兼容旧代码,待移除
shared_users: list[str] = field(default_factory=list) # 被共享的用户 ID 列表
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
-3
View File
@@ -18,7 +18,6 @@ class GeneratedVideo:
width: int
height: int
fps: float
workspace_id: str = ""
thumbnail_url: str | None = None
status: str = "completed"
review_status: str = "pending_review"
@@ -37,7 +36,6 @@ class GeneratedVideo:
name: str,
file_url: str,
*,
workspace_id: str = "",
file_size: int = 0,
duration: float = 0.0,
width: int = 0,
@@ -56,7 +54,6 @@ class GeneratedVideo:
raise ValueError("file_url cannot be empty")
return cls(
id=uuid4().hex,
workspace_id=workspace_id.strip(),
project_id=project_id.strip(),
generation_task_id=generation_task_id.strip(),
name=name.strip(),
-3
View File
@@ -19,7 +19,6 @@ class GenerationTask:
id: str
project_id: str
asset_library_id: str
workspace_id: str = ""
strategy_id: str = ""
voice_library_id: str = ""
edit_plan_id: str = ""
@@ -38,7 +37,6 @@ class GenerationTask:
project_id: str,
asset_library_id: str,
*,
workspace_id: str = "",
strategy_id: str = "",
voice_library_id: str = "",
edit_plan_id: str = "",
@@ -50,7 +48,6 @@ class GenerationTask:
raise ValueError("asset_library_id 不能为空")
return cls(
id=uuid4().hex,
workspace_id=workspace_id.strip(),
project_id=project_id.strip(),
asset_library_id=asset_library_id.strip(),
strategy_id=strategy_id.strip(),
+9 -33
View File
@@ -3,60 +3,36 @@ Permissions module - stub implementation.
Workspace concept has been removed. All permission checks pass by default.
"""
from typing import Optional
class WorkspaceMemberRole:
"""Stub enum - workspace concept removed."""
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
VIEWER = "viewer"
class PermissionChecker:
"""Stub permission checker - all checks pass since workspace is removed."""
def __init__(self, workspace_member_repository=None):
self.workspace_member_repository = workspace_member_repository
def __init__(self, member_repository=None):
self.member_repository = member_repository
def check_workspace_access(self, workspace_id, user_id):
def check_access(self, project_id, user_id):
return True, "owner"
def check_is_owner(self, workspace_id, user_id):
def check_is_owner(self, project_id, user_id):
return True
def check_is_admin_or_owner(self, workspace_id, user_id):
def check_is_admin_or_owner(self, project_id, user_id):
return True
def check_can_manage_members(self, workspace_id, user_id):
def check_can_manage_members(self, project_id, user_id):
return True
def check_can_edit_workspace(self, workspace_id, user_id):
def check_can_edit_project(self, project_id, user_id):
return True
def check_can_create_project(self, workspace_id, user_id):
def check_can_delete_project(self, project_id, user_id):
return True
def check_can_edit_project(self, workspace_id, user_id):
return True
def check_can_delete_project(self, workspace_id, user_id):
return True
def check_can_view_workspace(self, workspace_id, user_id):
def check_can_view_project(self, project_id, user_id):
return True
class Permission:
WORKSPACE_VIEW = "workspace:view"
WORKSPACE_EDIT = "workspace:edit"
WORKSPACE_DELETE = "workspace:delete"
WORKSPACE_MANAGE_SUBSCRIPTION = "workspace:manage_subscription"
MEMBER_VIEW = "member:view"
MEMBER_INVITE = "member:invite"
MEMBER_REMOVE = "member:remove"
MEMBER_UPDATE_ROLE = "member:update_role"
PROJECT_VIEW = "project:view"
PROJECT_CREATE = "project:create"
PROJECT_EDIT = "project:edit"
-15
View File
@@ -33,7 +33,6 @@ class Task:
id: str
project_id: str
workspace_id: str
name: str
description: str = ""
status: TaskStatus = TaskStatus.PENDING
@@ -53,7 +52,6 @@ class Task:
def create(
cls,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
priority: TaskPriority = TaskPriority.MEDIUM,
@@ -68,13 +66,10 @@ class Task:
raise ValueError("任务名称不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
workspace_id=workspace_id.strip(),
name=clean_name,
description=description.strip(),
priority=priority,
@@ -134,7 +129,6 @@ class Milestone:
id: str
project_id: str
workspace_id: str
name: str
description: str = ""
target_date: datetime | None = None
@@ -147,7 +141,6 @@ class Milestone:
def create(
cls,
project_id: str,
workspace_id: str,
name: str,
description: str = "",
target_date: datetime | None = None,
@@ -158,13 +151,10 @@ class Milestone:
raise ValueError("里程碑名称不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
workspace_id=workspace_id.strip(),
name=clean_name,
description=description.strip(),
target_date=target_date,
@@ -192,7 +182,6 @@ class TaskIssue:
id: str
task_id: str
project_id: str
workspace_id: str
title: str
description: str = ""
resolved: bool = False
@@ -206,7 +195,6 @@ class TaskIssue:
cls,
task_id: str,
project_id: str,
workspace_id: str,
title: str,
description: str = "",
created_by_user_id: str = "",
@@ -219,14 +207,11 @@ class TaskIssue:
raise ValueError("task_id 不能为空")
if not project_id.strip():
raise ValueError("project_id 不能为空")
if not workspace_id.strip():
raise ValueError("workspace_id 不能为空")
return cls(
id=uuid4().hex,
task_id=task_id.strip(),
project_id=project_id.strip(),
workspace_id=workspace_id.strip(),
title=clean_title,
description=description.strip(),
created_by_user_id=created_by_user_id.strip(),
+5 -9
View File
@@ -3,31 +3,27 @@ Quota checker - stub implementation.
Workspace concept removed. All quota checks pass by default.
"""
from typing import Optional
class QuotaChecker:
"""Stub quota checker - all checks pass since workspace is removed."""
def __init__(self, workspace_repository=None, project_repository=None):
self.workspace_repository = workspace_repository
def __init__(self, project_repository=None):
self.project_repository = project_repository
def check_can_create_project(self, workspace_id=None):
def check_can_create_project(self, user_id=None):
return True, None
def check_storage_available(self, workspace_id=None, additional_gb=0):
def check_storage_available(self, user_id=None, additional_gb=0):
return True, None
def get_quota_status(self, workspace_id=None):
def get_quota_status(self, user_id=None):
return {
"workspace_id": workspace_id or "",
"subscription_plan": "unlimited",
"projects": {"used": 0, "limit": 999999, "unlimited": True, "usage_percent": 0},
"storage": {"used_gb": 0, "limit_gb": 999999, "remaining_gb": 999999, "usage_percent": 0},
}
def update_storage_usage(self, workspace_id=None, delta_gb=0):
def update_storage_usage(self, user_id=None, delta_gb=0):
return True, None
+1 -1
View File
@@ -5,5 +5,5 @@ from typing import Protocol, Any
class ProjectTitleRepository(Protocol):
def list_by_project(self, project_id: str, active_only: bool = False) -> list[Any]: ...
def get(self, title_id: str) -> Any | None: ...
def create(self, *, workspace_id: str, project_id: str, text: str, category: str, created_by_user_id: str, favorite: bool = False) -> Any: ...
def create(self, *, project_id: str, text: str, category: str, created_by_user_id: str, favorite: bool = False) -> Any: ...
def update(self, item: Any) -> Any: ...
@@ -1,6 +0,0 @@
"""Stub port for workspace invitation repository - workspace concept removed."""
from __future__ import annotations
from typing import Protocol
class WorkspaceInvitationRepository(Protocol):
pass
@@ -1,6 +0,0 @@
"""Stub port for workspace member repository - workspace concept removed."""
from __future__ import annotations
from typing import Protocol
class WorkspaceMemberRepository(Protocol):
pass
-6
View File
@@ -1,6 +0,0 @@
"""Stub port for workspace repository - workspace concept removed."""
from __future__ import annotations
from typing import Protocol
class WorkspaceRepository(Protocol):
pass
-3
View File
@@ -26,7 +26,6 @@ def create_milestone(name, target_date, description=""):
"name": name,
"target_date": target_date,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"description": description,
}
resp = requests.post(f"{API_BASE}/project-management/milestones", json=payload)
@@ -44,7 +43,6 @@ def create_task(name, description, priority="medium"):
"name": name,
"description": description,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"priority": priority,
}
resp = requests.post(f"{API_BASE}/project-management/tasks", json=payload)
@@ -102,7 +100,6 @@ def main():
create_task("WorkspaceMembership 实体", "成员关系、角色存储", "high")
create_task("WorkspaceRole 枚举", "Owner/Admin/Member/Viewer 权限定义", "high")
create_task("权限检查中间件", "@require_permission 装饰器", "urgent")
create_task("数据隔离过滤器", "所有查询自动加 workspace_id", "urgent")
# Day 3-4: 成员管理
create_task("邀请成员 API", "发送邀请邮件 + 生成邀请令牌", "high")
-2
View File
@@ -29,7 +29,6 @@ def create_milestone(name, target_date, description=""):
"name": name,
"target_date": target_date,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"description": description,
}
resp = requests.post(f"{API_BASE}/project-management/milestones", json=payload)
@@ -47,7 +46,6 @@ def create_task(name, description, priority="medium", status="pending", progress
"name": name,
"description": description,
"project_id": PROJECT_ID,
"workspace_id": WORKSPACE_ID,
"priority": priority,
}
resp = requests.post(f"{API_BASE}/project-management/tasks", json=payload)
-6
View File
@@ -48,12 +48,10 @@ def main() -> None:
"owner_workspace",
owner.post(f"{BASE_URL}/workspaces", json={"name": "Boundary Workspace"}, headers=owner_headers, timeout=30),
)
workspace_id = workspace.get("id") or workspace["workspace_id"]
project = _json_or_raise(
"owner_project",
owner.post(
f"{BASE_URL}/projects",
json={"workspace_id": workspace_id, "name": "Boundary Project", "description": "smoke"},
headers=owner_headers,
timeout=30,
),
@@ -63,7 +61,6 @@ def main() -> None:
"owner_library",
owner.post(
f"{BASE_URL}/asset-libraries",
json={"workspace_id": workspace_id, "project_id": project_id, "name": "Boundary Library", "kind": "video"},
headers=owner_headers,
timeout=30,
),
@@ -74,7 +71,6 @@ def main() -> None:
owner.post(
f"{BASE_URL}/upload",
files={"file": ("boundary.mp4", io.BytesIO(b"boundary-owner"), "video/mp4")},
data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id},
headers=owner_headers,
timeout=60,
),
@@ -93,7 +89,6 @@ def main() -> None:
"owner_generation_create",
owner.post(
f"{BASE_URL}/generation/tasks",
json={"workspace_id": workspace_id, "project_id": project_id, "asset_library_id": library_id},
headers=owner_headers,
timeout=30,
),
@@ -184,7 +179,6 @@ def main() -> None:
upload_response = intruder.post(
f"{BASE_URL}/upload",
files={"file": ("boundary.txt", io.BytesIO(b"boundary"), "text/plain")},
data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id},
headers=intruder_headers,
timeout=60,
)
-4
View File
@@ -51,13 +51,11 @@ def main() -> None:
"workspace",
session.post(f"{BASE_URL}/workspaces", json={"name": "Upload Smoke Workspace"}, headers=headers, timeout=30),
)
workspace_id = workspace.get("id") or workspace["workspace_id"]
project = _json_or_raise(
"project",
session.post(
f"{BASE_URL}/projects",
json={"workspace_id": workspace_id, "name": "Upload Smoke Project", "description": "smoke"},
headers=headers,
timeout=30,
),
@@ -69,7 +67,6 @@ def main() -> None:
session.post(
f"{BASE_URL}/asset-libraries",
json={
"workspace_id": workspace_id,
"project_id": project_id,
"name": "Smoke Video Library",
"kind": "video",
@@ -85,7 +82,6 @@ def main() -> None:
session.post(
f"{BASE_URL}/upload",
files={"file": ("smoke.txt", io.BytesIO(b"xiaoxia upload smoke"), "text/plain")},
data={"workspace_id": workspace_id, "project_id": project_id, "library_id": library_id},
headers=headers,
timeout=60,
),
-5
View File
@@ -6,7 +6,6 @@ from packages.domain import Asset
def test_add_tag_to_asset():
"""测试添加标签到 Asset。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -25,7 +24,6 @@ def test_add_tag_to_asset():
def test_add_duplicate_tag_should_ignore():
"""测试添加重复标签应自动去重。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -43,7 +41,6 @@ def test_add_duplicate_tag_should_ignore():
def test_add_empty_tag_should_fail():
"""测试添加空标签应失败。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -61,7 +58,6 @@ def test_add_empty_tag_should_fail():
def test_remove_tag_from_asset():
"""测试从 Asset 删除标签。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -82,7 +78,6 @@ def test_remove_tag_from_asset():
def test_remove_nonexistent_tag_should_be_idempotent():
"""测试删除不存在的标签应幂等(不报错)。"""
asset = Asset.create(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name="video.mp4",
@@ -56,7 +56,6 @@ def test_classification_pipeline():
use_case = SubmitClassificationJobUseCase(job_repo)
job = use_case.execute(
SubmitClassificationJobCommand(
workspace_id="ws-1",
project_id="proj-1",
asset_id="asset-123",
)
@@ -60,11 +60,9 @@ def simulate_generate_video(
task_repo.update(task)
file_url = (
f"http://localhost:9000/xiaoxia-assets/workspaces/{task.workspace_id}"
f"/projects/{task.project_id}/generated/{task.id}/{task.id}.mp4"
)
video = GeneratedVideo.create(
workspace_id=task.workspace_id,
project_id=task.project_id,
generation_task_id=task.id,
name=f"{task.id}.mp4",
@@ -96,7 +94,6 @@ def test_create_generation_task_smoke():
use_case = CreateGenerationTaskUseCase(repo)
task = use_case.execute(
CreateGenerationTaskCommand(
workspace_id="ws-1",
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="str-1",
@@ -104,7 +101,6 @@ def test_create_generation_task_smoke():
created_by_user_id="user-1",
)
)
assert task.workspace_id == "ws-1"
assert task.project_id == "proj-1"
assert task.asset_library_id == "lib-1"
assert task.status == GenerationTaskStatus.PENDING
@@ -117,7 +113,6 @@ def test_generation_pipeline_smoke():
use_case = CreateGenerationTaskUseCase(task_repo)
task = use_case.execute(
CreateGenerationTaskCommand(
workspace_id="ws-1",
project_id="proj-1",
asset_library_id="lib-1",
strategy_id="str-1",
@@ -144,7 +139,6 @@ def test_generation_pipeline_smoke():
def test_get_generated_video_download_url():
video_repo = DummyGeneratedVideoRepository()
video = GeneratedVideo.create(
workspace_id="ws-1",
project_id="proj-1",
generation_task_id="task-1",
name="task-1.mp4",
@@ -165,7 +159,6 @@ def test_get_generated_video_download_url():
def test_generated_video_download_source_url_is_stable():
video_repo = DummyGeneratedVideoRepository()
video = GeneratedVideo.create(
workspace_id="ws-1",
project_id="proj-1",
generation_task_id="task-2",
name="task-2.mp4",
@@ -38,7 +38,6 @@ def simulate_ingest_asset(
# Create Asset
asset = Asset.create(
workspace_id=job.workspace_id,
project_id=job.project_id,
library_id=job.library_id,
name=filename,
@@ -80,7 +79,6 @@ def test_ingest_asset_pipeline():
use_case = SubmitIngestJobUseCase(job_repo)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
storage_key="uploads/test-video.mp4",
@@ -27,7 +27,6 @@ def test_create_task():
task = use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="开发登录功能",
description="实现用户登录功能",
priority=TaskPriority.HIGH,
@@ -48,12 +47,10 @@ def test_list_tasks():
# 创建两个任务
create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="任务1",
)
create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="任务2",
)
@@ -75,7 +72,6 @@ def test_update_task_status():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="测试任务",
)
@@ -95,7 +91,6 @@ def test_update_task_progress():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="测试任务",
)
@@ -120,7 +115,6 @@ def test_create_milestone():
milestone = use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="V1.0 发布",
description="第一个正式版本",
)
@@ -141,7 +135,6 @@ def test_create_and_resolve_issue():
issue = create_use_case.execute(
task_id="task_1",
project_id="proj_1",
workspace_id="ws_1",
title="接口报错",
description="调用登录接口返回 500",
)
@@ -170,21 +163,18 @@ def test_task_hierarchy():
# 创建父任务
parent_task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="开发用户模块",
)
# 创建子任务
child_task_1 = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="登录功能",
parent_task_id=parent_task.id,
)
child_task_2 = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="注册功能",
parent_task_id=parent_task.id,
)
@@ -208,7 +198,6 @@ def test_get_task_detail():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="测试任务",
description="这是一个测试任务",
)
@@ -239,7 +228,6 @@ def test_update_task():
# 创建任务
task = create_use_case.execute(
project_id="proj_1",
workspace_id="ws_1",
name="原始任务",
description="原始描述",
priority="low",
-8
View File
@@ -26,9 +26,7 @@ def test_create_and_list_projects():
create_use_case = CreateProjectUseCase(repository)
list_use_case = ListProjectsUseCase(repository)
project = create_use_case.execute(CreateProjectCommand(workspace_id="ws-1", name=" Demo Project "))
assert project.name == "Demo Project"
assert project.workspace_id == "ws-1"
items = list_use_case.execute("ws-1")
assert len(items) == 1
@@ -40,12 +38,10 @@ def test_get_project_by_id_restores_workspace_context():
create_use_case = CreateProjectUseCase(repository)
get_use_case = GetProjectUseCase(repository)
project = create_use_case.execute(CreateProjectCommand(workspace_id="ws-1", name="Demo Project"))
retrieved = get_use_case.execute(project.id)
assert retrieved is not None
assert retrieved.id == project.id
assert retrieved.workspace_id == "ws-1"
def test_create_and_list_asset_libraries():
@@ -55,7 +51,6 @@ def test_create_and_list_asset_libraries():
library = create_use_case.execute(
CreateAssetLibraryCommand(
workspace_id="ws-1",
project_id="proj-1",
name=" 素材库 A ",
kind=AssetLibraryKind.VIDEO,
@@ -76,7 +71,6 @@ def test_create_and_list_assets():
asset = create_use_case.execute(
CreateAssetCommand(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
name=" demo.mp4 ",
@@ -100,13 +94,11 @@ def test_submit_ingest_job():
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
storage_key="uploads/raw-video.mp4",
)
)
assert job.workspace_id == "ws-1"
assert job.storage_key == "uploads/raw-video.mp4"
assert job.status == IngestJobStatus.PENDING
assert job.result_asset_id == ""
@@ -24,14 +24,12 @@ def test_sqlalchemy_project_repository():
# Create project
project = use_case.execute(
CreateProjectCommand(
workspace_id="ws-1",
name="Test Project",
description="Test description",
)
)
assert project.name == "Test Project"
assert project.workspace_id == "ws-1"
# List projects
projects = repository.list_by_workspace("ws-1")
@@ -7,7 +7,6 @@ from packages.domain import IngestJobStatus
def simulate_upload_and_ingest(
workspace_id: str,
project_id: str,
library_id: str,
filename: str,
@@ -26,7 +25,6 @@ def simulate_upload_and_ingest(
use_case = SubmitIngestJobUseCase(job_repo)
job = use_case.execute(
SubmitIngestJobCommand(
workspace_id=workspace_id,
project_id=project_id,
library_id=library_id,
storage_key=storage_key,
@@ -50,7 +48,6 @@ def test_upload_to_asset_full_pipeline():
# Simulate upload
result = simulate_upload_and_ingest(
workspace_id="ws-1",
project_id="proj-1",
library_id="lib-1",
filename="demo-video.mp4",
@@ -87,7 +87,6 @@ class TestAcceptInvitationUseCase:
def valid_invitation(self):
return WorkspaceInvitation(
id="invitation-1",
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="invitee@test.com",
role="member",
@@ -122,7 +121,6 @@ class TestAcceptInvitationUseCase:
assert error is None
assert response is not None
assert response.workspace_id == "workspace-123"
assert response.workspace_name == "Test Workspace"
assert response.role == "member"
@@ -159,7 +157,6 @@ class TestAcceptInvitationUseCase:
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):
"""测试无效令牌"""
@@ -262,7 +259,6 @@ class TestAcceptInvitationUseCase:
existing_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role="admin",
)
@@ -306,7 +302,6 @@ class TestDeclineInvitationUseCase:
def valid_invitation(self):
return WorkspaceInvitation(
id="invitation-1",
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="invitee@test.com",
role="member",
-1
View File
@@ -10,7 +10,6 @@ from packages.domain import Asset, AssetStatus, ClassificationStatus
def _asset(name: str, mime_type: str, *, status=AssetStatus.READY, duration=None, quality_score=None):
return Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name=name,
@@ -8,7 +8,6 @@ def test_asset_library_repository_get_returns_library():
session.add(
AssetLibraryModel(
id="lib-1",
workspace_id="workspace-1",
project_id="project-1",
name="素材库",
kind="video",
@@ -22,7 +21,6 @@ def test_asset_library_repository_get_returns_library():
assert library is not None
assert library.id == "lib-1"
assert library.workspace_id == "workspace-1"
assert library.project_id == "project-1"
assert library.kind.value == "video"
-1
View File
@@ -10,7 +10,6 @@ from packages.domain import Asset, AssetStatus
def test_apply_asset_review_status_preserves_existing_metadata():
asset = Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name="video.mp4",
-4
View File
@@ -83,9 +83,7 @@ def test_legacy_middleware_optional_user_returns_user_with_valid_credentials():
def test_workspace_dependency_allows_member_access():
repo = _WorkspaceMemberRepositoryStub(role="member")
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"
@@ -102,12 +100,10 @@ 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():
@@ -21,7 +21,6 @@ def _repository():
def test_generated_video_repository_preserves_management_fields():
repository = _repository()
video = GeneratedVideo.create(
workspace_id="workspace-1",
project_id="project-1",
generation_task_id="task-1",
name="output.mp4",
@@ -10,7 +10,6 @@ from packages.domain import Asset, AssetStatus
def _asset() -> Asset:
return Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name="video.mp4",
-1
View File
@@ -13,7 +13,6 @@ from packages.domain import Asset, AssetStatus
def _asset(name: str, mime_type: str, status: AssetStatus) -> Asset:
return Asset.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
name=name,
-11
View File
@@ -89,7 +89,6 @@ class TestInviteMemberUseCase:
def owner_member(self):
return WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="inviter-id",
role=WorkspaceMemberRole.OWNER,
)
@@ -98,7 +97,6 @@ class TestInviteMemberUseCase:
def admin_member(self):
return WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="inviter-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -120,7 +118,6 @@ class TestInviteMemberUseCase:
mock_user_repo.find_by_id.return_value = test_inviter
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="member",
@@ -160,7 +157,6 @@ class TestInviteMemberUseCase:
mock_user_repo.find_by_id.return_value = test_inviter
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="viewer",
@@ -176,7 +172,6 @@ class TestInviteMemberUseCase:
mock_workspace_repo.find_by_id.return_value = None
request = InviteMemberRequest(
workspace_id="nonexistent",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="member",
@@ -199,7 +194,6 @@ class TestInviteMemberUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = None
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="nonmember-id",
invitee_email="newuser@test.com",
role="member",
@@ -222,14 +216,12 @@ class TestInviteMemberUseCase:
regular_member = WorkspaceMember(
id="member-3",
workspace_id="workspace-123",
user_id="inviter-id",
role=WorkspaceMemberRole.MEMBER,
)
mock_member_repo.find_by_workspace_and_user.return_value = regular_member
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="member",
@@ -255,7 +247,6 @@ class TestInviteMemberUseCase:
owner_member, # 第一次调用:inviter 是 owner
WorkspaceMember( # 第二次调用:invitee 已经是成员
id="member-4",
workspace_id="workspace-123",
user_id="existing-user-id",
role=WorkspaceMemberRole.MEMBER,
),
@@ -270,7 +261,6 @@ class TestInviteMemberUseCase:
mock_user_repo.find_by_email.return_value = existing_user
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="existing@test.com",
role="member",
@@ -284,7 +274,6 @@ class TestInviteMemberUseCase:
def test_invite_member_invalid_role(self, use_case):
"""测试无效角色(不能邀请为 owner"""
request = InviteMemberRequest(
workspace_id="workspace-123",
inviter_user_id="inviter-id",
invitee_email="newuser@test.com",
role="owner",
-9
View File
@@ -22,7 +22,6 @@ class TestJWTService:
def test_create_access_token(self, jwt_service):
"""测试创建 access_token"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
assert isinstance(token, str)
assert len(token) > 0
@@ -30,7 +29,6 @@ class TestJWTService:
# 验证 Token 内容
payload = jwt_service.verify_access_token(token)
assert payload["sub"] == "user-123"
assert payload["workspace_id"] == "workspace-456"
assert payload["role"] == "admin"
assert payload["type"] == TokenType.ACCESS
@@ -49,11 +47,9 @@ class TestJWTService:
def test_verify_valid_access_token(self, jwt_service):
"""测试验证有效的 access_token"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="member")
payload = jwt_service.verify_access_token(token)
assert payload["sub"] == "user-123"
assert payload["workspace_id"] == "workspace-456"
assert payload["role"] == "member"
def test_verify_expired_token(self, jwt_service):
@@ -64,7 +60,6 @@ class TestJWTService:
config.ACCESS_TOKEN_EXPIRE_MINUTES = -1 # 负数,立即过期
expired_service = JWTService(config)
token = expired_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
# 验证应该抛出过期异常
with pytest.raises(ExpiredSignatureError):
@@ -87,14 +82,12 @@ class TestJWTService:
jwt_service.verify_access_token(refresh_token)
# 反过来也一样
access_token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
with pytest.raises(ValueError, match="Token type must be 'refresh'"):
jwt_service.verify_refresh_token(access_token)
def test_verify_tampered_token(self, jwt_service):
"""测试验证被篡改的 Token"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
# 篡改 Token(修改最后几个字符)
tampered_token = token[:-5] + "XXXXX"
@@ -106,7 +99,6 @@ class TestJWTService:
"""测试额外的声明"""
token = jwt_service.create_access_token(
user_id="user-123",
workspace_id="workspace-456",
role="admin",
additional_claims={
"email": "user@example.com",
@@ -120,7 +112,6 @@ class TestJWTService:
def test_decode_unsafe(self, jwt_service):
"""测试不安全解码(不验证签名)"""
token = jwt_service.create_access_token(user_id="user-123", workspace_id="workspace-456", role="admin")
# 不验证签名地解码
payload = jwt_service.decode_token_unsafe(token)
-12
View File
@@ -71,7 +71,6 @@ class TestListMembersUseCase:
# 请求者是 Admin
requester_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -80,7 +79,6 @@ class TestListMembersUseCase:
# 3 个成员
member1 = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
invited_by=None,
@@ -88,7 +86,6 @@ class TestListMembersUseCase:
member2 = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
invited_by="owner-id",
@@ -96,7 +93,6 @@ class TestListMembersUseCase:
member3 = WorkspaceMember(
id="member-3",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
invited_by="admin-id",
@@ -129,7 +125,6 @@ class TestListMembersUseCase:
mock_user_repo.find_by_id.side_effect = [user1, user2, user3]
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
)
@@ -168,7 +163,6 @@ class TestListMembersUseCase:
mock_workspace_repo.find_by_id.return_value = None
request = ListMembersRequest(
workspace_id="nonexistent",
requester_user_id="user-id",
)
@@ -189,7 +183,6 @@ class TestListMembersUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = None
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="outsider-id",
)
@@ -210,7 +203,6 @@ class TestListMembersUseCase:
requester_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
)
@@ -218,7 +210,6 @@ class TestListMembersUseCase:
mock_member_repo.find_by_workspace.return_value = []
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="user-id",
)
@@ -228,10 +219,8 @@ class TestListMembersUseCase:
assert response is not None
assert len(response.members) == 0
def test_list_members_missing_workspace_id(self, use_case):
"""测试缺少工作空间 ID"""
request = ListMembersRequest(
workspace_id="",
requester_user_id="user-id",
)
@@ -243,7 +232,6 @@ class TestListMembersUseCase:
def test_list_members_missing_requester_id(self, use_case):
"""测试缺少请求者 ID"""
request = ListMembersRequest(
workspace_id="workspace-123",
requester_user_id="",
)
-265
View File
@@ -1,265 +0,0 @@
"""
获取工作空间列表和详情 Use Case 测试
"""
from datetime import datetime, timezone
from unittest.mock import Mock
import pytest
from packages.application.workspace.list_workspaces_use_case import (
GetWorkspaceDetailRequest,
GetWorkspaceDetailUseCase,
ListWorkspacesRequest,
ListWorkspacesUseCase,
)
from packages.domain.entities import Workspace, WorkspaceMember, WorkspaceMemberRole
class TestListWorkspacesUseCase:
"""获取工作空间列表测试"""
@pytest.fixture
def mock_workspace_repo(self):
repo = Mock()
repo.find_by_id = Mock(return_value=None)
return repo
@pytest.fixture
def mock_member_repo(self):
repo = Mock()
repo.find_by_user = Mock(return_value=[])
repo.count_by_workspace = Mock(return_value=0)
return repo
@pytest.fixture
def use_case(self, mock_workspace_repo, mock_member_repo):
return ListWorkspacesUseCase(
workspace_repository=mock_workspace_repo,
workspace_member_repository=mock_member_repo,
)
def test_list_workspaces_success(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
):
"""测试获取工作空间列表成功"""
# 用户是 2 个工作空间的成员
membership1 = WorkspaceMember(
id="member-1",
workspace_id="workspace-1",
user_id="user-123",
role=WorkspaceMemberRole.OWNER,
)
membership2 = WorkspaceMember(
id="member-2",
workspace_id="workspace-2",
user_id="user-123",
role=WorkspaceMemberRole.MEMBER,
)
mock_member_repo.find_by_user.return_value = [membership1, membership2]
workspace1 = Workspace(
id="workspace-1",
name="My Workspace",
owner_user_id="user-123",
subscription_plan="free",
)
workspace2 = Workspace(
id="workspace-2",
name="Team Workspace",
owner_user_id="other-user",
subscription_plan="pro",
)
mock_workspace_repo.find_by_id.side_effect = [workspace1, workspace2]
mock_member_repo.count_by_workspace.side_effect = [1, 5]
request = ListWorkspacesRequest(user_id="user-123")
response, error = use_case.execute(request)
assert error is None
assert response is not None
assert len(response.workspaces) == 2
# 验证第一个工作空间
ws1 = response.workspaces[0]
assert ws1.workspace_id == "workspace-1"
assert ws1.name == "My Workspace"
assert ws1.user_role == "owner"
assert ws1.member_count == 1
assert ws1.subscription_plan == "free"
# 验证第二个工作空间
ws2 = response.workspaces[1]
assert ws2.workspace_id == "workspace-2"
assert ws2.name == "Team Workspace"
assert ws2.user_role == "member"
assert ws2.member_count == 5
assert ws2.subscription_plan == "pro"
def test_list_workspaces_no_memberships(
self,
use_case,
mock_member_repo,
):
"""测试用户没有加入任何工作空间"""
mock_member_repo.find_by_user.return_value = []
request = ListWorkspacesRequest(user_id="user-123")
response, error = use_case.execute(request)
assert error is None
assert response is not None
assert len(response.workspaces) == 0
def test_list_workspaces_missing_user_id(self, use_case):
"""测试缺少用户 ID"""
request = ListWorkspacesRequest(user_id="")
response, error = use_case.execute(request)
assert response is None
assert error == "User ID is required"
class TestGetWorkspaceDetailUseCase:
"""获取工作空间详情测试"""
@pytest.fixture
def mock_workspace_repo(self):
repo = Mock()
repo.find_by_id = Mock(return_value=None)
return repo
@pytest.fixture
def mock_member_repo(self):
repo = Mock()
repo.find_by_workspace_and_user = Mock(return_value=None)
repo.count_by_workspace = Mock(return_value=0)
return repo
@pytest.fixture
def use_case(self, mock_workspace_repo, mock_member_repo):
return GetWorkspaceDetailUseCase(
workspace_repository=mock_workspace_repo,
workspace_member_repository=mock_member_repo,
)
@pytest.fixture
def test_workspace(self):
return Workspace(
id="workspace-123",
name="Test Workspace",
owner_user_id="owner-id",
subscription_plan="pro",
subscription_status="active",
max_projects=999999,
max_storage_gb=100,
used_storage_gb=25.5,
)
def test_get_workspace_detail_success(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
test_workspace,
):
"""测试获取工作空间详情成功"""
mock_workspace_repo.find_by_id.return_value = test_workspace
membership = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.ADMIN,
)
mock_member_repo.find_by_workspace_and_user.return_value = membership
mock_member_repo.count_by_workspace.return_value = 8
request = GetWorkspaceDetailRequest(
workspace_id="workspace-123",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert error is None
assert detail is not None
assert detail.workspace_id == "workspace-123"
assert detail.name == "Test Workspace"
assert detail.subscription_plan == "pro"
assert detail.subscription_status == "active"
assert detail.max_projects == 999999
assert detail.max_storage_gb == 100
assert detail.used_storage_gb == 25.5
assert detail.member_count == 8
assert detail.user_role == "admin"
def test_get_workspace_detail_not_found(
self,
use_case,
mock_workspace_repo,
):
"""测试工作空间不存在"""
mock_workspace_repo.find_by_id.return_value = None
request = GetWorkspaceDetailRequest(
workspace_id="nonexistent",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "Workspace not found"
def test_get_workspace_detail_not_member(
self,
use_case,
mock_workspace_repo,
mock_member_repo,
test_workspace,
):
"""测试用户不是成员"""
mock_workspace_repo.find_by_id.return_value = test_workspace
mock_member_repo.find_by_workspace_and_user.return_value = None
request = GetWorkspaceDetailRequest(
workspace_id="workspace-123",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "You are not a member of this workspace"
def test_get_workspace_detail_missing_workspace_id(self, use_case):
"""测试缺少工作空间 ID"""
request = GetWorkspaceDetailRequest(
workspace_id="",
user_id="user-123",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "Workspace ID is required"
def test_get_workspace_detail_missing_user_id(self, use_case):
"""测试缺少用户 ID"""
request = GetWorkspaceDetailRequest(
workspace_id="workspace-123",
user_id="",
)
detail, error = use_case.execute(request)
assert detail is None
assert error == "User ID is required"
-10
View File
@@ -27,7 +27,6 @@ class TestPermissionChecker:
"""测试有访问权限"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.MEMBER,
)
@@ -51,7 +50,6 @@ class TestPermissionChecker:
"""测试是 Owner"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.OWNER,
)
@@ -65,7 +63,6 @@ class TestPermissionChecker:
"""测试不是 Owner"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.ADMIN,
)
@@ -79,7 +76,6 @@ class TestPermissionChecker:
"""测试是 Admin"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.ADMIN,
)
@@ -93,7 +89,6 @@ class TestPermissionChecker:
"""测试是 Owner"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.OWNER,
)
@@ -107,7 +102,6 @@ class TestPermissionChecker:
"""测试是普通成员"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.MEMBER,
)
@@ -121,7 +115,6 @@ class TestPermissionChecker:
"""测试 Member 可以创建项目"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.MEMBER,
)
@@ -135,7 +128,6 @@ class TestPermissionChecker:
"""测试 Viewer 不能创建项目"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.VIEWER,
)
@@ -149,7 +141,6 @@ class TestPermissionChecker:
"""测试 Member 不能删除项目"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.MEMBER,
)
@@ -163,7 +154,6 @@ class TestPermissionChecker:
"""测试 Admin 可以删除项目"""
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-123",
role=WorkspaceMemberRole.ADMIN,
)
@@ -25,14 +25,12 @@ def _session_and_repository():
def test_select_title_prefers_favorite_then_lowest_usage():
_, repository = _session_and_repository()
normal = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text="普通标题",
category="default",
created_by_user_id="user-1",
)
favorite = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text="常用标题",
category="default",
@@ -52,7 +50,6 @@ def test_mark_title_used_after_generation_completion():
now = datetime.now(timezone.utc)
title = ProjectTitleModel(
id="title-1",
workspace_id="workspace-1",
project_id="project-1",
text="生成标题",
category="default",
@@ -68,7 +65,6 @@ def test_mark_title_used_after_generation_completion():
mark_title_used_for_generation(
session,
SimpleNamespace(strategy_id="title-1", workspace_id="workspace-1", project_id="project-1"),
)
updated = session.query(ProjectTitleModel).filter(ProjectTitleModel.id == "title-1").first()
@@ -21,7 +21,6 @@ def test_project_title_repository_creates_and_lists_titles():
repository = _repository()
title = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text=" 3 分钟看懂产品亮点 ",
category="marketing",
@@ -40,7 +39,6 @@ def test_project_title_repository_creates_and_lists_titles():
def test_project_title_repository_filters_inactive_titles():
repository = _repository()
title = repository.create(
workspace_id="workspace-1",
project_id="project-1",
text="停用标题",
category="default",
-1
View File
@@ -163,7 +163,6 @@ class TestQuotaChecker:
status = checker.get_quota_status("workspace-123")
assert status is not None
assert status["workspace_id"] == "workspace-123"
assert status["subscription_plan"] == "free"
assert status["projects"]["used"] == 2
assert status["projects"]["limit"] == 3
-21
View File
@@ -58,14 +58,12 @@ class TestRemoveMemberUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
target_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="target-id",
role=WorkspaceMemberRole.MEMBER,
)
@@ -76,7 +74,6 @@ class TestRemoveMemberUseCase:
]
request = RemoveMemberRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
target_user_id="target-id",
)
@@ -99,14 +96,12 @@ class TestRemoveMemberUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
target_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="target-id",
role=WorkspaceMemberRole.VIEWER,
)
@@ -117,7 +112,6 @@ class TestRemoveMemberUseCase:
]
request = RemoveMemberRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="target-id",
)
@@ -139,14 +133,12 @@ class TestRemoveMemberUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
owner_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
@@ -157,7 +149,6 @@ class TestRemoveMemberUseCase:
]
request = RemoveMemberRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="owner-id",
)
@@ -179,14 +170,12 @@ class TestRemoveMemberUseCase:
admin_member1 = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id-1",
role=WorkspaceMemberRole.ADMIN,
)
admin_member2 = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="admin-id-2",
role=WorkspaceMemberRole.ADMIN,
)
@@ -197,7 +186,6 @@ class TestRemoveMemberUseCase:
]
request = RemoveMemberRequest(
workspace_id="workspace-123",
requester_user_id="admin-id-1",
target_user_id="admin-id-2",
)
@@ -219,7 +207,6 @@ class TestRemoveMemberUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -227,7 +214,6 @@ class TestRemoveMemberUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = admin_member
request = RemoveMemberRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="admin-id",
)
@@ -249,7 +235,6 @@ class TestRemoveMemberUseCase:
regular_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
)
@@ -257,7 +242,6 @@ class TestRemoveMemberUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = regular_member
request = RemoveMemberRequest(
workspace_id="workspace-123",
requester_user_id="user-id",
target_user_id="target-id",
)
@@ -311,14 +295,12 @@ class TestLeaveWorkspaceUseCase:
member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
)
mock_member_repo.find_by_workspace_and_user.return_value = member
request = LeaveWorkspaceRequest(
workspace_id="workspace-123",
user_id="user-id",
)
@@ -340,14 +322,12 @@ class TestLeaveWorkspaceUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = LeaveWorkspaceRequest(
workspace_id="workspace-123",
user_id="owner-id",
)
@@ -368,7 +348,6 @@ class TestLeaveWorkspaceUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = None
request = LeaveWorkspaceRequest(
workspace_id="workspace-123",
user_id="user-id",
)
-16
View File
@@ -55,14 +55,12 @@ class TestUpgradeSubscriptionUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = UpgradeSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
new_plan="pro",
)
@@ -100,14 +98,12 @@ class TestUpgradeSubscriptionUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = UpgradeSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
new_plan="enterprise",
)
@@ -137,14 +133,12 @@ class TestUpgradeSubscriptionUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = UpgradeSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
new_plan="free",
)
@@ -171,14 +165,12 @@ class TestUpgradeSubscriptionUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = UpgradeSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
new_plan="pro",
)
@@ -205,14 +197,12 @@ class TestUpgradeSubscriptionUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
mock_member_repo.find_by_workspace_and_user.return_value = admin_member
request = UpgradeSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
new_plan="pro",
)
@@ -265,14 +255,12 @@ class TestCancelSubscriptionUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = CancelSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
)
@@ -304,14 +292,12 @@ class TestCancelSubscriptionUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
mock_member_repo.find_by_workspace_and_user.return_value = owner_member
request = CancelSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
)
@@ -337,14 +323,12 @@ class TestCancelSubscriptionUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
mock_member_repo.find_by_workspace_and_user.return_value = admin_member
request = CancelSubscriptionRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
)
-1
View File
@@ -16,7 +16,6 @@ def test_humanize_task_error_for_media_failures():
def test_ingest_step_is_user_readable():
job = IngestJob.create(
workspace_id="workspace-1",
project_id="project-1",
library_id="library-1",
storage_key="uploads/video.mp4",
@@ -56,14 +56,12 @@ class TestUpdateMemberRoleUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
target_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="target-id",
role=WorkspaceMemberRole.MEMBER,
)
@@ -74,7 +72,6 @@ class TestUpdateMemberRoleUseCase:
]
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
target_user_id="target-id",
new_role="admin",
@@ -104,14 +101,12 @@ class TestUpdateMemberRoleUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
target_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="target-id",
role=WorkspaceMemberRole.VIEWER,
)
@@ -122,7 +117,6 @@ class TestUpdateMemberRoleUseCase:
]
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="target-id",
new_role="member",
@@ -145,14 +139,12 @@ class TestUpdateMemberRoleUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
owner_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
@@ -163,7 +155,6 @@ class TestUpdateMemberRoleUseCase:
]
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="owner-id",
new_role="member",
@@ -186,14 +177,12 @@ class TestUpdateMemberRoleUseCase:
admin_member1 = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id-1",
role=WorkspaceMemberRole.ADMIN,
)
admin_member2 = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="admin-id-2",
role=WorkspaceMemberRole.ADMIN,
)
@@ -204,7 +193,6 @@ class TestUpdateMemberRoleUseCase:
]
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="admin-id-1",
target_user_id="admin-id-2",
new_role="member",
@@ -227,7 +215,6 @@ class TestUpdateMemberRoleUseCase:
admin_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="admin-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -235,7 +222,6 @@ class TestUpdateMemberRoleUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = admin_member
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="admin-id",
new_role="member",
@@ -249,7 +235,6 @@ class TestUpdateMemberRoleUseCase:
def test_update_role_invalid_role(self, use_case):
"""测试无效角色(不能改为 owner"""
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="admin-id",
target_user_id="target-id",
new_role="owner",
@@ -272,14 +257,12 @@ class TestUpdateMemberRoleUseCase:
owner_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="owner-id",
role=WorkspaceMemberRole.OWNER,
)
target_member = WorkspaceMember(
id="member-2",
workspace_id="workspace-123",
user_id="target-id",
role=WorkspaceMemberRole.ADMIN,
)
@@ -290,7 +273,6 @@ class TestUpdateMemberRoleUseCase:
]
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="owner-id",
target_user_id="target-id",
new_role="admin",
@@ -313,7 +295,6 @@ class TestUpdateMemberRoleUseCase:
regular_member = WorkspaceMember(
id="member-1",
workspace_id="workspace-123",
user_id="user-id",
role=WorkspaceMemberRole.MEMBER,
)
@@ -321,7 +302,6 @@ class TestUpdateMemberRoleUseCase:
mock_member_repo.find_by_workspace_and_user.return_value = regular_member
request = UpdateMemberRoleRequest(
workspace_id="workspace-123",
requester_user_id="user-id",
target_user_id="target-id",
new_role="admin",
@@ -1,98 +0,0 @@
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_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.application.auth.password_hasher import password_hasher
from packages.domain.entities import User
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",
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
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:")
Base.metadata.create_all(engine)
session_factory = sessionmaker(bind=engine)
return session_factory()