"""项目管理;所有管理路由显式校验精确菜单权限。""" from typing import Optional, List, Literal from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from app.core.auth import get_current_user from app.core.database import get_db from app.core.menu_guard import require_admin_or_super_admin from app.core.response import success_response from app.services import project_management as svc from app.services import project_assignment_records router = APIRouter(tags=['项目管理']) PREFIX = '/web/admin/project-management' class Body(BaseModel): model_config = ConfigDict(extra='forbid') class Member(Body): id: Optional[str] = None userId: str class Region(Body): id: Optional[str] = None regionId: str class Hospital(Body): id: Optional[str] = None hospitalId: str class TeamBody(Body): teamName: str = Field(min_length=1, max_length=200) leaderUserId: str enabled: bool = True members: List[Member] = Field(default_factory=list) regions: List[Region] = Field(default_factory=list) hospitals: List[Hospital] = Field(default_factory=list) remark: Optional[str] = Field(None, max_length=512) class ProjectBody(Body): projectName: str = Field(min_length=1, max_length=200) teamId: str leaderUserId: str bizTypes: List[str] startDate: Optional[str] = None endDate: Optional[str] = None status: Literal['pending', 'active', 'completed', 'disabled'] = 'pending' remark: Optional[str] = Field(None, max_length=512) class PreviewBody(Body): bizType: Literal['presign', 'cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'] id: str projectId: str class Snapshot(BaseModel): bizType: str id: str projectId: Optional[str] = None class AssignmentBody(PreviewBody): reason: str = Field(min_length=1, max_length=512) expectedAssignments: List[Snapshot] def identity(user=Depends(get_current_user)): tenant = user.get('tenant_no') if not tenant or not user.get('user_id'): raise HTTPException(403, '缺少认证租户') return tenant, user def guard(*codes): def dependency(ctx=Depends(identity), db: Session=Depends(get_db)): svc.check_permission(db, ctx[0], ctx[1], *codes) return ctx return dependency @router.get('/project-management/options') def options(bizType: str, usage: Literal['assignment', 'filter']='assignment', keyword: Optional[str]=None, currentProjectId: Optional[str]=None, ctx=Depends(identity), db: Session=Depends(get_db)): # Arbitrary currentProjectId cannot authorize reading historical project metadata. if usage == 'filter': require_admin_or_super_admin(ctx[1]) method = svc.filter_options if usage == 'filter' else svc.options return success_response(method(db, *ctx, bizType, keyword)) @router.get(PREFIX + '/references') def references(ctx=Depends(guard('project:team:list', 'project:team:edit', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)): return success_response(svc.references(db, ctx[0])) @router.get(PREFIX + '/teams') def teams(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, leaderUserId: Optional[str]=None, enabled: Optional[bool]=None, ctx=Depends(guard('project:team:list', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)): return success_response(svc.list_entities(db, ctx[0], svc.ProjectTeam, page, pageSize, keyword=keyword, leaderUserId=leaderUserId, enabled=enabled)) @router.get(PREFIX + '/projects') def projects(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, teamId: Optional[str]=None, status: Optional[str]=None, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)): return success_response(svc.list_entities(db, ctx[0], svc.Project, page, pageSize, keyword=keyword, teamId=teamId, status=status)) @router.post(PREFIX + '/teams') def create_team(body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)): return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump())) @router.post(PREFIX + '/projects') def create_project(body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)): return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump())) @router.get(PREFIX + '/teams/{row_id}') def get_team(row_id: str, ctx=Depends(guard('project:team:list')), db: Session=Depends(get_db)): return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.ProjectTeam, ctx[0], row_id))) @router.get(PREFIX + '/projects/{row_id}') def get_project(row_id: str, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)): return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.Project, ctx[0], row_id))) @router.put(PREFIX + '/teams/{row_id}') def update_team(row_id: str, body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)): return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump(), row_id)) @router.put(PREFIX + '/projects/{row_id}') def update_project(row_id: str, body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)): return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump(), row_id)) @router.delete(PREFIX + '/teams/{row_id}') def delete_team(row_id: str, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)): return success_response(svc.delete_entity(db, *ctx, svc.ProjectTeam, row_id)) @router.delete(PREFIX + '/projects/{row_id}') def delete_project(row_id: str, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)): return success_response(svc.delete_entity(db, *ctx, svc.Project, row_id)) @router.get(PREFIX + '/statistics') def statistics(groupBy: str='project', teamId: Optional[str]=None, projectId: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db)): return success_response(svc.statistics(db, *ctx, groupBy=groupBy, teamId=teamId, projectId=projectId, startDate=startDate, endDate=endDate)) @router.get(PREFIX + '/records') def records(bizType: str, page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), teamId: Optional[str]=None, projectId: Optional[str]=None, unassigned: bool=False, status: Optional[str]=None, keyword: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list', 'project:assignment:edit')), db: Session=Depends(get_db)): return success_response(svc.records(db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate)) @router.get(PREFIX + '/statistics/records') def statistics_records( bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'], page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200), teamId: Optional[str] = None, projectId: Optional[str] = None, unassigned: bool = False, status: Optional[str] = None, keyword: Optional[str] = None, startDate: Optional[str] = None, endDate: Optional[str] = None, ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db), ): return success_response(project_assignment_records.statistics_records( db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate, )) @router.post(PREFIX + '/assignments/preview') def preview(body: PreviewBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)): return success_response(svc.preview_assignment(db, *ctx, body.bizType, body.id, body.projectId)) @router.get(PREFIX + '/assignments/records') def assignment_records( bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'], page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200), teamId: Optional[str] = None, projectId: Optional[str] = None, unassigned: bool = False, status: Optional[str] = None, keyword: Optional[str] = None, startDate: Optional[str] = None, endDate: Optional[str] = None, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db), ): return success_response(project_assignment_records.records( db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate, )) @router.post(PREFIX + '/assignments') def assign(body: AssignmentBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)): return success_response(svc.assign(db, *ctx, body.bizType, body.id, body.projectId, body.reason, [r.model_dump() for r in body.expectedAssignments])) @router.get(PREFIX + '/assignments/history') def history(bizType: str, id: str, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)): return success_response(svc.assignment_history(db, *ctx, bizType, id))