From 355dd0ffbb5a9af18fb7815f3340a14e55540309 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Tue, 16 Jun 2026 11:02:06 +0800 Subject: [PATCH] feat: add project management module (tasks, milestones, issues) - Domain: Task, Milestone, TaskIssue entities with business logic - Ports: TaskRepository, MilestoneRepository, TaskIssueRepository interfaces - Adapters: In-Memory and SQLAlchemy implementations - Application: Use cases for task/milestone/issue operations - API: FastAPI routes for project management - Database: Alembic migration 002 for new tables - Tests: 7 integration tests all passing --- STATUS.md | 143 +++++++ .../versions/002_add_project_management.py | 91 ++++ apps/api/app/api/router.py | 7 + apps/api/app/api/routes/project_management.py | 405 ++++++++++++++++++ .../project_management_repositories.py | 91 ++++ packages/adapters/sqlalchemy_impl/models.py | 55 ++- .../project_management_repositories.py | 243 +++++++++++ .../project_management_use_cases.py | 151 +++++++ packages/domain/__init__.py | 6 + packages/domain/project_management.py | 241 +++++++++++ packages/ports/__init__.py | 4 + .../ports/project_management_repositories.py | 101 +++++ tests/integration/test_project_management.py | 196 +++++++++ 13 files changed, 1733 insertions(+), 1 deletion(-) create mode 100644 STATUS.md create mode 100644 alembic/versions/002_add_project_management.py create mode 100644 apps/api/app/api/routes/project_management.py create mode 100644 packages/adapters/in_memory/project_management_repositories.py create mode 100644 packages/adapters/sqlalchemy_impl/project_management_repositories.py create mode 100644 packages/application/project_management_use_cases.py create mode 100644 packages/domain/project_management.py create mode 100644 packages/ports/project_management_repositories.py create mode 100644 tests/integration/test_project_management.py diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 000000000..f2cd3a60e --- /dev/null +++ b/STATUS.md @@ -0,0 +1,143 @@ +# Xiaoxia SaaS - 项目状态 + +> 最后更新:2026-06-16 +> 这是项目当前状态的单页真相,每次新会话优先读这个文件。 + +--- + +## 当前阶段 + +**Phase 1 已完成** - 核心平台层已搭建完成并稳定化中 + +--- + +## 已完成 + +### 核心架构 +- ✅ Clean Architecture 骨架(packages/domain + ports + application + adapters) +- ✅ 三层应用工程(apps/api + worker + web) +- ✅ 双持久化实现(In-Memory 测试 + PostgreSQL 生产) +- ✅ Alembic 数据库迁移 +- ✅ Docker Compose 开发环境 + +### 核心业务对象 +- ✅ User(用户) +- ✅ Workspace(工作空间) +- ✅ Project(项目) +- ✅ AssetLibrary(素材库:视频/音频) +- ✅ Asset(素材) +- ✅ IngestJob(入库任务:PENDING → PROCESSING → COMPLETED/FAILED) +- ✅ ClassificationJob(分类任务:含 9 种分类) + +### 核心业务流程 +- ✅ 上传 → 入库 → Asset 创建链路 +- ✅ 分类任务链路 +- ✅ 完整异步任务处理(Celery + Redis) + +### 基础设施 +- ✅ **MinIO 真实文件存储**(`apps/api/app/core/storage.py`) +- ✅ **Gitea CI/CD workflows**(`.gitea/workflows/tests.yml` + `deploy.yml`) +- ✅ 环境配置体系(`.env.staging` + `.env.production`) +- ✅ API 文档(Swagger) + +### 测试与质量 +- ✅ 8 个集成测试全绿 +- ✅ 测试策略:集成测试优先,覆盖核心业务流程 + +--- + +## 进行中 + +### 稳定化与部署打磨 +- 🔄 Gitea workflows 持续优化(最近 10+ commits) +- 🔄 部署流程与环境隔离稳定化 +- 🔄 Docker 构建优化(国内镜像源、多阶段构建) + +### 前端页面 +- 🔄 Next.js 前端占位已建立,实际页面待补 + +--- + +## 下一步优先级 + +1. **前端页面补齐** + - 项目/素材库管理页面 + - 素材上传/预览页面 + - 任务状态监控页面 + +2. **更完整的 SAAS 产品化能力** + - 多租户/组织/权限体系 + - 账号体系与认证 + - 订阅/计费体系 + +3. **业务能力扩展** + - 真实视频分类模型接入 + - 更多素材处理能力(剪辑/合成/配音) + - 批量任务调度 + +--- + +## 关键目录 + +**核心业务逻辑** +- `packages/domain/` - 核心实体与业务规则 +- `packages/application/` - 用例层 +- `packages/ports/` - 接口定义 +- `packages/adapters/` - 接口实现 + +**应用层** +- `apps/api/` - FastAPI REST API +- `apps/worker/` - Celery 异步任务 +- `apps/web/` - Next.js 前端 + +**基础设施** +- `infra/docker/` - Docker Compose 配置 +- `.gitea/workflows/` - CI/CD 流水线 + +**测试** +- `tests/integration/` - 集成测试 + +**数据库** +- `alembic/` - 数据库迁移 + +**文档** +- `docs/` - 项目文档 +- `README.md` - 项目总览与开发指南 + +--- + +## 关键决策记录 + +- 新 SaaS 与旧桌面版完全物理隔离 +- 旧桌面版仅作为业务参考,不再作为未来主线 +- 新系统从第一天起就遵循 Clean Architecture +- 持久化层提供双实现(in-memory + SQLAlchemy),方便测试 +- 测试策略:集成测试优先,覆盖核心业务流程 +- 数据库迁移从第一天起就版本化管理 +- CI/CD 基于 Gitea Actions + 自建 runner + +--- + +## 仓库信息 + +- **本地路径**: `F:\openclaw-saas` +- **远程仓库**: `xiaoxia-server:/var/lib/xiaoxia-ci/xiaoxia-saas.git` +- **分支**: `main` +- **最新提交**: `2c78407 fix: pass commit metadata into deploy builds` + +--- + +## 技术栈 + +**Backend**: Python 3.12 + FastAPI + Pydantic + SQLAlchemy + Alembic +**Worker**: Celery + Redis +**Database**: PostgreSQL + SQLite(测试) +**Storage**: MinIO(S3-compatible) +**Frontend**: Next.js 14 + TypeScript + React 18 +**CI/CD**: Gitea Actions +**Architecture**: Clean Architecture + Ports/Adapters +**Testing**: pytest + 集成测试优先 + +--- + +**以后每次新会话,先读这个文件 + `README.md`,快速对齐项目现状。** diff --git a/alembic/versions/002_add_project_management.py b/alembic/versions/002_add_project_management.py new file mode 100644 index 000000000..4f06e8472 --- /dev/null +++ b/alembic/versions/002_add_project_management.py @@ -0,0 +1,91 @@ +"""Add project management tables + +Revision ID: 002 +Revises: 001 +Create Date: 2026-06-16 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: Union[str, None] = '001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create tasks table + op.create_table( + 'tasks', + sa.Column('id', sa.String(32), nullable=False), + sa.Column('project_id', sa.String(32), nullable=False), + sa.Column('workspace_id', sa.String(32), nullable=False), + sa.Column('name', sa.String(200), nullable=False), + sa.Column('description', sa.Text(), nullable=False, server_default=''), + sa.Column('status', sa.String(20), nullable=False, server_default='pending'), + sa.Column('priority', sa.String(20), nullable=False, server_default='medium'), + sa.Column('parent_task_id', sa.String(32), nullable=False, server_default=''), + sa.Column('assignee_user_id', sa.String(32), nullable=False, server_default=''), + sa.Column('progress', sa.Float(), nullable=False, server_default='0'), + sa.Column('planned_start_date', sa.DateTime(), nullable=True), + sa.Column('planned_end_date', sa.DateTime(), nullable=True), + sa.Column('actual_start_date', sa.DateTime(), nullable=True), + sa.Column('actual_end_date', sa.DateTime(), nullable=True), + sa.Column('tags_json', sa.Text(), nullable=False, server_default='[]'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tasks_project_id'), 'tasks', ['project_id'], unique=False) + op.create_index(op.f('ix_tasks_workspace_id'), 'tasks', ['workspace_id'], unique=False) + op.create_index(op.f('ix_tasks_parent_task_id'), 'tasks', ['parent_task_id'], unique=False) + op.create_index(op.f('ix_tasks_status'), 'tasks', ['status'], unique=False) + + # Create milestones table + op.create_table( + 'milestones', + sa.Column('id', sa.String(32), nullable=False), + sa.Column('project_id', sa.String(32), nullable=False), + sa.Column('workspace_id', sa.String(32), nullable=False), + sa.Column('name', sa.String(200), nullable=False), + sa.Column('description', sa.Text(), nullable=False, server_default=''), + sa.Column('target_date', sa.DateTime(), nullable=True), + sa.Column('completed', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_milestones_project_id'), 'milestones', ['project_id'], unique=False) + op.create_index(op.f('ix_milestones_workspace_id'), 'milestones', ['workspace_id'], unique=False) + + # Create task_issues table + op.create_table( + 'task_issues', + sa.Column('id', sa.String(32), nullable=False), + sa.Column('task_id', sa.String(32), nullable=False), + sa.Column('project_id', sa.String(32), nullable=False), + sa.Column('workspace_id', sa.String(32), nullable=False), + sa.Column('title', sa.String(200), nullable=False), + sa.Column('description', sa.Text(), nullable=False, server_default=''), + sa.Column('resolved', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('resolved_at', sa.DateTime(), nullable=True), + sa.Column('created_by_user_id', sa.String(32), nullable=False, server_default=''), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_issues_task_id'), 'task_issues', ['task_id'], unique=False) + op.create_index(op.f('ix_task_issues_project_id'), 'task_issues', ['project_id'], unique=False) + op.create_index(op.f('ix_task_issues_workspace_id'), 'task_issues', ['workspace_id'], unique=False) + + +def downgrade() -> None: + op.drop_table('task_issues') + op.drop_table('milestones') + op.drop_table('tasks') diff --git a/apps/api/app/api/router.py b/apps/api/app/api/router.py index 64221f71f..4f8d0ace4 100644 --- a/apps/api/app/api/router.py +++ b/apps/api/app/api/router.py @@ -4,6 +4,7 @@ from app.api.routes.asset_libraries import router as asset_libraries_router from app.api.routes.assets import router as assets_router from app.api.routes.health import router as health_router from app.api.routes.ingest_jobs import router as ingest_jobs_router +from app.api.routes.project_management import router as project_management_router from app.api.routes.projects import router as projects_router from app.api.routes.upload import router as upload_router @@ -50,3 +51,9 @@ api_router.include_router( prefix="/upload", tags=["文件上传"], ) + +# Project Management (Tasks, Milestones, Issues) +api_router.include_router( + project_management_router, + tags=["项目推进管理"], +) diff --git a/apps/api/app/api/routes/project_management.py b/apps/api/app/api/routes/project_management.py new file mode 100644 index 000000000..431c2bad1 --- /dev/null +++ b/apps/api/app/api/routes/project_management.py @@ -0,0 +1,405 @@ +"""项目管理 API 路由""" +from datetime import datetime +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from packages.adapters.in_memory.project_management_repositories import ( + InMemoryMilestoneRepository, + InMemoryTaskIssueRepository, + InMemoryTaskRepository, +) +from packages.application.project_management_use_cases import ( + CreateMilestoneUseCase, + CreateTaskIssueUseCase, + CreateTaskUseCase, + ListProjectMilestonesUseCase, + ListProjectTasksUseCase, + ListTaskIssuesUseCase, + ResolveTaskIssueUseCase, + UpdateTaskProgressUseCase, + UpdateTaskStatusUseCase, +) +from packages.domain import TaskPriority, TaskStatus + +router = APIRouter(prefix="/api/v1/project-management", tags=["project-management"]) + +# 简易依赖注入(生产环境应走数据库) +_task_repo = InMemoryTaskRepository() +_milestone_repo = InMemoryMilestoneRepository() +_issue_repo = InMemoryTaskIssueRepository() + + +def get_task_repo(): + return _task_repo + + +def get_milestone_repo(): + return _milestone_repo + + +def get_issue_repo(): + return _issue_repo + + +# ========== Request/Response Models ========== + + +class CreateTaskRequest(BaseModel): + project_id: str + workspace_id: str + name: str + description: str = "" + priority: TaskPriority = TaskPriority.MEDIUM + parent_task_id: str = "" + assignee_user_id: str = "" + + +class TaskResponse(BaseModel): + id: str + project_id: str + workspace_id: str + name: str + description: str + status: TaskStatus + priority: TaskPriority + parent_task_id: str + assignee_user_id: str + progress: float + planned_start_date: datetime | None + planned_end_date: datetime | None + actual_start_date: datetime | None + actual_end_date: datetime | None + tags: list[str] + created_at: datetime + updated_at: datetime + + +class UpdateTaskStatusRequest(BaseModel): + status: TaskStatus + + +class UpdateTaskProgressRequest(BaseModel): + progress: Annotated[float, Field(ge=0, le=100)] + + +class CreateMilestoneRequest(BaseModel): + project_id: str + workspace_id: str + name: str + description: str = "" + + +class MilestoneResponse(BaseModel): + id: str + project_id: str + workspace_id: str + name: str + description: str + target_date: datetime | None + completed: bool + completed_at: datetime | None + created_at: datetime + updated_at: datetime + + +class CreateTaskIssueRequest(BaseModel): + task_id: str + project_id: str + workspace_id: str + title: str + description: str = "" + created_by_user_id: str = "" + + +class TaskIssueResponse(BaseModel): + id: str + task_id: str + project_id: str + workspace_id: str + title: str + description: str + resolved: bool + resolved_at: datetime | None + created_by_user_id: str + created_at: datetime + updated_at: datetime + + +# ========== Task Endpoints ========== + + +@router.post("/tasks", response_model=TaskResponse) +def create_task( + req: CreateTaskRequest, + task_repo=Depends(get_task_repo), +): + """创建任务""" + 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, + parent_task_id=req.parent_task_id, + assignee_user_id=req.assignee_user_id, + ) + return TaskResponse( + id=task.id, + project_id=task.project_id, + workspace_id=task.workspace_id, + name=task.name, + description=task.description, + status=task.status, + priority=task.priority, + parent_task_id=task.parent_task_id, + assignee_user_id=task.assignee_user_id, + progress=task.progress, + planned_start_date=task.planned_start_date, + planned_end_date=task.planned_end_date, + actual_start_date=task.actual_start_date, + actual_end_date=task.actual_end_date, + tags=task.tags, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +@router.get("/tasks", response_model=list[TaskResponse]) +def list_tasks( + project_id: str, + task_repo=Depends(get_task_repo), +): + """获取项目任务列表""" + use_case = ListProjectTasksUseCase(task_repo) + tasks = use_case.execute(project_id) + return [ + TaskResponse( + id=t.id, + project_id=t.project_id, + workspace_id=t.workspace_id, + name=t.name, + description=t.description, + status=t.status, + priority=t.priority, + parent_task_id=t.parent_task_id, + assignee_user_id=t.assignee_user_id, + progress=t.progress, + planned_start_date=t.planned_start_date, + planned_end_date=t.planned_end_date, + actual_start_date=t.actual_start_date, + actual_end_date=t.actual_end_date, + tags=t.tags, + created_at=t.created_at, + updated_at=t.updated_at, + ) + for t in tasks + ] + + +@router.patch("/tasks/{task_id}/status", response_model=TaskResponse) +def update_task_status( + task_id: str, + req: UpdateTaskStatusRequest, + task_repo=Depends(get_task_repo), +): + """更新任务状态""" + use_case = UpdateTaskStatusUseCase(task_repo) + try: + task = use_case.execute(task_id, req.status) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return TaskResponse( + id=task.id, + project_id=task.project_id, + workspace_id=task.workspace_id, + name=task.name, + description=task.description, + status=task.status, + priority=task.priority, + parent_task_id=task.parent_task_id, + assignee_user_id=task.assignee_user_id, + progress=task.progress, + planned_start_date=task.planned_start_date, + planned_end_date=task.planned_end_date, + actual_start_date=task.actual_start_date, + actual_end_date=task.actual_end_date, + tags=task.tags, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +@router.patch("/tasks/{task_id}/progress", response_model=TaskResponse) +def update_task_progress( + task_id: str, + req: UpdateTaskProgressRequest, + task_repo=Depends(get_task_repo), +): + """更新任务进度""" + use_case = UpdateTaskProgressUseCase(task_repo) + try: + task = use_case.execute(task_id, req.progress) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return TaskResponse( + id=task.id, + project_id=task.project_id, + workspace_id=task.workspace_id, + name=task.name, + description=task.description, + status=task.status, + priority=task.priority, + parent_task_id=task.parent_task_id, + assignee_user_id=task.assignee_user_id, + progress=task.progress, + planned_start_date=task.planned_start_date, + planned_end_date=task.planned_end_date, + actual_start_date=task.actual_start_date, + actual_end_date=task.actual_end_date, + tags=task.tags, + created_at=task.created_at, + updated_at=task.updated_at, + ) + + +# ========== Milestone Endpoints ========== + + +@router.post("/milestones", response_model=MilestoneResponse) +def create_milestone( + req: CreateMilestoneRequest, + milestone_repo=Depends(get_milestone_repo), +): + """创建里程碑""" + 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, + completed=milestone.completed, + completed_at=milestone.completed_at, + created_at=milestone.created_at, + updated_at=milestone.updated_at, + ) + + +@router.get("/milestones", response_model=list[MilestoneResponse]) +def list_milestones( + project_id: str, + milestone_repo=Depends(get_milestone_repo), +): + """获取项目里程碑列表""" + use_case = ListProjectMilestonesUseCase(milestone_repo) + milestones = use_case.execute(project_id) + return [ + 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, + completed=m.completed, + completed_at=m.completed_at, + created_at=m.created_at, + updated_at=m.updated_at, + ) + for m in milestones + ] + + +# ========== Task Issue Endpoints ========== + + +@router.post("/issues", response_model=TaskIssueResponse) +def create_issue( + req: CreateTaskIssueRequest, + issue_repo=Depends(get_issue_repo), +): + """创建任务问题""" + use_case = CreateTaskIssueUseCase(issue_repo) + 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, + ) + return TaskIssueResponse( + 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, + resolved_at=issue.resolved_at, + created_by_user_id=issue.created_by_user_id, + created_at=issue.created_at, + updated_at=issue.updated_at, + ) + + +@router.get("/issues", response_model=list[TaskIssueResponse]) +def list_issues( + task_id: str, + issue_repo=Depends(get_issue_repo), +): + """获取任务问题列表""" + use_case = ListTaskIssuesUseCase(issue_repo) + issues = use_case.execute(task_id) + return [ + TaskIssueResponse( + 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, + resolved_at=i.resolved_at, + created_by_user_id=i.created_by_user_id, + created_at=i.created_at, + updated_at=i.updated_at, + ) + for i in issues + ] + + +@router.patch("/issues/{issue_id}/resolve", response_model=TaskIssueResponse) +def resolve_issue( + issue_id: str, + issue_repo=Depends(get_issue_repo), +): + """解决任务问题""" + use_case = ResolveTaskIssueUseCase(issue_repo) + try: + issue = use_case.execute(issue_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + return TaskIssueResponse( + 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, + resolved_at=issue.resolved_at, + created_by_user_id=issue.created_by_user_id, + created_at=issue.created_at, + updated_at=issue.updated_at, + ) diff --git a/packages/adapters/in_memory/project_management_repositories.py b/packages/adapters/in_memory/project_management_repositories.py new file mode 100644 index 000000000..44257d050 --- /dev/null +++ b/packages/adapters/in_memory/project_management_repositories.py @@ -0,0 +1,91 @@ +"""项目管理 In-Memory Repository 实现""" +from packages.domain import Milestone, Task, TaskIssue +from packages.ports.project_management_repositories import ( + MilestoneRepository, + TaskIssueRepository, + TaskRepository, +) + + +class InMemoryTaskRepository(TaskRepository): + """任务 In-Memory 仓储实现""" + + def __init__(self): + self._store: dict[str, Task] = {} + + def create(self, task: Task) -> Task: + self._store[task.id] = task + return task + + def get_by_id(self, task_id: str) -> Task | None: + return self._store.get(task_id) + + def list_by_project(self, project_id: str) -> list[Task]: + return [t for t in self._store.values() if t.project_id == project_id] + + def list_by_parent(self, parent_task_id: str) -> list[Task]: + return [t for t in self._store.values() if t.parent_task_id == parent_task_id] + + def update(self, task: Task) -> Task: + if task.id not in self._store: + raise ValueError(f"Task {task.id} not found") + self._store[task.id] = task + return task + + def delete(self, task_id: str) -> None: + self._store.pop(task_id, None) + + +class InMemoryMilestoneRepository(MilestoneRepository): + """里程碑 In-Memory 仓储实现""" + + def __init__(self): + self._store: dict[str, Milestone] = {} + + def create(self, milestone: Milestone) -> Milestone: + self._store[milestone.id] = milestone + return milestone + + def get_by_id(self, milestone_id: str) -> Milestone | None: + return self._store.get(milestone_id) + + def list_by_project(self, project_id: str) -> list[Milestone]: + return [m for m in self._store.values() if m.project_id == project_id] + + def update(self, milestone: Milestone) -> Milestone: + if milestone.id not in self._store: + raise ValueError(f"Milestone {milestone.id} not found") + self._store[milestone.id] = milestone + return milestone + + def delete(self, milestone_id: str) -> None: + self._store.pop(milestone_id, None) + + +class InMemoryTaskIssueRepository(TaskIssueRepository): + """任务问题 In-Memory 仓储实现""" + + def __init__(self): + self._store: dict[str, TaskIssue] = {} + + def create(self, issue: TaskIssue) -> TaskIssue: + self._store[issue.id] = issue + return issue + + def get_by_id(self, issue_id: str) -> TaskIssue | None: + return self._store.get(issue_id) + + def list_by_task(self, task_id: str) -> list[TaskIssue]: + return [i for i in self._store.values() if i.task_id == task_id] + + def list_by_project(self, project_id: str) -> list[TaskIssue]: + return [i for i in self._store.values() if i.project_id == project_id] + + def update(self, issue: TaskIssue) -> TaskIssue: + if issue.id not in self._store: + raise ValueError(f"TaskIssue {issue.id} not found") + self._store[issue.id] = issue + return issue + + def delete(self, issue_id: str) -> None: + self._store.pop(issue_id, None) diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py index 93a172b0a..b6081916c 100644 --- a/packages/adapters/sqlalchemy_impl/models.py +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, DateTime, String, Text, create_engine +from sqlalchemy import Boolean, Column, DateTime, Float, String, Text, create_engine from sqlalchemy.orm import declarative_base from datetime import datetime, timezone @@ -53,3 +53,56 @@ class IngestJobModel(Base): result_asset_id = Column(String(32), nullable=False, default="") created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class TaskModel(Base): + __tablename__ = "tasks" + + id = Column(String(32), primary_key=True) + project_id = Column(String(32), nullable=False, index=True) + workspace_id = Column(String(32), nullable=False, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=False, default="") + status = Column(String(20), nullable=False, default="pending", index=True) + priority = Column(String(20), nullable=False, default="medium") + parent_task_id = Column(String(32), nullable=False, default="", index=True) + assignee_user_id = Column(String(32), nullable=False, default="") + progress = Column(Float, nullable=False, default=0.0) + planned_start_date = Column(DateTime, nullable=True) + planned_end_date = Column(DateTime, nullable=True) + actual_start_date = Column(DateTime, nullable=True) + actual_end_date = Column(DateTime, nullable=True) + tags_json = Column(Text, nullable=False, default="[]") + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class MilestoneModel(Base): + __tablename__ = "milestones" + + id = Column(String(32), primary_key=True) + project_id = Column(String(32), nullable=False, index=True) + workspace_id = Column(String(32), nullable=False, index=True) + name = Column(String(200), nullable=False) + description = Column(Text, nullable=False, default="") + target_date = Column(DateTime, nullable=True) + completed = Column(Boolean, nullable=False, default=False) + completed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + + +class TaskIssueModel(Base): + __tablename__ = "task_issues" + + id = Column(String(32), primary_key=True) + task_id = Column(String(32), nullable=False, index=True) + project_id = Column(String(32), nullable=False, index=True) + workspace_id = Column(String(32), nullable=False, index=True) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=False, default="") + resolved = Column(Boolean, nullable=False, default=False) + resolved_at = Column(DateTime, nullable=True) + created_by_user_id = Column(String(32), nullable=False, default="") + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/packages/adapters/sqlalchemy_impl/project_management_repositories.py b/packages/adapters/sqlalchemy_impl/project_management_repositories.py new file mode 100644 index 000000000..3790234b4 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/project_management_repositories.py @@ -0,0 +1,243 @@ +"""项目管理 SQLAlchemy Repository 实现""" +import json +from sqlalchemy.orm import Session + +from packages.domain import Milestone, Task, TaskIssue +from packages.ports.project_management_repositories import ( + MilestoneRepository, + TaskIssueRepository, + TaskRepository, +) +from .models import MilestoneModel, TaskIssueModel, TaskModel + + +class SQLAlchemyTaskRepository(TaskRepository): + """任务 SQLAlchemy 仓储实现""" + + def __init__(self, session: Session): + self._session = session + + def create(self, task: Task) -> Task: + 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, + priority=task.priority.value, + parent_task_id=task.parent_task_id, + assignee_user_id=task.assignee_user_id, + progress=task.progress, + planned_start_date=task.planned_start_date, + planned_end_date=task.planned_end_date, + actual_start_date=task.actual_start_date, + actual_end_date=task.actual_end_date, + tags_json=json.dumps(task.tags, ensure_ascii=False), + created_at=task.created_at, + updated_at=task.updated_at, + ) + self._session.add(model) + self._session.commit() + return task + + def get_by_id(self, task_id: str) -> Task | None: + model = self._session.query(TaskModel).filter(TaskModel.id == task_id).first() + if not model: + return None + return self._model_to_entity(model) + + def list_by_project(self, project_id: str) -> list[Task]: + models = self._session.query(TaskModel).filter(TaskModel.project_id == project_id).all() + return [self._model_to_entity(m) for m in models] + + def list_by_parent(self, parent_task_id: str) -> list[Task]: + models = self._session.query(TaskModel).filter(TaskModel.parent_task_id == parent_task_id).all() + return [self._model_to_entity(m) for m in models] + + def update(self, task: Task) -> Task: + model = self._session.query(TaskModel).filter(TaskModel.id == task.id).first() + if not model: + raise ValueError(f"Task {task.id} not found") + + model.name = task.name + model.description = task.description + model.status = task.status.value + model.priority = task.priority.value + model.parent_task_id = task.parent_task_id + model.assignee_user_id = task.assignee_user_id + model.progress = task.progress + model.planned_start_date = task.planned_start_date + model.planned_end_date = task.planned_end_date + model.actual_start_date = task.actual_start_date + model.actual_end_date = task.actual_end_date + model.tags_json = json.dumps(task.tags, ensure_ascii=False) + model.updated_at = task.updated_at + + self._session.commit() + return task + + def delete(self, task_id: str) -> None: + self._session.query(TaskModel).filter(TaskModel.id == task_id).delete() + self._session.commit() + + def _model_to_entity(self, model: TaskModel) -> Task: + from packages.domain.project_management import TaskPriority, TaskStatus + 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), + priority=TaskPriority(model.priority), + parent_task_id=model.parent_task_id, + assignee_user_id=model.assignee_user_id, + progress=model.progress, + planned_start_date=model.planned_start_date, + planned_end_date=model.planned_end_date, + actual_start_date=model.actual_start_date, + actual_end_date=model.actual_end_date, + tags=json.loads(model.tags_json), + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +class SQLAlchemyMilestoneRepository(MilestoneRepository): + """里程碑 SQLAlchemy 仓储实现""" + + def __init__(self, session: Session): + self._session = session + + def create(self, milestone: Milestone) -> Milestone: + 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, + completed=milestone.completed, + completed_at=milestone.completed_at, + created_at=milestone.created_at, + updated_at=milestone.updated_at, + ) + self._session.add(model) + self._session.commit() + return milestone + + def get_by_id(self, milestone_id: str) -> Milestone | None: + model = self._session.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).first() + if not model: + return None + return self._model_to_entity(model) + + def list_by_project(self, project_id: str) -> list[Milestone]: + models = self._session.query(MilestoneModel).filter(MilestoneModel.project_id == project_id).all() + return [self._model_to_entity(m) for m in models] + + def update(self, milestone: Milestone) -> Milestone: + model = self._session.query(MilestoneModel).filter(MilestoneModel.id == milestone.id).first() + if not model: + raise ValueError(f"Milestone {milestone.id} not found") + + model.name = milestone.name + model.description = milestone.description + model.target_date = milestone.target_date + model.completed = milestone.completed + model.completed_at = milestone.completed_at + model.updated_at = milestone.updated_at + + self._session.commit() + return milestone + + def delete(self, milestone_id: str) -> None: + self._session.query(MilestoneModel).filter(MilestoneModel.id == milestone_id).delete() + self._session.commit() + + def _model_to_entity(self, model: MilestoneModel) -> Milestone: + 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, + completed=model.completed, + completed_at=model.completed_at, + created_at=model.created_at, + updated_at=model.updated_at, + ) + + +class SQLAlchemyTaskIssueRepository(TaskIssueRepository): + """任务问题 SQLAlchemy 仓储实现""" + + def __init__(self, session: Session): + self._session = session + + def create(self, issue: TaskIssue) -> TaskIssue: + model = TaskIssueModel( + 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, + resolved_at=issue.resolved_at, + created_by_user_id=issue.created_by_user_id, + created_at=issue.created_at, + updated_at=issue.updated_at, + ) + self._session.add(model) + self._session.commit() + return issue + + def get_by_id(self, issue_id: str) -> TaskIssue | None: + model = self._session.query(TaskIssueModel).filter(TaskIssueModel.id == issue_id).first() + if not model: + return None + return self._model_to_entity(model) + + def list_by_task(self, task_id: str) -> list[TaskIssue]: + models = self._session.query(TaskIssueModel).filter(TaskIssueModel.task_id == task_id).all() + return [self._model_to_entity(m) for m in models] + + def list_by_project(self, project_id: str) -> list[TaskIssue]: + models = self._session.query(TaskIssueModel).filter(TaskIssueModel.project_id == project_id).all() + return [self._model_to_entity(m) for m in models] + + def update(self, issue: TaskIssue) -> TaskIssue: + model = self._session.query(TaskIssueModel).filter(TaskIssueModel.id == issue.id).first() + if not model: + raise ValueError(f"TaskIssue {issue.id} not found") + + model.title = issue.title + model.description = issue.description + model.resolved = issue.resolved + model.resolved_at = issue.resolved_at + model.updated_at = issue.updated_at + + self._session.commit() + return issue + + def delete(self, issue_id: str) -> None: + self._session.query(TaskIssueModel).filter(TaskIssueModel.id == issue_id).delete() + self._session.commit() + + def _model_to_entity(self, model: TaskIssueModel) -> TaskIssue: + return TaskIssue( + 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, + resolved_at=model.resolved_at, + created_by_user_id=model.created_by_user_id, + created_at=model.created_at, + updated_at=model.updated_at, + ) diff --git a/packages/application/project_management_use_cases.py b/packages/application/project_management_use_cases.py new file mode 100644 index 000000000..9efdba7f6 --- /dev/null +++ b/packages/application/project_management_use_cases.py @@ -0,0 +1,151 @@ +"""项目管理 Use Cases""" +from packages.domain import Milestone, Task, TaskIssue, TaskPriority, TaskStatus +from packages.ports import MilestoneRepository, TaskIssueRepository, TaskRepository + + +class CreateTaskUseCase: + """创建任务用例""" + + def __init__(self, task_repo: TaskRepository): + self.task_repo = task_repo + + def execute( + self, + project_id: str, + workspace_id: str, + name: str, + description: str = "", + priority: TaskPriority = TaskPriority.MEDIUM, + parent_task_id: str = "", + assignee_user_id: str = "", + ) -> Task: + task = Task.create( + project_id=project_id, + workspace_id=workspace_id, + name=name, + description=description, + priority=priority, + parent_task_id=parent_task_id, + assignee_user_id=assignee_user_id, + ) + return self.task_repo.create(task) + + +class ListProjectTasksUseCase: + """获取项目任务列表用例""" + + def __init__(self, task_repo: TaskRepository): + self.task_repo = task_repo + + def execute(self, project_id: str) -> list[Task]: + return self.task_repo.list_by_project(project_id) + + +class UpdateTaskStatusUseCase: + """更新任务状态用例""" + + def __init__(self, task_repo: TaskRepository): + self.task_repo = task_repo + + def execute(self, task_id: str, new_status: TaskStatus) -> Task: + task = self.task_repo.get_by_id(task_id) + if not task: + raise ValueError(f"Task {task_id} not found") + task.update_status(new_status) + return self.task_repo.update(task) + + +class UpdateTaskProgressUseCase: + """更新任务进度用例""" + + def __init__(self, task_repo: TaskRepository): + self.task_repo = task_repo + + def execute(self, task_id: str, progress: float) -> Task: + task = self.task_repo.get_by_id(task_id) + if not task: + raise ValueError(f"Task {task_id} not found") + task.update_progress(progress) + return self.task_repo.update(task) + + +class CreateMilestoneUseCase: + """创建里程碑用例""" + + def __init__(self, milestone_repo: MilestoneRepository): + self.milestone_repo = milestone_repo + + 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, + ) + return self.milestone_repo.create(milestone) + + +class ListProjectMilestonesUseCase: + """获取项目里程碑列表用例""" + + def __init__(self, milestone_repo: MilestoneRepository): + self.milestone_repo = milestone_repo + + def execute(self, project_id: str) -> list[Milestone]: + return self.milestone_repo.list_by_project(project_id) + + +class CreateTaskIssueUseCase: + """创建任务问题用例""" + + def __init__(self, issue_repo: TaskIssueRepository): + self.issue_repo = issue_repo + + def execute( + self, + task_id: str, + project_id: str, + workspace_id: str, + title: str, + description: str = "", + created_by_user_id: str = "", + ) -> TaskIssue: + 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, + ) + return self.issue_repo.create(issue) + + +class ListTaskIssuesUseCase: + """获取任务问题列表用例""" + + def __init__(self, issue_repo: TaskIssueRepository): + self.issue_repo = issue_repo + + def execute(self, task_id: str) -> list[TaskIssue]: + return self.issue_repo.list_by_task(task_id) + + +class ResolveTaskIssueUseCase: + """解决任务问题用例""" + + def __init__(self, issue_repo: TaskIssueRepository): + self.issue_repo = issue_repo + + def execute(self, issue_id: str) -> TaskIssue: + issue = self.issue_repo.get_by_id(issue_id) + if not issue: + raise ValueError(f"TaskIssue {issue_id} not found") + issue.mark_resolved() + return self.issue_repo.update(issue) diff --git a/packages/domain/__init__.py b/packages/domain/__init__.py index 087eb05b4..7b081b6b1 100644 --- a/packages/domain/__init__.py +++ b/packages/domain/__init__.py @@ -2,6 +2,7 @@ from .classification import AssetClassification, ClassificationJob, ClassificationJobStatus from .entities import Asset, AssetLibrary, AssetLibraryKind, IngestJob, IngestJobStatus, Project, User, Workspace +from .project_management import Milestone, Task, TaskIssue, TaskPriority, TaskStatus __all__ = [ "Asset", @@ -12,7 +13,12 @@ __all__ = [ "ClassificationJobStatus", "IngestJob", "IngestJobStatus", + "Milestone", "Project", + "Task", + "TaskIssue", + "TaskPriority", + "TaskStatus", "User", "Workspace", ] diff --git a/packages/domain/project_management.py b/packages/domain/project_management.py new file mode 100644 index 000000000..331bb3ccf --- /dev/null +++ b/packages/domain/project_management.py @@ -0,0 +1,241 @@ +"""项目管理领域对象:任务、里程碑、项目阶段""" +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum +from uuid import uuid4 + + +class TaskStatus(StrEnum): + """任务状态""" + PENDING = "pending" # 待开始 + IN_PROGRESS = "in_progress" # 进行中 + BLOCKED = "blocked" # 阻塞 + COMPLETED = "completed" # 已完成 + CANCELLED = "cancelled" # 已取消 + + +class TaskPriority(StrEnum): + """任务优先级""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + + +@dataclass(slots=True) +class Task: + """任务实体""" + id: str + project_id: str + workspace_id: str + name: str + description: str = "" + status: TaskStatus = TaskStatus.PENDING + priority: TaskPriority = TaskPriority.MEDIUM + parent_task_id: str = "" # 父任务ID(支持子任务层级) + assignee_user_id: str = "" # 负责人 + progress: float = 0.0 # 进度 0-100 + planned_start_date: datetime | None = None + planned_end_date: datetime | None = None + actual_start_date: datetime | None = None + actual_end_date: datetime | None = None + tags: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + project_id: str, + workspace_id: str, + name: str, + description: str = "", + priority: TaskPriority = TaskPriority.MEDIUM, + parent_task_id: str = "", + assignee_user_id: str = "", + planned_start_date: datetime | None = None, + planned_end_date: datetime | None = None, + ) -> "Task": + """创建任务""" + clean_name = name.strip() + if not clean_name: + 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, + parent_task_id=parent_task_id.strip(), + assignee_user_id=assignee_user_id.strip(), + planned_start_date=planned_start_date, + planned_end_date=planned_end_date, + ) + + def update_status(self, new_status: TaskStatus) -> None: + """更新任务状态""" + self.status = new_status + self.updated_at = datetime.now(timezone.utc) + + # 自动设置实际开始/结束时间 + if new_status == TaskStatus.IN_PROGRESS and self.actual_start_date is None: + self.actual_start_date = datetime.now(timezone.utc) + elif new_status == TaskStatus.COMPLETED and self.actual_end_date is None: + self.actual_end_date = datetime.now(timezone.utc) + self.progress = 100.0 + + def update_progress(self, progress: float) -> None: + """更新任务进度""" + if not 0 <= progress <= 100: + raise ValueError("进度必须在 0-100 之间") + self.progress = progress + self.updated_at = datetime.now(timezone.utc) + + # 自动更新状态 + if progress > 0 and self.status == TaskStatus.PENDING: + self.status = TaskStatus.IN_PROGRESS + if progress == 100 and self.status != TaskStatus.COMPLETED: + self.status = TaskStatus.COMPLETED + if self.actual_end_date is None: + self.actual_end_date = datetime.now(timezone.utc) + + def add_tag(self, tag: str) -> None: + """添加标签""" + clean_tag = tag.strip() + if not clean_tag: + raise ValueError("标签不能为空") + if clean_tag not in self.tags: + self.tags.append(clean_tag) + self.updated_at = datetime.now(timezone.utc) + + def remove_tag(self, tag: str) -> None: + """删除标签""" + clean_tag = tag.strip() + if clean_tag in self.tags: + self.tags.remove(clean_tag) + self.updated_at = datetime.now(timezone.utc) + + +@dataclass(slots=True) +class Milestone: + """里程碑实体""" + id: str + project_id: str + workspace_id: str + name: str + description: str = "" + target_date: datetime | None = None + completed: bool = False + completed_at: datetime | None = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + project_id: str, + workspace_id: str, + name: str, + description: str = "", + target_date: datetime | None = None, + ) -> "Milestone": + """创建里程碑""" + clean_name = name.strip() + if not clean_name: + 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, + ) + + def mark_completed(self) -> None: + """标记为已完成""" + if not self.completed: + self.completed = True + self.completed_at = datetime.now(timezone.utc) + self.updated_at = datetime.now(timezone.utc) + + def reopen(self) -> None: + """重新打开里程碑""" + if self.completed: + self.completed = False + self.completed_at = None + self.updated_at = datetime.now(timezone.utc) + + +@dataclass(slots=True) +class TaskIssue: + """任务问题/卡点实体""" + id: str + task_id: str + project_id: str + workspace_id: str + title: str + description: str = "" + resolved: bool = False + resolved_at: datetime | None = None + created_by_user_id: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @classmethod + def create( + cls, + task_id: str, + project_id: str, + workspace_id: str, + title: str, + description: str = "", + created_by_user_id: str = "", + ) -> "TaskIssue": + """创建任务问题""" + clean_title = title.strip() + if not clean_title: + raise ValueError("问题标题不能为空") + if not task_id.strip(): + 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(), + ) + + def mark_resolved(self) -> None: + """标记为已解决""" + if not self.resolved: + self.resolved = True + self.resolved_at = datetime.now(timezone.utc) + self.updated_at = datetime.now(timezone.utc) + + def reopen(self) -> None: + """重新打开问题""" + if self.resolved: + self.resolved = False + self.resolved_at = None + self.updated_at = datetime.now(timezone.utc) diff --git a/packages/ports/__init__.py b/packages/ports/__init__.py index 6a6259f64..f53fe2cb4 100644 --- a/packages/ports/__init__.py +++ b/packages/ports/__init__.py @@ -3,11 +3,15 @@ from .asset_library_repository import AssetLibraryRepository from .asset_repository import AssetRepository from .ingest_job_repository import IngestJobRepository +from .project_management_repositories import MilestoneRepository, TaskIssueRepository, TaskRepository from .project_repository import ProjectRepository __all__ = [ "AssetLibraryRepository", "AssetRepository", "IngestJobRepository", + "MilestoneRepository", "ProjectRepository", + "TaskIssueRepository", + "TaskRepository", ] diff --git a/packages/ports/project_management_repositories.py b/packages/ports/project_management_repositories.py new file mode 100644 index 000000000..83e087faa --- /dev/null +++ b/packages/ports/project_management_repositories.py @@ -0,0 +1,101 @@ +"""项目管理 Repository 接口定义""" +from abc import ABC, abstractmethod + +from packages.domain import Milestone, Task, TaskIssue + + +class TaskRepository(ABC): + """任务仓储接口""" + + @abstractmethod + def create(self, task: Task) -> Task: + """创建任务""" + pass + + @abstractmethod + def get_by_id(self, task_id: str) -> Task | None: + """根据ID获取任务""" + pass + + @abstractmethod + def list_by_project(self, project_id: str) -> list[Task]: + """获取项目下的所有任务""" + pass + + @abstractmethod + def list_by_parent(self, parent_task_id: str) -> list[Task]: + """获取子任务列表""" + pass + + @abstractmethod + def update(self, task: Task) -> Task: + """更新任务""" + pass + + @abstractmethod + def delete(self, task_id: str) -> None: + """删除任务""" + pass + + +class MilestoneRepository(ABC): + """里程碑仓储接口""" + + @abstractmethod + def create(self, milestone: Milestone) -> Milestone: + """创建里程碑""" + pass + + @abstractmethod + def get_by_id(self, milestone_id: str) -> Milestone | None: + """根据ID获取里程碑""" + pass + + @abstractmethod + def list_by_project(self, project_id: str) -> list[Milestone]: + """获取项目下的所有里程碑""" + pass + + @abstractmethod + def update(self, milestone: Milestone) -> Milestone: + """更新里程碑""" + pass + + @abstractmethod + def delete(self, milestone_id: str) -> None: + """删除里程碑""" + pass + + +class TaskIssueRepository(ABC): + """任务问题仓储接口""" + + @abstractmethod + def create(self, issue: TaskIssue) -> TaskIssue: + """创建任务问题""" + pass + + @abstractmethod + def get_by_id(self, issue_id: str) -> TaskIssue | None: + """根据ID获取任务问题""" + pass + + @abstractmethod + def list_by_task(self, task_id: str) -> list[TaskIssue]: + """获取任务下的所有问题""" + pass + + @abstractmethod + def list_by_project(self, project_id: str) -> list[TaskIssue]: + """获取项目下的所有问题""" + pass + + @abstractmethod + def update(self, issue: TaskIssue) -> TaskIssue: + """更新任务问题""" + pass + + @abstractmethod + def delete(self, issue_id: str) -> None: + """删除任务问题""" + pass diff --git a/tests/integration/test_project_management.py b/tests/integration/test_project_management.py new file mode 100644 index 000000000..8be018887 --- /dev/null +++ b/tests/integration/test_project_management.py @@ -0,0 +1,196 @@ +"""项目管理功能集成测试""" +import pytest + +from packages.adapters.in_memory.project_management_repositories import ( + InMemoryMilestoneRepository, + InMemoryTaskIssueRepository, + InMemoryTaskRepository, +) +from packages.application.project_management_use_cases import ( + CreateMilestoneUseCase, + CreateTaskIssueUseCase, + CreateTaskUseCase, + ListProjectTasksUseCase, + ListTaskIssuesUseCase, + ResolveTaskIssueUseCase, + UpdateTaskProgressUseCase, + UpdateTaskStatusUseCase, +) +from packages.domain import TaskPriority, TaskStatus + + +def test_create_task(): + """测试创建任务""" + repo = InMemoryTaskRepository() + use_case = CreateTaskUseCase(repo) + + task = use_case.execute( + project_id="proj_1", + workspace_id="ws_1", + name="开发登录功能", + description="实现用户登录功能", + priority=TaskPriority.HIGH, + ) + + assert task.id is not None + assert task.name == "开发登录功能" + assert task.status == TaskStatus.PENDING + assert task.priority == TaskPriority.HIGH + assert task.progress == 0.0 + + +def test_list_tasks(): + """测试获取任务列表""" + repo = InMemoryTaskRepository() + create_use_case = CreateTaskUseCase(repo) + + # 创建两个任务 + 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", + ) + + # 查询任务列表 + list_use_case = ListProjectTasksUseCase(repo) + tasks = list_use_case.execute("proj_1") + + assert len(tasks) == 2 + assert tasks[0].name == "任务1" + assert tasks[1].name == "任务2" + + +def test_update_task_status(): + """测试更新任务状态""" + repo = InMemoryTaskRepository() + create_use_case = CreateTaskUseCase(repo) + update_use_case = UpdateTaskStatusUseCase(repo) + + # 创建任务 + task = create_use_case.execute( + project_id="proj_1", + workspace_id="ws_1", + name="测试任务", + ) + + # 更新状态为进行中 + updated_task = update_use_case.execute(task.id, TaskStatus.IN_PROGRESS) + + assert updated_task.status == TaskStatus.IN_PROGRESS + assert updated_task.actual_start_date is not None + + +def test_update_task_progress(): + """测试更新任务进度""" + repo = InMemoryTaskRepository() + create_use_case = CreateTaskUseCase(repo) + progress_use_case = UpdateTaskProgressUseCase(repo) + + # 创建任务 + task = create_use_case.execute( + project_id="proj_1", + workspace_id="ws_1", + name="测试任务", + ) + + # 更新进度到 50% + updated_task = progress_use_case.execute(task.id, 50.0) + + assert updated_task.progress == 50.0 + assert updated_task.status == TaskStatus.IN_PROGRESS + + # 更新进度到 100% + completed_task = progress_use_case.execute(task.id, 100.0) + + assert completed_task.progress == 100.0 + assert completed_task.status == TaskStatus.COMPLETED + assert completed_task.actual_end_date is not None + + +def test_create_milestone(): + """测试创建里程碑""" + repo = InMemoryMilestoneRepository() + use_case = CreateMilestoneUseCase(repo) + + milestone = use_case.execute( + project_id="proj_1", + workspace_id="ws_1", + name="V1.0 发布", + description="第一个正式版本", + ) + + assert milestone.id is not None + assert milestone.name == "V1.0 发布" + assert milestone.completed is False + + +def test_create_and_resolve_issue(): + """测试创建和解决任务问题""" + repo = InMemoryTaskIssueRepository() + create_use_case = CreateTaskIssueUseCase(repo) + resolve_use_case = ResolveTaskIssueUseCase(repo) + list_use_case = ListTaskIssuesUseCase(repo) + + # 创建问题 + issue = create_use_case.execute( + task_id="task_1", + project_id="proj_1", + workspace_id="ws_1", + title="接口报错", + description="调用登录接口返回 500", + ) + + assert issue.id is not None + assert issue.title == "接口报错" + assert issue.resolved is False + + # 解决问题 + resolved_issue = resolve_use_case.execute(issue.id) + + assert resolved_issue.resolved is True + assert resolved_issue.resolved_at is not None + + # 查询任务问题列表 + issues = list_use_case.execute("task_1") + assert len(issues) == 1 + assert issues[0].resolved is True + + +def test_task_hierarchy(): + """测试任务层级关系""" + repo = InMemoryTaskRepository() + create_use_case = CreateTaskUseCase(repo) + + # 创建父任务 + 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, + ) + + # 查询子任务 + children = repo.list_by_parent(parent_task.id) + + assert len(children) == 2 + assert children[0].parent_task_id == parent_task.id + assert children[1].parent_task_id == parent_task.id