立项配置数据入库、审计日志数据入库、编辑页UI界面美化、设备管理内容补充、审计日志显示优化
This commit is contained in:
@@ -3,9 +3,9 @@ import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, require_roles, require_study_member
|
||||
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member
|
||||
from app.crud import audit as audit_crud
|
||||
from app.schemas.audit import AuditLogRead
|
||||
from app.schemas.audit import AuditEventCreate, AuditLogRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -52,3 +52,30 @@ async def delete_audit_log(
|
||||
if not log or log.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在")
|
||||
await audit_crud.delete_log(db, log)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/events",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def create_audit_event(
|
||||
study_id: uuid.UUID,
|
||||
payload: AuditEventCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
allowed_actions = {"AUDIT_EXPORT_SYSTEM", "AUDIT_EXPORT_PROJECT"}
|
||||
if payload.action not in allowed_actions:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的审计事件")
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=payload.entity_type or "audit_log",
|
||||
entity_id=payload.entity_id,
|
||||
action=payload.action,
|
||||
detail=payload.detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
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_not_locked, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import material_equipment as equipment_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.material_equipment import MaterialEquipmentCreate, MaterialEquipmentRead, MaterialEquipmentUpdate
|
||||
|
||||
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="项目不存在")
|
||||
return study
|
||||
|
||||
|
||||
def _validate_calibration(need_calibration: bool, calibration_cycle_days: int | None):
|
||||
if need_calibration and (calibration_cycle_days is None or calibration_cycle_days < 1):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/equipment",
|
||||
response_model=MaterialEquipmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def create_equipment(
|
||||
study_id: uuid.UUID,
|
||||
equipment_in: MaterialEquipmentCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> MaterialEquipmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
_validate_calibration(equipment_in.need_calibration, equipment_in.calibration_cycle_days)
|
||||
if not equipment_in.need_calibration:
|
||||
equipment_in = equipment_in.model_copy(update={"calibration_cycle_days": None})
|
||||
item = await equipment_crud.create_equipment(db, study_id, equipment_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="material_equipment",
|
||||
entity_id=item.id,
|
||||
action="CREATE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {item.id} 已创建",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return MaterialEquipmentRead.model_validate(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/equipment",
|
||||
response_model=list[MaterialEquipmentRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_equipments(
|
||||
study_id: uuid.UUID,
|
||||
name: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[MaterialEquipmentRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await equipment_crud.list_equipments(db, study_id, name=name, skip=skip, limit=limit)
|
||||
return [MaterialEquipmentRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/equipment/{equipment_id}",
|
||||
response_model=MaterialEquipmentRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_equipment(
|
||||
study_id: uuid.UUID,
|
||||
equipment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> MaterialEquipmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||
return MaterialEquipmentRead.model_validate(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/equipment/{equipment_id}",
|
||||
response_model=MaterialEquipmentRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def update_equipment(
|
||||
study_id: uuid.UUID,
|
||||
equipment_id: uuid.UUID,
|
||||
equipment_in: MaterialEquipmentUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> MaterialEquipmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||
|
||||
next_need_calibration = equipment_in.need_calibration if equipment_in.need_calibration is not None else item.need_calibration
|
||||
next_cycle = equipment_in.calibration_cycle_days if equipment_in.calibration_cycle_days is not None else item.calibration_cycle_days
|
||||
_validate_calibration(next_need_calibration, next_cycle)
|
||||
if not next_need_calibration:
|
||||
equipment_in = equipment_in.model_copy(update={"calibration_cycle_days": None})
|
||||
|
||||
item = await equipment_crud.update_equipment(db, item, equipment_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="material_equipment",
|
||||
entity_id=equipment_id,
|
||||
action="UPDATE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {equipment_id} 已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return MaterialEquipmentRead.model_validate(item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/equipment/{equipment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def delete_equipment(
|
||||
study_id: uuid.UUID,
|
||||
equipment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await equipment_crud.get_equipment(db, equipment_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在")
|
||||
await equipment_crud.delete_equipment(db, item)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="material_equipment",
|
||||
entity_id=equipment_id,
|
||||
action="DELETE_MATERIAL_EQUIPMENT",
|
||||
detail=f"设备 {equipment_id} 已删除",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,9 +1,11 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import user as user_crud
|
||||
@@ -30,6 +32,7 @@ async def add_member(
|
||||
study_id: uuid.UUID,
|
||||
member_in: StudyMemberCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudyMemberRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
existing = await member_crud.get_member(db, study_id, member_in.user_id)
|
||||
@@ -40,9 +43,33 @@ async def add_member(
|
||||
existing,
|
||||
StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study),
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_member",
|
||||
entity_id=updated.id,
|
||||
action="PROJECT_MEMBER_UPDATED",
|
||||
detail=json.dumps({
|
||||
"targetName": str(updated.user_id),
|
||||
"before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study},
|
||||
"after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study},
|
||||
}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return updated
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在")
|
||||
member = await member_crud.add_member(db, study_id, member_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_member",
|
||||
entity_id=member.id,
|
||||
action="PROJECT_MEMBER_ADDED",
|
||||
detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return member
|
||||
|
||||
|
||||
@@ -95,12 +122,25 @@ async def update_member(
|
||||
member_id: uuid.UUID,
|
||||
member_in: StudyMemberUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudyMemberRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
member = await member_crud.get_member_by_id(db, member_id)
|
||||
if not member or member.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
|
||||
updated = await member_crud.update_member(db, member, member_in)
|
||||
after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active}
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_member",
|
||||
entity_id=updated.id,
|
||||
action="PROJECT_MEMBER_UPDATED",
|
||||
detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@@ -113,10 +153,22 @@ async def remove_member(
|
||||
study_id: uuid.UUID,
|
||||
member_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StudyMemberRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
member = await member_crud.get_member_by_id(db, member_id)
|
||||
if not member or member.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在")
|
||||
before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active}
|
||||
removed = await member_crud.remove_member(db, member)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="study_member",
|
||||
entity_id=removed.id,
|
||||
action="PROJECT_MEMBER_REMOVED",
|
||||
detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return removed
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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_not_locked, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import project_milestone as milestone_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.project_milestone import ProjectMilestoneRead, ProjectMilestoneUpdate
|
||||
|
||||
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="项目不存在")
|
||||
return study
|
||||
|
||||
|
||||
def _validate_date_range(start, end, label: str):
|
||||
if (start and not end) or (not start and end):
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"{label}开始和结束日期需同时填写")
|
||||
if start and end and end < start:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"{label}结束日期不能早于开始日期")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/milestones",
|
||||
response_model=list[ProjectMilestoneRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_project_milestones(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[ProjectMilestoneRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
rows = await milestone_crud.list_project_milestones(db, study_id)
|
||||
return [ProjectMilestoneRead.model_validate(item) for item in rows]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/milestones/{milestone_id}",
|
||||
response_model=ProjectMilestoneRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())],
|
||||
)
|
||||
async def update_project_milestone(
|
||||
study_id: uuid.UUID,
|
||||
milestone_id: uuid.UUID,
|
||||
payload: ProjectMilestoneUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> ProjectMilestoneRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
row = await milestone_crud.get_project_milestone(db, study_id, milestone_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目里程碑不存在")
|
||||
|
||||
_validate_date_range(payload.adjusted_start_date, payload.adjusted_end_date, "调整计划时间")
|
||||
_validate_date_range(payload.actual_start_date, payload.actual_end_date, "实际时间")
|
||||
|
||||
item = await milestone_crud.update_project_milestone(db, row, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="milestone",
|
||||
entity_id=milestone_id,
|
||||
action="UPDATE_PROJECT_MILESTONE",
|
||||
detail=f"项目里程碑 {milestone_id} 已更新",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return ProjectMilestoneRead.model_validate(item)
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications
|
||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -26,6 +26,8 @@ api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-con
|
||||
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
|
||||
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
|
||||
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
||||
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
|
||||
api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"])
|
||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
|
||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.crud import startup as startup_crud
|
||||
from app.crud import study as study_crud
|
||||
@@ -46,6 +48,16 @@ async def create_site(
|
||||
StartupEthicsCreate(site_id=site.id),
|
||||
created_by=getattr(current_user, "id", None),
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="site",
|
||||
entity_id=site.id,
|
||||
action="SITE_CREATED",
|
||||
detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return site
|
||||
|
||||
|
||||
@@ -86,12 +98,38 @@ async def update_site(
|
||||
site_id: uuid.UUID,
|
||||
site_in: SiteUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> SiteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
site = await site_crud.get_site(db, site_id)
|
||||
if not site or site.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
||||
before_data = {
|
||||
"name": site.name,
|
||||
"city": site.city,
|
||||
"pi_name": site.pi_name,
|
||||
"contact": site.contact,
|
||||
"is_active": site.is_active,
|
||||
}
|
||||
updated = await site_crud.update_site(db, site, site_in)
|
||||
after_data = {
|
||||
"name": updated.name,
|
||||
"city": updated.city,
|
||||
"pi_name": updated.pi_name,
|
||||
"contact": updated.contact,
|
||||
"is_active": updated.is_active,
|
||||
}
|
||||
action = "SITE_STATUS_CHANGED" if before_data.get("is_active") != after_data.get("is_active") else "SITE_UPDATED"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="site",
|
||||
entity_id=updated.id,
|
||||
action=action,
|
||||
detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@@ -113,5 +151,22 @@ async def delete_site(
|
||||
site = await site_crud.get_site(db, site_id)
|
||||
if not site or site.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在")
|
||||
before_data = {
|
||||
"name": site.name,
|
||||
"city": site.city,
|
||||
"pi_name": site.pi_name,
|
||||
"contact": site.contact,
|
||||
"is_active": site.is_active,
|
||||
}
|
||||
await site_crud.delete_site_and_related(db, site)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="site",
|
||||
entity_id=site_id,
|
||||
action="SITE_DELETED",
|
||||
detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False),
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -25,6 +25,7 @@ from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
|
||||
from app.schemas.member import StudyMemberCreate
|
||||
from app.schemas.study_setup_config import (
|
||||
ProjectPublishSnapshot,
|
||||
SetupProjectionSummary,
|
||||
StudySetupConfigData,
|
||||
StudySetupConfigPublish,
|
||||
@@ -253,6 +254,42 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf
|
||||
)
|
||||
|
||||
|
||||
def _to_date_text(value: date | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
return value.isoformat()
|
||||
|
||||
|
||||
def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot:
|
||||
return ProjectPublishSnapshot(
|
||||
code=getattr(study, "code", None) or "",
|
||||
name=getattr(study, "name", None) or "",
|
||||
project_full_name=getattr(study, "project_full_name", None) or "",
|
||||
sponsor=getattr(study, "sponsor", None) or "",
|
||||
protocol_no=getattr(study, "protocol_no", None) or "",
|
||||
lead_unit=getattr(study, "lead_unit", None) or "",
|
||||
principal_investigator=getattr(study, "principal_investigator", None) or "",
|
||||
main_pm=getattr(study, "main_pm", None) or "",
|
||||
research_analysis=getattr(study, "research_analysis", None) or "",
|
||||
research_product=getattr(study, "research_product", None) or "",
|
||||
control_product=getattr(study, "control_product", None) or "",
|
||||
indication=getattr(study, "indication", None) or "",
|
||||
research_population=getattr(study, "research_population", None) or "",
|
||||
research_design=getattr(study, "research_design", None) or "",
|
||||
plan_start_date=_to_date_text(getattr(study, "plan_start_date", None)),
|
||||
plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)),
|
||||
planned_site_count=getattr(study, "planned_site_count", None),
|
||||
planned_enrollment_count=getattr(study, "planned_enrollment_count", None),
|
||||
summary_note=getattr(study, "summary_note", None) or "",
|
||||
objective_note=getattr(study, "objective_note", None) or "",
|
||||
status=getattr(study, "status", None) or "",
|
||||
visit_interval_days=getattr(study, "visit_interval_days", None),
|
||||
visit_total=getattr(study, "visit_total", None),
|
||||
visit_window_start_offset=getattr(study, "visit_window_start_offset", None),
|
||||
visit_window_end_offset=getattr(study, "visit_window_end_offset", None),
|
||||
)
|
||||
|
||||
|
||||
def _to_setup_config_read(
|
||||
record,
|
||||
*,
|
||||
@@ -268,6 +305,11 @@ def _to_setup_config_read(
|
||||
data=StudySetupConfigData.model_validate(record.config or {}),
|
||||
publish_status=record.publish_status or "DRAFT",
|
||||
published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None,
|
||||
published_project_snapshot=(
|
||||
ProjectPublishSnapshot.model_validate(record.published_project_snapshot)
|
||||
if record.published_project_snapshot
|
||||
else None
|
||||
),
|
||||
published_by=record.published_by,
|
||||
published_by_name=published_by_name,
|
||||
published_at=record.published_at,
|
||||
@@ -764,12 +806,18 @@ async def upsert_study_setup_config(
|
||||
|
||||
existing = await study_setup_config_crud.get_by_study(db, study_id)
|
||||
old_config = dict(existing.config or {}) if existing else {}
|
||||
force_draft = bool(payload.force_draft)
|
||||
if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot:
|
||||
current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json")
|
||||
if current_project_snapshot != dict(existing.published_project_snapshot or {}):
|
||||
force_draft = True
|
||||
record, conflict = await study_setup_config_crud.upsert(
|
||||
db,
|
||||
study_id,
|
||||
expected_version=payload.expected_version,
|
||||
data=payload.data,
|
||||
saved_by=current_user.id,
|
||||
force_draft=force_draft,
|
||||
)
|
||||
if conflict:
|
||||
_raise_conflict_error()
|
||||
@@ -857,6 +905,9 @@ async def publish_study_setup_config(
|
||||
for item in projection.skipped_items
|
||||
],
|
||||
)
|
||||
study_after_publish = await study_crud.get(db, study_id)
|
||||
if study_after_publish:
|
||||
published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json")
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
|
||||
Reference in New Issue
Block a user