Add tenant-scoped teams and projects, order attribution across four businesses, conversion inheritance, scoped statistics and audited reassignment. Reuse existing Web and mobile UI patterns; team regions and hospitals remain informational. Include incremental SQL and isolated regression coverage. Actual MySQL and browser/WebView integration validation remains pending; relates to issue #13.
174 lines
7.6 KiB
Python
174 lines
7.6 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
|
|
|
|
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.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.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))
|