feat: refine subject visits and project workflows

Add early termination visit workflow with ordering, non-applicable visit handling, visit window display, and medication adherence support.

Extend monitoring visit issue template fields, site scoping, setup draft project info handling, login security UI, attachment behavior, and related tests/migrations.
This commit is contained in:
Cheng Zhou
2026-05-09 17:10:34 +08:00
parent 74feca4467
commit 917ab7ccf1
41 changed files with 3463 additions and 701 deletions
@@ -6,7 +6,11 @@ from pydantic import BaseModel, ConfigDict, field_validator
class MonitoringVisitIssueCreate(BaseModel):
issue_no: str | None = None
site_id: uuid.UUID | None = None
category: str
severity: str | None = None
mark: str | None = None
visit_cycle: str | None = None
subject_code: str | None = None
monitor_item: str | None = None
monitor_type: str | None = None
@@ -18,6 +22,9 @@ class MonitoringVisitIssueCreate(BaseModel):
description: str | None = None
action_taken: str | None = None
follow_up_progress: str | None = None
center_query: str | None = None
center_latest_reply: str | None = None
rectification_completed: bool = False
found_date: date | None = None
due_at: datetime | None = None
actual_resolve_date: date | None = None
@@ -42,6 +49,9 @@ class MonitoringVisitIssueCreate(BaseModel):
@field_validator(
"subject_code",
"severity",
"mark",
"visit_cycle",
"monitor_item",
"monitor_type",
"source",
@@ -51,6 +61,8 @@ class MonitoringVisitIssueCreate(BaseModel):
"description",
"action_taken",
"follow_up_progress",
"center_query",
"center_latest_reply",
"responsible_name",
mode="before",
)
@@ -80,9 +92,13 @@ class MonitoringVisitIssueCreate(BaseModel):
class MonitoringVisitIssueRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: uuid.UUID | None
issue_no: str
open_duration: str
category: str
severity: str | None
mark: str | None
visit_cycle: str | None
subject_code: str | None
monitor_item: str | None
monitor_type: str | None
@@ -96,6 +112,9 @@ class MonitoringVisitIssueRead(BaseModel):
description: str | None
action_taken: str | None
follow_up_progress: str | None
center_query: str | None
center_latest_reply: str | None
rectification_completed: bool
found_date: date | None
overdue: bool
due_at: datetime | None
@@ -115,7 +134,11 @@ class MonitoringVisitIssueImportSummary(BaseModel):
class MonitoringVisitIssueUpdate(BaseModel):
issue_no: str | None = None
site_id: uuid.UUID | None = None
category: str | None = None
severity: str | None = None
mark: str | None = None
visit_cycle: str | None = None
subject_code: str | None = None
monitor_item: str | None = None
monitor_type: str | None = None
@@ -127,6 +150,9 @@ class MonitoringVisitIssueUpdate(BaseModel):
description: str | None = None
action_taken: str | None = None
follow_up_progress: str | None = None
center_query: str | None = None
center_latest_reply: str | None = None
rectification_completed: bool | None = None
found_date: date | None = None
due_at: datetime | None = None
actual_resolve_date: date | None = None
@@ -145,6 +171,9 @@ class MonitoringVisitIssueUpdate(BaseModel):
@field_validator(
"subject_code",
"severity",
"mark",
"visit_cycle",
"monitor_item",
"monitor_type",
"source",
@@ -154,6 +183,8 @@ class MonitoringVisitIssueUpdate(BaseModel):
"description",
"action_taken",
"follow_up_progress",
"center_query",
"center_latest_reply",
"responsible_name",
mode="before",
)
+40 -51
View File
@@ -1,14 +1,9 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
ConfirmStatus = Literal["待确认", "已确认", "退回"]
MilestoneStatus = Literal["未开始", "进行中", "已完成", "延期"]
class ProjectMilestoneItem(BaseModel):
id: str
name: str = ""
@@ -18,7 +13,7 @@ class ProjectMilestoneItem(BaseModel):
durationDays: int = 1
owner: str = ""
remark: str = ""
status: MilestoneStatus = "未开始"
status: str = "未开始"
class EnrollmentPlanItem(BaseModel):
@@ -35,7 +30,7 @@ class SiteMilestoneItem(BaseModel):
planDate: str = ""
owner: str = ""
remark: str = ""
status: MilestoneStatus = "未开始"
status: str = "未开始"
class SiteEnrollmentPlanItem(BaseModel):
@@ -63,12 +58,49 @@ class CenterConfirmItem(BaseModel):
siteId: str = ""
siteName: str = ""
confirmer: str = ""
confirmStatus: ConfirmStatus = "待确认"
confirmStatus: str = "待确认"
confirmDate: str = ""
note: str = ""
class VisitScheduleItem(BaseModel):
visit_code: str = ""
baseline_offset_days: int = 0
window_before_days: int = 0
window_after_days: int = 0
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
status: str = ""
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def normalize_visit_schedule(self):
for item in self.visit_schedule:
item.visit_code = item.visit_code.strip()
return self
class StudySetupConfigData(BaseModel):
projectInfo: ProjectPublishSnapshot = Field(default_factory=ProjectPublishSnapshot)
projectMilestones: list[ProjectMilestoneItem] = Field(default_factory=list)
enrollmentPlan: EnrollmentPlanItem = Field(default_factory=EnrollmentPlanItem)
siteMilestones: list[SiteMilestoneItem] = Field(default_factory=list)
@@ -143,49 +175,6 @@ class SetupProjectionSummary(BaseModel):
skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list)
class VisitScheduleItem(BaseModel):
visit_code: str = Field(min_length=1, max_length=50)
baseline_offset_days: int = Field(ge=0, le=3650)
window_before_days: int = Field(ge=0, le=365)
window_after_days: int = Field(ge=0, le=365)
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
status: str = ""
visit_schedule: list[VisitScheduleItem] = Field(default_factory=list)
@model_validator(mode="after")
def validate_visit_schedule(self):
codes: set[str] = set()
for index, item in enumerate(self.visit_schedule):
code = item.visit_code.strip()
if not code:
raise ValueError(f"{index + 1} 行访视编号不能为空")
if code in codes:
raise ValueError(f"访视编号重复:{code}")
codes.add(code)
item.visit_code = code
return self
class StudySetupConfigRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
+2
View File
@@ -20,6 +20,7 @@ class SubjectUpdate(BaseModel):
enrollment_date: Optional[date] = None
baseline_date: Optional[date] = None
completion_date: Optional[date] = None
actual_medication_count: Optional[int] = None
drop_reason: Optional[str] = None
@@ -34,6 +35,7 @@ class SubjectRead(BaseModel):
enrollment_date: Optional[date]
baseline_date: Optional[date]
completion_date: Optional[date]
actual_medication_count: Optional[int]
drop_reason: Optional[str]
created_at: datetime
updated_at: datetime
+6
View File
@@ -19,6 +19,12 @@ class VisitUpdate(BaseModel):
notes: Optional[str] = None
class EarlyTerminationCreate(BaseModel):
termination_date: date
reason: str
notes: Optional[str] = None
class VisitRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID