立项配置页初步优化

This commit is contained in:
Cheng Zhou
2026-02-24 16:53:22 +08:00
parent 0693f09b1d
commit 8f3f717e48
124 changed files with 12519 additions and 2954 deletions
+11 -3
View File
@@ -64,12 +64,14 @@ async def create_ae(
term=ae_in.term,
onset_date=ae_in.onset_date,
resolution_date=None,
seriousness=str(ae_in.seriousness),
severity=str(ae_in.seriousness),
seriousness=ae_in.seriousness.value,
severity=ae_in.seriousness.value,
causality=ae_in.causality,
action_taken=None,
outcome=ae_in.outcome,
reported_to_sponsor=False,
is_sae=bool(ae_in.is_sae or ae_in.is_susar),
is_susar=bool(ae_in.is_susar),
report_due_date=due_date,
status="NEW",
description=ae_in.description,
@@ -125,8 +127,14 @@ async def list_ae(
async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent:
update_data = ae_in.model_dump(exclude_unset=True)
if "is_susar" in update_data and update_data["is_susar"]:
update_data["is_sae"] = True
if "is_sae" in update_data and update_data["is_sae"] is False:
update_data["is_susar"] = False
if "seriousness" in update_data:
update_data["seriousness"] = str(update_data["seriousness"])
severity_value = update_data["seriousness"].value if hasattr(update_data["seriousness"], "value") else str(update_data["seriousness"])
update_data["seriousness"] = severity_value
update_data["severity"] = severity_value
if "subject_id" in update_data:
await _validate_site_subject(db, study_id, None, update_data["subject_id"])
if "onset_date" in update_data or "seriousness" in update_data:
+6 -2
View File
@@ -17,6 +17,7 @@ async def log_action(
detail: str | None,
operator_id: uuid.UUID,
operator_role: str,
auto_commit: bool = True,
) -> AuditLog:
log = AuditLog(
study_id=study_id,
@@ -28,8 +29,11 @@ async def log_action(
operator_role=operator_role,
)
db.add(log)
await db.commit()
await db.refresh(log)
if auto_commit:
await db.commit()
await db.refresh(log)
else:
await db.flush()
return log
+5
View File
@@ -25,6 +25,7 @@ from app.models.site import Site
from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics
from app.models.startup_feasibility import StartupFeasibility
from app.models.study_center_confirm import StudyCenterConfirm
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.models.training_authorization import TrainingAuthorization
@@ -44,6 +45,9 @@ async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate
pi_name=site_in.pi_name,
contact=site_in.contact,
is_active=site_in.is_active,
enrollment_plan_start_date=site_in.enrollment_plan_start_date,
enrollment_plan_end_date=site_in.enrollment_plan_end_date,
enrollment_plan_note=site_in.enrollment_plan_note,
)
db.add(site)
await db.commit()
@@ -314,6 +318,7 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
await db.execute(delete(AdverseEvent).where(AdverseEvent.site_id == site_id))
await db.execute(delete(FinanceItem).where(FinanceItem.site_id == site_id))
await db.execute(delete(Subject).where(Subject.site_id == site_id))
await db.execute(delete(StudyCenterConfirm).where(StudyCenterConfirm.site_id == site_id))
await db.execute(delete(Milestone).where(Milestone.site_id == site_id))
await db.execute(delete(KickoffMeeting).where(KickoffMeeting.site_id == site_id))
+37 -19
View File
@@ -9,23 +9,29 @@ from app.schemas.study import StudyCreate, StudyUpdate
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
import uuid as uuid_lib # Import uuid to generate unique code if needed
code = study_in.code
if not code:
# Generate a unique code if not provided
# Use first 8 chars of a new UUID, uppercase
code = f"P-{str(uuid_lib.uuid4())[:8].upper()}"
study = Study(
code=code,
code=study_in.code.strip(),
name=study_in.name,
# sponsor is removed from schema, but model has it. Set to None/empty or keep existing value if schema had it (which it doesn't now)
# If we removed it from schema, study_in.sponsor will fail if we try to access it via .sponsor attribute directly if it's not in the pydantic model anymore.
# Since we removed `sponsor` field from StudyCreate, `study_in` object won't have `sponsor` attribute unless we access dict or use default.
# However, the Study MODEL still has sponsor. We should set it to None.
sponsor=None,
project_full_name=study_in.project_full_name,
sponsor=study_in.sponsor,
protocol_no=study_in.protocol_no,
lead_unit=study_in.lead_unit,
principal_investigator=study_in.principal_investigator,
main_pm=study_in.main_pm,
research_analysis=study_in.research_analysis,
research_product=study_in.research_product,
control_product=study_in.control_product,
indication=study_in.indication,
research_population=study_in.research_population,
research_design=study_in.research_design,
plan_start_date=study_in.plan_start_date,
plan_end_date=study_in.plan_end_date,
planned_site_count=study_in.planned_site_count,
planned_enrollment_count=study_in.planned_enrollment_count,
enrollment_monthly_goal_note=study_in.enrollment_monthly_goal_note,
enrollment_stage_breakdown=study_in.enrollment_stage_breakdown,
summary_note=study_in.summary_note,
objective_note=study_in.objective_note,
phase=study_in.phase,
status=study_in.status,
visit_interval_days=study_in.visit_interval_days,
@@ -114,6 +120,10 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.faq_category import FaqCategory
from app.models.faq_item import FaqItem
from app.models.faq_reply import FaqReply
from app.models.study_setup_config import StudySetupConfig
from app.models.study_setup_config_version import StudySetupConfigVersion
from app.models.study_monitoring_strategy import StudyMonitoringStrategy
from app.models.study_center_confirm import StudyCenterConfirm
# 按依赖关系顺序删除关联数据
# 1. 删除审计日志
@@ -160,13 +170,21 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
await db.execute(sa_delete(SubjectHistory).where(SubjectHistory.study_id == study_id))
await db.execute(sa_delete(Subject).where(Subject.study_id == study_id))
# 13. 删除站点
# 13. 删除立项联动实体(需先于站点删除,避免外键约束)
await db.execute(sa_delete(StudyMonitoringStrategy).where(StudyMonitoringStrategy.study_id == study_id))
await db.execute(sa_delete(StudyCenterConfirm).where(StudyCenterConfirm.study_id == study_id))
# 14. 删除站点
await db.execute(sa_delete(Site).where(Site.study_id == study_id))
# 14. 删除成员
# 15. 删除成员
await db.execute(sa_delete(StudyMember).where(StudyMember.study_id == study_id))
# 15. 最后删除项目本身
# 16. 删除立项配置
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
# 17. 最后删除项目本身
await db.execute(sa_delete(Study).where(Study.id == study_id))
await db.commit()
+171
View File
@@ -0,0 +1,171 @@
import uuid
from datetime import datetime
from sqlalchemy import delete as sa_delete, select
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_setup_config import StudySetupConfig
from app.models.study_setup_config_version import StudySetupConfigVersion
from app.schemas.study_setup_config import StudySetupConfigData
def _to_storage(data: StudySetupConfigData | dict) -> dict:
if isinstance(data, StudySetupConfigData):
return data.model_dump(mode="json")
return data
async def get_by_study(db: AsyncSession, study_id: uuid.UUID) -> StudySetupConfig | None:
result = await db.execute(select(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
return result.scalar_one_or_none()
async def upsert(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
data: StudySetupConfigData | dict,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id)
payload = _to_storage(data)
if existing:
if expected_version is not None and expected_version != existing.version:
return None, True
existing.version = existing.version + 1
existing.config = payload
existing.saved_by = saved_by
existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT"
existing.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
return existing, False
record = StudySetupConfig(
study_id=study_id,
version=1,
config=payload,
publish_status="DRAFT",
saved_by=saved_by,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record, False
async def publish(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
published_by: uuid.UUID | None,
auto_commit: bool = True,
) -> tuple[StudySetupConfig | None, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False
if expected_version is not None and expected_version != existing.version:
return None, True
next_version = existing.version + 1
next_display_version_stmt = select(func.max(StudySetupConfigVersion.display_version)).where(
StudySetupConfigVersion.study_id == study_id
)
display_version_result = await db.execute(next_display_version_stmt)
current_max_display_version = display_version_result.scalar_one_or_none() or 0
next_display_version = current_max_display_version + 1
existing.version = next_version
existing.publish_status = "PUBLISHED"
existing.published_config = existing.config
existing.published_by = published_by
existing.published_at = datetime.utcnow()
existing.saved_by = published_by
existing.updated_at = datetime.utcnow()
snapshot = StudySetupConfigVersion(
study_setup_config_id=existing.id,
study_id=study_id,
version=next_version,
display_version=next_display_version,
source_version=next_version - 1,
config=existing.config,
published_by=published_by,
published_at=existing.published_at,
)
db.add(snapshot)
if auto_commit:
await db.commit()
await db.refresh(existing)
else:
await db.flush()
return existing, False
async def list_versions(db: AsyncSession, study_id: uuid.UUID) -> list[StudySetupConfigVersion]:
result = await db.execute(
select(StudySetupConfigVersion)
.where(StudySetupConfigVersion.study_id == study_id)
.order_by(StudySetupConfigVersion.version.desc(), StudySetupConfigVersion.published_at.desc())
)
return list(result.scalars().all())
async def rollback_to_version(
db: AsyncSession,
study_id: uuid.UUID,
*,
expected_version: int | None,
target_version: int,
saved_by: uuid.UUID | None,
) -> tuple[StudySetupConfig | None, bool, bool]:
existing = await get_by_study(db, study_id)
if not existing:
return None, False, False
if expected_version is not None and expected_version != existing.version:
return None, True, False
result = await db.execute(
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == target_version,
)
)
target = result.scalar_one_or_none()
if not target:
return None, False, True
existing.version = existing.version + 1
existing.config = target.config
existing.saved_by = saved_by
existing.publish_status = "DRAFT"
existing.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing)
return existing, False, False
async def delete_version(
db: AsyncSession,
study_id: uuid.UUID,
*,
target_version: int,
) -> bool:
result = await db.execute(
select(StudySetupConfigVersion).where(
StudySetupConfigVersion.study_id == study_id,
StudySetupConfigVersion.version == target_version,
)
)
target = result.scalar_one_or_none()
if not target:
return False
await db.delete(target)
await db.commit()
return True
async def delete_by_study(db: AsyncSession, study_id: uuid.UUID) -> None:
await db.execute(sa_delete(StudySetupConfigVersion).where(StudySetupConfigVersion.study_id == study_id))
await db.execute(sa_delete(StudySetupConfig).where(StudySetupConfig.study_id == study_id))
await db.commit()
+37
View File
@@ -16,6 +16,27 @@ from app.models.subject import Subject
from app.schemas.subject import SubjectCreate, SubjectUpdate
def _validate_subject_date_chain(
*,
screening_date: date | None,
consent_date: date | None,
enrollment_date: date | None,
completion_date: date | None,
) -> None:
if screening_date and consent_date and consent_date < screening_date:
raise ValueError("知情同意日期不能早于筛选日期")
if screening_date and enrollment_date and enrollment_date < screening_date:
raise ValueError("入组日期不能早于筛选日期")
if consent_date and enrollment_date and enrollment_date < consent_date:
raise ValueError("入组日期不能早于知情同意日期")
if screening_date and completion_date and completion_date < screening_date:
raise ValueError("完成日期不能早于筛选日期")
if consent_date and completion_date and completion_date < consent_date:
raise ValueError("完成日期不能早于知情同意日期")
if enrollment_date and completion_date and completion_date < enrollment_date:
raise ValueError("完成日期不能早于入组日期")
async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID) -> None:
result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none()
@@ -27,6 +48,12 @@ async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UU
async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: SubjectCreate) -> Subject:
await _validate_site(db, study_id, subject_in.site_id)
_validate_subject_date_chain(
screening_date=subject_in.screening_date,
consent_date=subject_in.consent_date,
enrollment_date=None,
completion_date=None,
)
subject = Subject(
study_id=study_id,
site_id=subject_in.site_id,
@@ -121,6 +148,16 @@ async def generate_default_visits(db: AsyncSession, subject: Subject) -> None:
async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject:
update_data = subject_in.model_dump(exclude_unset=True)
next_screening_date = subject.screening_date
next_consent_date = update_data.get("consent_date", subject.consent_date)
next_enrollment_date = update_data.get("enrollment_date", subject.enrollment_date)
next_completion_date = update_data.get("completion_date", subject.completion_date)
_validate_subject_date_chain(
screening_date=next_screening_date,
consent_date=next_consent_date,
enrollment_date=next_enrollment_date,
completion_date=next_completion_date,
)
if update_data:
await db.execute(
sa_update(Subject)