from __future__ import annotations from datetime import date as Date from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ...core.database import get_db from ...core.response import error_response, success_response from ...dependencies import get_current_user from ...models import Project, User, UserProject from ...schemas.pmflow_os import ( PMFlowCapacityOverridePayload, PMFlowChangeItemDeferRequest, PMFlowChangeItemEditRequest, PMFlowChangeSetCommitRequest, PMFlowExecutionHandoffConsumeRequest, PMFlowExecutionHandoffRequest, PMFlowExecutionProgressEventRequest, PMFlowExtractionCreateRequest, PMFlowIterationRecommendationChangeSetDraftRequest, PMFlowIterationRecommendationGroupChangeSetDraftRequest, PMFlowReleaseFeedbackIngestRequest, PMFlowReleaseValidationRunCreateRequest, PMFlowReviewDecisionRequest, PMFlowThreadMessageCreateRequest, ) from ...services.auth import has_any_role from ...services.adaptive_capacity_service import AdaptiveCapacityService from ...services.pmflow_change_review_service import PMFlowChangeReviewError, PMFlowChangeReviewService from ...services.pmflow_os_projection_service import PMFlowOSProjectionService from ...services.team_execution_capability_service import TeamExecutionCapabilityService router = APIRouter(prefix="/pmflow-os", tags=["pmflow-os"]) def _review_error(exc: PMFlowChangeReviewError) -> JSONResponse: return JSONResponse(status_code=exc.status_code, content=error_response(exc.status_code, f"{exc.code}: {exc.message}")) async def _ensure_project_member_or_manager( db: AsyncSession, *, tenant_id: int, project_id: int, current_user: User, ) -> None: project_result = await db.execute( select(Project.id).where(and_(Project.id == int(project_id), Project.tenant_id == int(tenant_id))) ) if project_result.scalar_one_or_none() is None: raise HTTPException(status_code=404, detail="project_not_found") if has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]): return member_result = await db.execute( select(UserProject.user_id).where( and_( UserProject.tenant_id == int(tenant_id), UserProject.project_id == int(project_id), UserProject.user_id == int(current_user.id), ) ) ) if member_result.scalar_one_or_none() is None: raise HTTPException(status_code=403, detail="forbidden") def _ensure_capacity_override_manager(current_user: User) -> None: if not has_any_role(str(getattr(current_user, "role", "") or ""), ["OWNER", "REVIEWER"]): raise HTTPException(status_code=403, detail="forbidden") def _parse_capacity_date(value: str | None) -> Date | None: if not value: return None try: return Date.fromisoformat(str(value)[:10]) except ValueError as exc: raise HTTPException(status_code=422, detail="invalid_date") from exc @router.get("/projects/{project_id}/truth-state") async def get_pmflow_os_truth_state( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.get_truth_state( project_id=int(project_id), current_user_id=int(current_user.id), ) except ValueError as exc: if str(exc) == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.post("/projects/{project_id}/threads/{thread_id}/messages") async def create_pmflow_os_thread_message( project_id: int, thread_id: str, body: PMFlowThreadMessageCreateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) try: payload = await service.create_thread_message( project_id=int(project_id), thread_id=thread_id, body=body, user_id=int(current_user.id), ) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.post("/projects/{project_id}/extractions") async def create_pmflow_os_extraction( project_id: int, body: PMFlowExtractionCreateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) try: payload = await service.create_extraction(project_id=int(project_id), body=body, user_id=int(current_user.id)) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.get("/projects/{project_id}/change-sets/{change_set_id}") async def get_pmflow_os_change_set( project_id: int, change_set_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) try: payload = await service.get_change_set_detail(project_id=int(project_id), change_set_id=int(change_set_id)) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.post("/change-items/{change_item_id}/accept") async def accept_pmflow_os_change_item( change_item_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) project_id = await service.get_item_project_id(change_item_id=int(change_item_id)) if project_id is None: return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found")) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user) try: payload = await service.accept_item(change_item_id=int(change_item_id), user_id=int(current_user.id)) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.post("/change-items/{change_item_id}/edit") async def edit_pmflow_os_change_item( change_item_id: int, body: PMFlowChangeItemEditRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) project_id = await service.get_item_project_id(change_item_id=int(change_item_id)) if project_id is None: return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found")) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user) try: payload = await service.edit_item(change_item_id=int(change_item_id), body=body, user_id=int(current_user.id)) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.post("/change-items/{change_item_id}/defer") async def defer_pmflow_os_change_item( change_item_id: int, body: PMFlowChangeItemDeferRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) project_id = await service.get_item_project_id(change_item_id=int(change_item_id)) if project_id is None: return JSONResponse(status_code=404, content=error_response(404, "change_item_not_found")) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user) try: payload = await service.defer_item( change_item_id=int(change_item_id), user_id=int(current_user.id), reason=body.reason if body is not None else None, ) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.post("/change-sets/{change_set_id}/commit") async def commit_pmflow_os_change_set( change_set_id: int, body: PMFlowChangeSetCommitRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) service = PMFlowChangeReviewService(db, tenant_id=tenant_id) project_id = await service.get_change_set_project_id(change_set_id=int(change_set_id)) if project_id is None: return JSONResponse(status_code=404, content=error_response(404, "changeset_not_found")) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user) try: payload = await service.commit_change_set( change_set_id=int(change_set_id), body=body or PMFlowChangeSetCommitRequest(), user_id=int(current_user.id), ) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response(payload) @router.post("/change-sets/{change_set_id}/review-decisions") async def create_pmflow_os_review_decision( change_set_id: int, body: PMFlowReviewDecisionRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) review_service = PMFlowChangeReviewService(db, tenant_id=tenant_id) project_id = await review_service.get_change_set_project_id(change_set_id=int(change_set_id)) if project_id is None: return JSONResponse(status_code=404, content=error_response(404, "changeset_not_found")) await _ensure_project_member_or_manager(db, tenant_id=tenant_id, project_id=project_id, current_user=current_user) try: review_payload = await review_service.record_review_decision( change_set_id=int(change_set_id), body=body, user_id=int(current_user.id), ) truth_state = await PMFlowOSProjectionService(db, tenant_id=tenant_id).get_truth_state( project_id=int(project_id), current_user_id=int(current_user.id), ) except PMFlowChangeReviewError as exc: return _review_error(exc) return success_response( { "changeSetDetail": review_payload["changeSetDetail"], "truthState": truth_state.model_dump(mode="json"), "warnings": review_payload.get("warnings", []), } ) @router.get("/projects/{project_id}/execution-summary") async def get_pmflow_os_execution_summary( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.get_execution_summary( project_id=int(project_id), current_user_id=int(current_user.id), ) except ValueError as exc: if str(exc) == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.get("/projects/{project_id}/execution-capabilities") async def get_pmflow_os_execution_capabilities( project_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = TeamExecutionCapabilityService(db, tenant_id=tenant_id) try: payload = await service.get_snapshot( project_id=int(project_id), current_user_id=int(current_user.id), ) except ValueError as exc: if str(exc) == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.get("/projects/{project_id}/capacity-plan") async def get_pmflow_os_capacity_plan( project_id: int, plan_date: str | None = Query(default=None, alias="date"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = AdaptiveCapacityService(db, tenant_id=tenant_id) try: payload = await service.get_plan( project_id=int(project_id), current_user_id=int(current_user.id), plan_date=_parse_capacity_date(plan_date), ) except ValueError as exc: if str(exc) == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.post("/projects/{project_id}/capacity-overrides/simulate") async def simulate_pmflow_os_capacity_override( project_id: int, body: PMFlowCapacityOverridePayload, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) _ensure_capacity_override_manager(current_user) service = AdaptiveCapacityService(db, tenant_id=tenant_id) try: payload = await service.simulate_override( project_id=int(project_id), current_user_id=int(current_user.id), payload=body, ) except ValueError as exc: if str(exc) == "capacity_member_not_found": raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.post("/projects/{project_id}/capacity-overrides") async def create_pmflow_os_capacity_override( project_id: int, body: PMFlowCapacityOverridePayload, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) _ensure_capacity_override_manager(current_user) service = AdaptiveCapacityService(db, tenant_id=tenant_id) try: payload = await service.apply_override( project_id=int(project_id), current_user_id=int(current_user.id), payload=body, ) except ValueError as exc: if str(exc) == "capacity_member_not_found": raise HTTPException(status_code=404, detail="capacity_member_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.get("/projects/{project_id}/capacity-overrides") async def list_pmflow_os_capacity_overrides( project_id: int, plan_date: str | None = Query(default=None, alias="date"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = AdaptiveCapacityService(db, tenant_id=tenant_id) payload = await service.list_overrides(project_id=int(project_id), plan_date=_parse_capacity_date(plan_date)) return success_response([item.model_dump(mode="json") for item in payload]) @router.post("/projects/{project_id}/execution-handoffs") async def create_pmflow_os_execution_handoff( project_id: int, body: PMFlowExecutionHandoffRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.create_execution_handoff( project_id=int(project_id), body=body, current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code == "task_not_found": return JSONResponse(status_code=404, content=error_response(404, "task_not_found")) if code == "repo_binding_not_found": return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.post("/projects/{project_id}/execution-progress-events") async def create_pmflow_os_execution_progress_event( project_id: int, body: PMFlowExecutionProgressEventRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.record_execution_progress_event( project_id=int(project_id), body=body, current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code == "task_not_found": return JSONResponse(status_code=404, content=error_response(404, "task_not_found")) if code == "repo_binding_not_found": return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json")) @router.post("/projects/{project_id}/release-validation-runs") async def create_pmflow_os_release_validation_run( project_id: int, body: PMFlowReleaseValidationRunCreateRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.create_release_validation_run( project_id=int(project_id), body=body, current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code == "release_validation_evidence_empty": return JSONResponse(status_code=409, content=error_response(409, "release_validation_evidence_empty")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload) @router.post("/projects/{project_id}/release-feedback-ingests") async def create_pmflow_os_release_feedback_ingest( project_id: int, body: PMFlowReleaseFeedbackIngestRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.create_release_feedback_ingest( project_id=int(project_id), body=body, current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code in {"feedback_content_empty", "反馈内容不能为空"}: return JSONResponse(status_code=400, content=error_response(400, "feedback_content_empty")) if code == "charter_not_found": return JSONResponse(status_code=404, content=error_response(404, "charter_not_found")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload) @router.post("/projects/{project_id}/iteration-recommendations/{recommendation_id}/change-set-drafts") async def create_pmflow_os_iteration_recommendation_change_set_draft( project_id: int, recommendation_id: int, body: PMFlowIterationRecommendationChangeSetDraftRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.create_iteration_recommendation_change_set_draft( project_id=int(project_id), recommendation_id=int(recommendation_id), body=body or PMFlowIterationRecommendationChangeSetDraftRequest(), current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code == "iteration_recommendation_not_found": return JSONResponse(status_code=404, content=error_response(404, "iteration_recommendation_not_found")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload) @router.post("/projects/{project_id}/iteration-recommendation-groups/{group_key}/change-set-drafts") async def create_pmflow_os_iteration_recommendation_group_change_set_draft( project_id: int, group_key: str, body: PMFlowIterationRecommendationGroupChangeSetDraftRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.create_iteration_recommendation_group_change_set_draft( project_id=int(project_id), group_key=group_key, body=body or PMFlowIterationRecommendationGroupChangeSetDraftRequest(), current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code == "iteration_recommendation_group_not_found": return JSONResponse(status_code=404, content=error_response(404, "iteration_recommendation_group_not_found")) if code == "iteration_recommendation_not_found": return JSONResponse(status_code=404, content=error_response(404, "iteration_recommendation_not_found")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload) @router.post("/projects/{project_id}/execution-handoffs/{prompt_package_id}/consume") async def consume_pmflow_os_execution_handoff( project_id: int, prompt_package_id: int, body: PMFlowExecutionHandoffConsumeRequest | None = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): tenant_id = int(current_user.tenant_id) await _ensure_project_member_or_manager( db, tenant_id=tenant_id, project_id=int(project_id), current_user=current_user, ) service = PMFlowOSProjectionService(db, tenant_id=tenant_id) try: payload = await service.consume_execution_handoff( project_id=int(project_id), prompt_package_id=int(prompt_package_id), body=body or PMFlowExecutionHandoffConsumeRequest(), current_user_id=int(current_user.id), ) except ValueError as exc: code = str(exc) if code in {"prompt_package_not_found", "execution_handoff_not_found"}: return JSONResponse(status_code=404, content=error_response(404, code)) if code == "task_not_found": return JSONResponse(status_code=404, content=error_response(404, "task_not_found")) if code == "repo_binding_not_found": return JSONResponse(status_code=422, content=error_response(422, "repo_binding_not_found")) if code == "project_not_found": raise HTTPException(status_code=404, detail="project_not_found") from exc raise return success_response(payload.model_dump(mode="json"))