Step 5:里程碑与任务(伦理/启动会进度)

This commit is contained in:
Cheng Zhou
2025-12-16 17:18:26 +08:00
parent 6c4a2eb1dc
commit a9e15faedc
72 changed files with 535 additions and 1 deletions
+51
View File
@@ -0,0 +1,51 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.models.milestone import Milestone
from app.models.task import Task
from app.schemas.progress import StudyProgressRead
router = APIRouter()
@router.get("/progress", response_model=StudyProgressRead, dependencies=[Depends(require_study_member())])
async def get_progress(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> StudyProgressRead:
# Milestones
milestone_total_stmt = select(func.count()).select_from(Milestone).where(Milestone.study_id == study_id)
milestone_done_stmt = select(func.count()).select_from(Milestone).where(
Milestone.study_id == study_id, Milestone.status == "DONE"
)
task_total_stmt = select(func.count()).select_from(Task).where(Task.study_id == study_id)
task_done_stmt = select(func.count()).select_from(Task).where(Task.study_id == study_id, Task.status == "DONE")
task_overdue_stmt = select(func.count()).select_from(Task).where(
Task.study_id == study_id,
Task.due_date < date.today(),
Task.status != "DONE",
)
milestone_total = (await db.execute(milestone_total_stmt)).scalar_one()
milestone_done = (await db.execute(milestone_done_stmt)).scalar_one()
task_total = (await db.execute(task_total_stmt)).scalar_one()
task_done = (await db.execute(task_done_stmt)).scalar_one()
task_overdue = (await db.execute(task_overdue_stmt)).scalar_one()
total_items = milestone_total + task_total
done_items = milestone_done + task_done
completion_rate = float(done_items / total_items) if total_items else 0.0
return StudyProgressRead(
milestones_total=milestone_total,
milestones_done=milestone_done,
tasks_total=task_total,
tasks_done=task_done,
tasks_overdue=task_overdue,
completion_rate=completion_rate,
)
+94
View File
@@ -0,0 +1,94 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import milestone as milestone_crud
from app.crud import study as study_crud
from app.schemas.milestone import MilestoneCreate, MilestoneRead, MilestoneUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
@router.post(
"/",
response_model=MilestoneRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def create_milestone(
study_id: uuid.UUID,
milestone_in: MilestoneCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> MilestoneRead:
await _ensure_study_exists(db, study_id)
milestone = await milestone_crud.create(db, study_id, milestone_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="milestone",
entity_id=milestone.id,
action="CREATE_MILESTONE",
detail=f"Milestone {milestone.id} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return milestone
@router.get(
"/",
response_model=list[MilestoneRead],
dependencies=[Depends(require_study_member())],
)
async def list_milestones(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> list[MilestoneRead]:
await _ensure_study_exists(db, study_id)
milestones = await milestone_crud.list_milestones(db, study_id)
return list(milestones)
@router.patch(
"/{milestone_id}",
response_model=MilestoneRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_milestone(
study_id: uuid.UUID,
milestone_id: uuid.UUID,
milestone_in: MilestoneUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> MilestoneRead:
await _ensure_study_exists(db, study_id)
milestone = await milestone_crud.get(db, milestone_id)
if not milestone or milestone.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
old_status = milestone.status
updated = await milestone_crud.update(db, milestone, milestone_in)
detail = None
if milestone_in.status and milestone_in.status != old_status:
detail = f"milestone {milestone_id} status {old_status} -> {milestone_in.status}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="milestone",
entity_id=milestone_id,
action="UPDATE_MILESTONE",
detail=detail or "Milestone updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return updated
+4 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -11,3 +11,6 @@ api_router.include_router(members.router, prefix="/studies/{study_id}/members",
api_router.include_router(comments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/comments", tags=["comments"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags=["tasks"])
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
+114
View File
@@ -0,0 +1,114 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import milestone as milestone_crud
from app.crud import study as study_crud
from app.crud import task as task_crud
from app.schemas.task import TaskCreate, TaskRead, TaskUpdate
router = APIRouter()
ALLOW_ASSIGNEE_UPDATE_STATUS = False
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
return study
async def _validate_milestone(db: AsyncSession, study_id: uuid.UUID, milestone_id: uuid.UUID | None):
if milestone_id is None:
return
milestone = await milestone_crud.get(db, milestone_id)
if not milestone:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
if milestone.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Milestone does not belong to study")
@router.post(
"/",
response_model=TaskRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def create_task(
study_id: uuid.UUID,
task_in: TaskCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TaskRead:
await _ensure_study_exists(db, study_id)
await _validate_milestone(db, study_id, task_in.milestone_id)
task = await task_crud.create(db, study_id, task_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="task",
entity_id=task.id,
action="CREATE_TASK",
detail=f"Task {task.id} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return task
@router.get(
"/",
response_model=list[TaskRead],
dependencies=[Depends(require_study_member())],
)
async def list_tasks(
study_id: uuid.UUID,
milestone_id: uuid.UUID | None = None,
assignee_id: uuid.UUID | None = None,
status: str | None = None,
db: AsyncSession = Depends(get_db_session),
) -> list[TaskRead]:
await _ensure_study_exists(db, study_id)
tasks = await task_crud.list_tasks(db, study_id, milestone_id=milestone_id, assignee_id=assignee_id, status=status)
return list(tasks)
@router.patch(
"/{task_id}",
response_model=TaskRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_task(
study_id: uuid.UUID,
task_id: uuid.UUID,
task_in: TaskUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> TaskRead:
await _ensure_study_exists(db, study_id)
task = await task_crud.get(db, task_id)
if not task or task.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
if task_in.milestone_id:
await _validate_milestone(db, study_id, task_in.milestone_id)
old_status = task.status
updated = await task_crud.update(db, task, task_in)
detail = None
if task_in.status and task_in.status != old_status:
detail = f"task {task_id} status {old_status} -> {task_in.status}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="task",
entity_id=task_id,
action="UPDATE_TASK" if not detail else "TASK_STATUS_CHANGE",
detail=detail or "Task updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return updated
+48
View File
@@ -0,0 +1,48 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.milestone import Milestone
from app.schemas.milestone import MilestoneCreate, MilestoneUpdate
async def create(db: AsyncSession, study_id: uuid.UUID, milestone_in: MilestoneCreate) -> Milestone:
milestone = Milestone(
study_id=study_id,
type=milestone_in.type,
name=milestone_in.name or milestone_in.type,
planned_date=milestone_in.planned_date,
actual_date=None,
status=milestone_in.status or "NOT_STARTED",
owner_id=milestone_in.owner_id,
notes=milestone_in.notes,
)
db.add(milestone)
await db.commit()
await db.refresh(milestone)
return milestone
async def get(db: AsyncSession, milestone_id: uuid.UUID) -> Milestone | None:
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
return result.scalar_one_or_none()
async def list_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]:
result = await db.execute(select(Milestone).where(Milestone.study_id == study_id).order_by(Milestone.planned_date))
return result.scalars().all()
async def update(db: AsyncSession, milestone: Milestone, milestone_in: MilestoneUpdate) -> Milestone:
update_data = milestone_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(Milestone)
.where(Milestone.id == milestone.id)
.values(**update_data)
)
await db.commit()
await db.refresh(milestone)
return milestone
+77
View File
@@ -0,0 +1,77 @@
import uuid
from datetime import datetime, timezone
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.task import Task
from app.schemas.task import TaskCreate, TaskUpdate
async def create(
db: AsyncSession,
study_id: uuid.UUID,
task_in: TaskCreate,
*,
created_by: uuid.UUID,
) -> Task:
task = Task(
study_id=study_id,
milestone_id=task_in.milestone_id,
title=task_in.title,
description=task_in.description,
assignee_id=task_in.assignee_id,
priority=task_in.priority,
due_date=task_in.due_date,
status="TODO",
created_by=created_by,
)
db.add(task)
await db.commit()
await db.refresh(task)
return task
async def get(db: AsyncSession, task_id: uuid.UUID) -> Task | None:
result = await db.execute(select(Task).where(Task.id == task_id))
return result.scalar_one_or_none()
async def list_tasks(
db: AsyncSession,
study_id: uuid.UUID,
milestone_id: uuid.UUID | None = None,
assignee_id: uuid.UUID | None = None,
status: str | None = None,
) -> Sequence[Task]:
stmt = select(Task).where(Task.study_id == study_id)
if milestone_id:
stmt = stmt.where(Task.milestone_id == milestone_id)
if assignee_id:
stmt = stmt.where(Task.assignee_id == assignee_id)
if status:
stmt = stmt.where(Task.status == status)
stmt = stmt.order_by(Task.due_date)
result = await db.execute(stmt)
return result.scalars().all()
async def update(db: AsyncSession, task: Task, task_in: TaskUpdate) -> Task:
update_data = task_in.model_dump(exclude_unset=True)
if "status" in update_data:
status_change = update_data["status"]
if status_change == "DONE":
update_data["completed_at"] = datetime.now(timezone.utc)
else:
update_data["completed_at"] = None
if update_data:
await db.execute(
sa_update(Task)
.where(Task.id == task.id)
.values(**update_data)
)
await db.commit()
await db.refresh(task)
return task
+2
View File
@@ -8,3 +8,5 @@ from app.models.study_member import StudyMember # noqa: F401
from app.models.comment import Comment # noqa: F401
from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.milestone import Milestone # noqa: F401
from app.models.task import Task # noqa: F401
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Milestone(Base):
__tablename__ = "milestones"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
type: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
milestone_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("milestones.id"), nullable=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
assignee_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="MEDIUM")
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="TODO")
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+39
View File
@@ -0,0 +1,39 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class MilestoneCreate(BaseModel):
type: str
name: Optional[str] = None
planned_date: Optional[date] = None
owner_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
status: str = Field(default="NOT_STARTED")
class MilestoneUpdate(BaseModel):
name: Optional[str] = None
planned_date: Optional[date] = None
actual_date: Optional[date] = None
status: Optional[str] = None
owner_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
class MilestoneRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
type: str
name: str
planned_date: Optional[date]
actual_date: Optional[date]
status: str
owner_id: Optional[uuid.UUID]
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
class StudyProgressRead(BaseModel):
milestones_total: int
milestones_done: int
tasks_total: int
tasks_done: int
tasks_overdue: int
completion_rate: float
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class TaskCreate(BaseModel):
milestone_id: Optional[uuid.UUID] = None
title: str
description: Optional[str] = None
assignee_id: Optional[uuid.UUID] = None
priority: str = "MEDIUM"
due_date: Optional[date] = None
class TaskUpdate(BaseModel):
milestone_id: Optional[uuid.UUID] = None
title: Optional[str] = None
description: Optional[str] = None
assignee_id: Optional[uuid.UUID] = None
priority: Optional[str] = None
due_date: Optional[date] = None
status: Optional[str] = None
class TaskRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
milestone_id: Optional[uuid.UUID]
title: str
description: Optional[str]
assignee_id: Optional[uuid.UUID]
priority: str
due_date: Optional[date]
status: str
completed_at: Optional[datetime]
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.