Reuse assignment business columns and keyword searches in the four statistics drill-down lists. Add a statistics-permission read endpoint sharing tenant-scoped detail queries without granting assignment access. Related to issue #13. Independent static review completed. User self-test arrangement: regression cases authored, no tests or builds executed.
205 lines
9.3 KiB
Python
205 lines
9.3 KiB
Python
"""项目管理;所有管理路由显式校验精确菜单权限。"""
|
|
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))
|