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
This commit is contained in:
@@ -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`,快速对齐项目现状。**
|
||||
@@ -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')
|
||||
@@ -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=["项目推进管理"],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user