立项配置数据入库、审计日志数据入库、编辑页UI界面美化、设备管理内容补充、审计日志显示优化

This commit is contained in:
Cheng Zhou
2026-02-27 16:16:26 +08:00
parent fd7e3fc948
commit db2d38edbc
48 changed files with 2936 additions and 909 deletions
+29 -2
View File
@@ -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}
+153
View File
@@ -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,
)
+53 -1
View File
@@ -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
+74
View File
@@ -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)
+3 -1
View File
@@ -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"])
+55
View File
@@ -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
+51
View File
@@ -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,
+71
View File
@@ -0,0 +1,71 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.material_equipment import MaterialEquipment
from app.schemas.material_equipment import MaterialEquipmentCreate, MaterialEquipmentUpdate
async def create_equipment(
db: AsyncSession,
study_id: uuid.UUID,
equipment_in: MaterialEquipmentCreate,
created_by: uuid.UUID | None,
) -> MaterialEquipment:
item = MaterialEquipment(
study_id=study_id,
name=equipment_in.name,
spec_model=equipment_in.spec_model,
unit=equipment_in.unit,
brand=equipment_in.brand,
origin=equipment_in.origin,
production_permit_file_name=equipment_in.production_permit_file_name,
tech_index_file_name=equipment_in.tech_index_file_name,
need_calibration=equipment_in.need_calibration,
calibration_cycle_days=equipment_in.calibration_cycle_days,
created_by=created_by,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
async def get_equipment(db: AsyncSession, equipment_id: uuid.UUID) -> MaterialEquipment | None:
result = await db.execute(select(MaterialEquipment).where(MaterialEquipment.id == equipment_id))
return result.scalar_one_or_none()
async def list_equipments(
db: AsyncSession,
study_id: uuid.UUID,
name: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[MaterialEquipment]:
stmt = select(MaterialEquipment).where(MaterialEquipment.study_id == study_id)
if name:
stmt = stmt.where(MaterialEquipment.name.ilike(f"%{name}%"))
stmt = stmt.order_by(MaterialEquipment.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_equipment(
db: AsyncSession,
equipment: MaterialEquipment,
equipment_in: MaterialEquipmentUpdate,
) -> MaterialEquipment:
update_data = equipment_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(equipment, key, value)
await db.commit()
await db.refresh(equipment)
return equipment
async def delete_equipment(db: AsyncSession, equipment: MaterialEquipment) -> None:
await db.delete(equipment)
await db.commit()
+54
View File
@@ -0,0 +1,54 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.milestone import Milestone
from app.schemas.project_milestone import ProjectMilestoneUpdate
PROJECT_MILESTONE_TYPE = "SETUP_PROJECT_MILESTONE"
async def list_project_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]:
stmt = (
select(Milestone)
.where(
Milestone.study_id == study_id,
Milestone.type == PROJECT_MILESTONE_TYPE,
)
.order_by(Milestone.planned_date.asc().nulls_last(), Milestone.created_at.asc())
)
result = await db.execute(stmt)
return result.scalars().all()
async def get_project_milestone(db: AsyncSession, study_id: uuid.UUID, milestone_id: uuid.UUID) -> Milestone | None:
result = await db.execute(
select(Milestone).where(
Milestone.id == milestone_id,
Milestone.study_id == study_id,
Milestone.type == PROJECT_MILESTONE_TYPE,
)
)
return result.scalar_one_or_none()
async def update_project_milestone(db: AsyncSession, milestone: Milestone, data: ProjectMilestoneUpdate) -> Milestone:
milestone.adjusted_start_date = data.adjusted_start_date
milestone.adjusted_end_date = data.adjusted_end_date
milestone.actual_start_date = data.actual_start_date
milestone.actual_end_date = data.actual_end_date
milestone.actual_date = data.actual_end_date
milestone.notes = data.notes
if milestone.actual_end_date:
milestone.status = "DONE"
elif milestone.actual_start_date:
milestone.status = "IN_PROGRESS"
else:
milestone.status = "NOT_STARTED"
await db.commit()
await db.refresh(milestone)
return milestone
+2
View File
@@ -112,6 +112,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.document import Document
from app.models.attachment import Attachment
from app.models.drug_shipment import DrugShipment
from app.models.material_equipment import MaterialEquipment
from app.models.training_authorization import TrainingAuthorization
from app.models.startup_feasibility import StartupFeasibility
from app.models.startup_ethics import StartupEthics
@@ -145,6 +146,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
# 6. 删除药物配送
await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id))
await db.execute(sa_delete(MaterialEquipment).where(MaterialEquipment.study_id == study_id))
# 7. 删除附件和文档
await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id))
+8 -1
View File
@@ -28,6 +28,7 @@ async def upsert(
expected_version: int | None,
data: StudySetupConfigData | dict,
saved_by: uuid.UUID | None,
force_draft: bool = False,
) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id)
payload = _to_storage(data)
@@ -37,7 +38,13 @@ async def upsert(
existing.version = existing.version + 1
existing.config = payload
existing.saved_by = saved_by
existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT"
if force_draft:
existing.publish_status = "DRAFT"
elif existing.publish_status == "DRAFT":
# Keep draft state sticky until explicit publish API is called.
existing.publish_status = "DRAFT"
else:
existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT"
existing.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
+1
View File
@@ -23,6 +23,7 @@ from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
from app.models.special_expense import SpecialExpense # noqa: F401
from app.models.fee_attachment import FeeAttachment # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.material_equipment import MaterialEquipment # noqa: F401
from app.models.startup_feasibility import StartupFeasibility # noqa: F401
from app.models.startup_ethics import StartupEthics # noqa: F401
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class MaterialEquipment(Base):
__tablename__ = "material_equipments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
spec_model: Mapped[str] = mapped_column(String(255), nullable=False)
unit: Mapped[str | None] = mapped_column(String(50), nullable=True)
brand: Mapped[str] = mapped_column(String(100), nullable=False)
origin: Mapped[str | None] = mapped_column(String(255), nullable=True)
production_permit_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
tech_index_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
need_calibration: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
calibration_cycle_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+4
View File
@@ -16,6 +16,10 @@ class Milestone(Base):
type: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
planned_date: Mapped[date | None] = mapped_column(Date, nullable=True)
adjusted_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
adjusted_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_start_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_end_date: Mapped[date | None] = mapped_column(Date, nullable=True)
actual_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED")
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
+1
View File
@@ -18,6 +18,7 @@ class StudySetupConfig(Base):
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False)
published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
+7
View File
@@ -14,3 +14,10 @@ class AuditLogRead(BaseModel):
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AuditEventCreate(BaseModel):
action: str
detail: Optional[str] = None
entity_type: Optional[str] = None
entity_id: Optional[uuid.UUID] = None
+81
View File
@@ -0,0 +1,81 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, field_validator
class MaterialEquipmentCreate(BaseModel):
name: str
spec_model: str
unit: str | None = None
brand: str
origin: str | None = None
production_permit_file_name: str | None = None
tech_index_file_name: str | None = None
need_calibration: bool
calibration_cycle_days: int | None = None
@field_validator("name", "spec_model", "brand", mode="before")
@classmethod
def _strip_required(cls, value: str) -> str:
text = (value or "").strip()
if not text:
raise ValueError("不能为空")
return text
@field_validator("unit", "origin", "production_permit_file_name", "tech_index_file_name", mode="before")
@classmethod
def _strip_optional(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
class MaterialEquipmentUpdate(BaseModel):
name: str | None = None
spec_model: str | None = None
unit: str | None = None
brand: str | None = None
origin: str | None = None
production_permit_file_name: str | None = None
tech_index_file_name: str | None = None
need_calibration: bool | None = None
calibration_cycle_days: int | None = None
@field_validator("name", "spec_model", "brand", mode="before")
@classmethod
def _strip_required(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
if not text:
raise ValueError("不能为空")
return text
@field_validator("unit", "origin", "production_permit_file_name", "tech_index_file_name", mode="before")
@classmethod
def _strip_optional(cls, value: str | None) -> str | None:
if value is None:
return None
text = value.strip()
return text or None
class MaterialEquipmentRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
name: str
spec_model: str
unit: str | None
brand: str
origin: str | None
production_permit_file_name: str | None
tech_index_file_name: str | None
need_calibration: bool
calibration_cycle_days: int | None
created_by: uuid.UUID | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict
class ProjectMilestoneRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
name: str
planned_date: date | None
adjusted_start_date: date | None
adjusted_end_date: date | None
actual_start_date: date | None
actual_end_date: date | None
status: str
notes: str | None
owner_name: str | None
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProjectMilestoneUpdate(BaseModel):
adjusted_start_date: date | None = None
adjusted_end_date: date | None = None
actual_start_date: date | None = None
actual_end_date: date | None = None
notes: str | None = None
+30
View File
@@ -80,6 +80,7 @@ class StudySetupConfigData(BaseModel):
class StudySetupConfigUpsert(BaseModel):
expected_version: int | None = None
data: StudySetupConfigData
force_draft: bool = False
class StudySetupConfigPublish(BaseModel):
@@ -120,6 +121,34 @@ class SetupProjectionSummary(BaseModel):
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
class ProjectPublishSnapshot(BaseModel):
code: str = ""
name: str = ""
project_full_name: str = ""
sponsor: str = ""
protocol_no: str = ""
lead_unit: str = ""
principal_investigator: str = ""
main_pm: str = ""
research_analysis: str = ""
research_product: str = ""
control_product: str = ""
indication: str = ""
research_population: str = ""
research_design: str = ""
plan_start_date: str = ""
plan_end_date: str = ""
planned_site_count: int | None = None
planned_enrollment_count: int | None = None
summary_note: str = ""
objective_note: str = ""
status: str = ""
visit_interval_days: int | None = None
visit_total: int | None = None
visit_window_start_offset: int | None = None
visit_window_end_offset: int | None = None
class StudySetupConfigRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
@@ -127,6 +156,7 @@ class StudySetupConfigRead(BaseModel):
data: StudySetupConfigData
publish_status: str
published_data: StudySetupConfigData | None = None
published_project_snapshot: ProjectPublishSnapshot | None = None
published_by: uuid.UUID | None = None
published_by_name: str | None = None
published_at: datetime | None = None
@@ -143,6 +143,11 @@ async def _replace_project_milestones(
type=PROJECT_MILESTONE_TYPE,
name=name,
planned_date=planned_date,
adjusted_start_date=_parse_date((getattr(row, "adjustedStartDate", "") or "").strip()),
adjusted_end_date=_parse_date((getattr(row, "adjustedEndDate", "") or "").strip()),
actual_start_date=_parse_date((getattr(row, "actualStartDate", "") or "").strip()),
actual_end_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()),
actual_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()),
status=mapped_status,
owner_id=owner_id,
owner_name=owner_name,