d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
542 lines
19 KiB
Python
542 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.deps import get_cra_site_scope, is_system_admin
|
|
from app.core.project_permissions import role_has_api_permission
|
|
from app.crud import member as member_crud
|
|
from app.crud import site as site_crud
|
|
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
|
|
from app.models.ae import AdverseEvent
|
|
from app.models.collaboration import CollaborationEditRequest, CollaborationFile, CollaborationMember
|
|
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
|
|
from app.models.document import Document, DocumentStatus
|
|
from app.models.document_version import DocumentVersion
|
|
from app.models.milestone import Milestone
|
|
from app.models.monitoring_visit_issue import MonitoringVisitIssue
|
|
from app.models.site import Site
|
|
from app.models.subject import Subject
|
|
from app.models.user import User
|
|
from app.models.visit import Visit
|
|
from app.services import notification_service
|
|
|
|
BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
|
RISK_DUE_SOON_DAYS = 3
|
|
MILESTONE_DUE_SOON_DAYS = 7
|
|
VISIT_WINDOW_DUE_SOON_DAYS = 3
|
|
|
|
|
|
def _date_due_at(value: date | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
return datetime.combine(value, time.max, tzinfo=BUSINESS_TIMEZONE).astimezone(timezone.utc)
|
|
|
|
|
|
def _as_utc(value: datetime | None) -> datetime | None:
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
return value.replace(tzinfo=timezone.utc)
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
async def _count_and_earliest(db: AsyncSession, model, *filters):
|
|
return (await db.execute(
|
|
select(func.count(model.id), func.min(model.report_due_date if model is AdverseEvent else model.due_at))
|
|
.where(*filters)
|
|
)).one()
|
|
|
|
|
|
async def _sync_risk_reminders(
|
|
db: AsyncSession,
|
|
*,
|
|
study_id: uuid.UUID,
|
|
user,
|
|
role: str,
|
|
current_time: datetime,
|
|
) -> None:
|
|
can_read_aes = is_system_admin(user) or await role_has_api_permission(
|
|
db, study_id, role, "subject_aes:read"
|
|
)
|
|
can_read_monitoring = is_system_admin(user) or await role_has_api_permission(
|
|
db, study_id, role, "monitoring_issues:read"
|
|
)
|
|
cra_scope = await get_cra_site_scope(db, study_id, user)
|
|
site_ids = cra_scope[0] if cra_scope else None
|
|
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
|
due_soon_date = today + timedelta(days=RISK_DUE_SOON_DAYS)
|
|
|
|
ae_scope = [AdverseEvent.study_id == study_id, AdverseEvent.status != "CLOSED"]
|
|
if site_ids is not None:
|
|
ae_scope.append(AdverseEvent.site_id.in_(site_ids))
|
|
overdue_aes = 0
|
|
overdue_ae_due = None
|
|
due_soon_aes = 0
|
|
due_soon_ae_due = None
|
|
if can_read_aes:
|
|
overdue_aes, overdue_ae_due = await _count_and_earliest(
|
|
db,
|
|
AdverseEvent,
|
|
*ae_scope,
|
|
AdverseEvent.report_due_date.is_not(None),
|
|
AdverseEvent.report_due_date < today,
|
|
)
|
|
due_soon_aes, due_soon_ae_due = await _count_and_earliest(
|
|
db,
|
|
AdverseEvent,
|
|
*ae_scope,
|
|
AdverseEvent.report_due_date >= today,
|
|
AdverseEvent.report_due_date <= due_soon_date,
|
|
)
|
|
await notification_service.sync_aggregate_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category="RISK_OVERDUE_AE",
|
|
priority="URGENT",
|
|
title="逾期 AE 待处理",
|
|
message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进",
|
|
action_path="/risk-issues/sae",
|
|
source_type="RISK_OVERDUE_AE",
|
|
source_id=str(study_id),
|
|
count=int(overdue_aes) if can_read_aes else 0,
|
|
due_at=_date_due_at(overdue_ae_due),
|
|
)
|
|
await notification_service.sync_aggregate_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category="RISK_AE_DUE_SOON",
|
|
priority="HIGH",
|
|
title="AE 上报时限临近",
|
|
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_aes} 条 AE 到达上报时限",
|
|
action_path="/risk-issues/sae",
|
|
source_type="RISK_AE_DUE_SOON",
|
|
source_id=str(study_id),
|
|
count=int(due_soon_aes) if can_read_aes else 0,
|
|
due_at=_date_due_at(due_soon_ae_due),
|
|
)
|
|
|
|
monitoring_scope = [
|
|
MonitoringVisitIssue.study_id == study_id,
|
|
MonitoringVisitIssue.status == "OPEN",
|
|
MonitoringVisitIssue.due_at.is_not(None),
|
|
]
|
|
if site_ids is not None:
|
|
monitoring_scope.append(MonitoringVisitIssue.site_id.in_(site_ids))
|
|
overdue_monitoring = 0
|
|
overdue_monitoring_due = None
|
|
due_soon_monitoring = 0
|
|
due_soon_monitoring_due = None
|
|
if can_read_monitoring:
|
|
overdue_monitoring, overdue_monitoring_due = await _count_and_earliest(
|
|
db,
|
|
MonitoringVisitIssue,
|
|
*monitoring_scope,
|
|
MonitoringVisitIssue.due_at < current_time,
|
|
)
|
|
due_soon_monitoring, due_soon_monitoring_due = await _count_and_earliest(
|
|
db,
|
|
MonitoringVisitIssue,
|
|
*monitoring_scope,
|
|
MonitoringVisitIssue.due_at >= current_time,
|
|
MonitoringVisitIssue.due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS),
|
|
)
|
|
await notification_service.sync_aggregate_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category="RISK_OVERDUE_MONITORING",
|
|
priority="HIGH",
|
|
title="监查问题已逾期",
|
|
message=f"当前有 {overdue_monitoring} 条监查问题已超过计划解决日期",
|
|
action_path="/risk-issues/monitoring-visits",
|
|
source_type="RISK_OVERDUE_MONITORING",
|
|
source_id=str(study_id),
|
|
count=int(overdue_monitoring) if can_read_monitoring else 0,
|
|
due_at=_as_utc(overdue_monitoring_due),
|
|
)
|
|
await notification_service.sync_aggregate_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category="RISK_MONITORING_DUE_SOON",
|
|
priority="HIGH",
|
|
title="监查问题整改时限临近",
|
|
message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_monitoring} 条监查问题到期",
|
|
action_path="/risk-issues/monitoring-visits",
|
|
source_type="RISK_MONITORING_DUE_SOON",
|
|
source_id=str(study_id),
|
|
count=int(due_soon_monitoring) if can_read_monitoring else 0,
|
|
due_at=_as_utc(due_soon_monitoring_due),
|
|
)
|
|
|
|
|
|
async def _sync_document_distribution_reminders(
|
|
db: AsyncSession,
|
|
*,
|
|
study_id: uuid.UUID,
|
|
user,
|
|
role: str,
|
|
current_time: datetime,
|
|
) -> None:
|
|
can_read_documents = is_system_admin(user) or await role_has_api_permission(
|
|
db, study_id, role, "documents:read"
|
|
)
|
|
if not can_read_documents:
|
|
await notification_service.resolve_missing_recipient_sources(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
source_type="DOCUMENT_DISTRIBUTION",
|
|
active_source_ids=set(),
|
|
)
|
|
return
|
|
site_ids = await site_crud.list_ids_by_contact_user(db, study_id, user.id)
|
|
target_filters = [
|
|
and_(
|
|
Distribution.target_type == DistributionTargetType.USER,
|
|
Distribution.target_id == str(user.id),
|
|
),
|
|
and_(
|
|
Distribution.target_type == DistributionTargetType.ROLE,
|
|
Distribution.target_id == role,
|
|
),
|
|
]
|
|
if site_ids:
|
|
target_filters.append(and_(
|
|
Distribution.target_type == DistributionTargetType.SITE,
|
|
Distribution.target_id.in_({str(site_id) for site_id in site_ids}),
|
|
))
|
|
rows = (await db.execute(
|
|
select(Distribution, Document, DocumentVersion)
|
|
.join(Document, Distribution.document_id == Document.id)
|
|
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
|
|
.outerjoin(
|
|
Acknowledgement,
|
|
and_(
|
|
Acknowledgement.distribution_id == Distribution.id,
|
|
Acknowledgement.user_id == user.id,
|
|
Acknowledgement.ack_type == AcknowledgementType.RECEIVED,
|
|
),
|
|
)
|
|
.where(
|
|
Document.trial_id == study_id,
|
|
Document.status == DocumentStatus.ACTIVE,
|
|
Distribution.status == DistributionStatus.ACTIVE,
|
|
Acknowledgement.id.is_(None),
|
|
or_(*target_filters),
|
|
)
|
|
)).all()
|
|
|
|
active_source_ids: set[str] = set()
|
|
for distribution, document, version in rows:
|
|
source_id = str(distribution.id)
|
|
active_source_ids.add(source_id)
|
|
distribution_due_at = _as_utc(distribution.due_at)
|
|
if distribution_due_at and distribution_due_at < current_time:
|
|
stage = "OVERDUE"
|
|
category = "DOCUMENT_ACK_OVERDUE"
|
|
priority = "URGENT"
|
|
title = "文件回执已逾期"
|
|
elif distribution_due_at and distribution_due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS):
|
|
stage = "DUE_SOON"
|
|
category = "DOCUMENT_ACK_DUE_SOON"
|
|
priority = "HIGH"
|
|
title = "文件回执即将到期"
|
|
else:
|
|
stage = "PENDING"
|
|
category = "DOCUMENT_DISTRIBUTION"
|
|
priority = "NORMAL"
|
|
title = "有新的文件版本待接收"
|
|
due_version = distribution_due_at.isoformat() if distribution_due_at else "none"
|
|
await notification_service.sync_state_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category=category,
|
|
priority=priority,
|
|
title=title,
|
|
message=f"“{document.title}” {version.version_no} 已分发,请完成接收回执",
|
|
action_path=f"/documents/{document.id}",
|
|
source_type="DOCUMENT_DISTRIBUTION",
|
|
source_id=source_id,
|
|
source_version=f"{stage}:{due_version}",
|
|
active=True,
|
|
due_at=distribution_due_at,
|
|
)
|
|
await notification_service.resolve_missing_recipient_sources(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
source_type="DOCUMENT_DISTRIBUTION",
|
|
active_source_ids=active_source_ids,
|
|
)
|
|
|
|
|
|
async def _sync_milestone_reminders(
|
|
db: AsyncSession,
|
|
*,
|
|
study_id: uuid.UUID,
|
|
user,
|
|
role: str,
|
|
current_time: datetime,
|
|
) -> None:
|
|
can_read = is_system_admin(user) or await role_has_api_permission(
|
|
db, study_id, role, "project_milestones:read"
|
|
)
|
|
active_source_ids: set[str] = set()
|
|
if can_read:
|
|
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
|
due_soon_date = today + timedelta(days=MILESTONE_DUE_SOON_DAYS)
|
|
milestones = (await db.scalars(
|
|
select(Milestone).where(
|
|
Milestone.study_id == study_id,
|
|
Milestone.owner_id == user.id,
|
|
Milestone.status != "DONE",
|
|
)
|
|
)).all()
|
|
for milestone in milestones:
|
|
due_date = milestone.adjusted_end_date or milestone.planned_date
|
|
if due_date is None or due_date > due_soon_date:
|
|
continue
|
|
source_id = str(milestone.id)
|
|
active_source_ids.add(source_id)
|
|
overdue = due_date < today
|
|
await notification_service.sync_state_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category="MILESTONE_OVERDUE" if overdue else "MILESTONE_DUE_SOON",
|
|
priority="HIGH" if overdue else "NORMAL",
|
|
title="项目里程碑已逾期" if overdue else "项目里程碑即将到期",
|
|
message=f"“{milestone.name}”计划日期为 {due_date.isoformat()}",
|
|
action_path="/project/milestones",
|
|
source_type="PROJECT_MILESTONE",
|
|
source_id=source_id,
|
|
source_version=f"{'OVERDUE' if overdue else 'DUE_SOON'}:{due_date.isoformat()}:{milestone.status}",
|
|
active=True,
|
|
due_at=_date_due_at(due_date),
|
|
)
|
|
await notification_service.resolve_missing_recipient_sources(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
source_type="PROJECT_MILESTONE",
|
|
active_source_ids=active_source_ids,
|
|
)
|
|
|
|
|
|
async def _sync_visit_window_reminders(
|
|
db: AsyncSession,
|
|
*,
|
|
study_id: uuid.UUID,
|
|
user,
|
|
role: str,
|
|
current_time: datetime,
|
|
) -> None:
|
|
"""Project active visit windows to the members who can actually maintain them."""
|
|
can_manage_visits = is_system_admin(user) or await role_has_api_permission(
|
|
db, study_id, role, "visits:update"
|
|
)
|
|
active_source_ids: set[str] = set()
|
|
if can_manage_visits:
|
|
cra_scope = await get_cra_site_scope(db, study_id, user)
|
|
site_ids = cra_scope[0] if cra_scope else None
|
|
filters = [
|
|
Visit.study_id == study_id,
|
|
Visit.actual_date.is_(None),
|
|
Visit.status.not_in(["DONE", "CANCELLED"]),
|
|
Subject.status.not_in(["COMPLETED", "DROPPED"]),
|
|
Site.is_active.is_(True),
|
|
]
|
|
if site_ids is not None:
|
|
filters.append(Subject.site_id.in_(site_ids))
|
|
rows = (await db.execute(
|
|
select(Visit, Subject)
|
|
.join(Subject, Subject.id == Visit.subject_id)
|
|
.join(Site, Site.id == Subject.site_id)
|
|
.where(*filters)
|
|
)).all()
|
|
|
|
today = current_time.astimezone(BUSINESS_TIMEZONE).date()
|
|
due_soon_date = today + timedelta(days=VISIT_WINDOW_DUE_SOON_DAYS)
|
|
for visit, subject in rows:
|
|
window_start = visit.window_start or visit.planned_date
|
|
window_end = visit.window_end or visit.planned_date
|
|
if window_start is None or window_end is None or window_start > window_end:
|
|
continue
|
|
|
|
if window_end < today:
|
|
category = "VISIT_WINDOW_MISSED"
|
|
priority = "HIGH"
|
|
title = "受试者访视已错过窗口"
|
|
message = "一个受试者访视已错过计划窗口,请进入详情核对并处理"
|
|
stage = "MISSED"
|
|
elif window_start <= due_soon_date:
|
|
category = "VISIT_WINDOW_DUE_SOON"
|
|
priority = "NORMAL"
|
|
title = "受试者访视窗口临近"
|
|
message = "一个受试者访视已进入近期窗口,请及时核对访视安排"
|
|
stage = "DUE_SOON"
|
|
else:
|
|
continue
|
|
|
|
source_id = str(visit.id)
|
|
active_source_ids.add(source_id)
|
|
await notification_service.sync_state_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category=category,
|
|
priority=priority,
|
|
title=title,
|
|
message=message,
|
|
action_path=f"/subjects/{subject.id}",
|
|
source_type="SUBJECT_VISIT_WINDOW",
|
|
source_id=source_id,
|
|
source_version=f"{stage}:{window_start.isoformat()}:{window_end.isoformat()}",
|
|
active=True,
|
|
due_at=_date_due_at(window_end),
|
|
)
|
|
await notification_service.resolve_missing_recipient_sources(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
source_type="SUBJECT_VISIT_WINDOW",
|
|
active_source_ids=active_source_ids,
|
|
)
|
|
|
|
|
|
async def _sync_collaboration_edit_request_reminders(
|
|
db: AsyncSession,
|
|
*,
|
|
study_id: uuid.UUID,
|
|
user,
|
|
role: str,
|
|
) -> None:
|
|
can_read = is_system_admin(user) or await role_has_api_permission(
|
|
db, study_id, role, "collaboration:read"
|
|
)
|
|
if not can_read:
|
|
await notification_service.resolve_missing_recipient_sources(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
source_type="COLLABORATION_EDIT_REQUEST",
|
|
active_source_ids=set(),
|
|
)
|
|
return
|
|
manages_file = or_(
|
|
CollaborationFile.owner_id == user.id,
|
|
select(CollaborationMember.id).where(
|
|
CollaborationMember.file_id == CollaborationFile.id,
|
|
CollaborationMember.user_id == user.id,
|
|
CollaborationMember.role == "MANAGER",
|
|
).exists(),
|
|
)
|
|
rows = (await db.execute(
|
|
select(CollaborationEditRequest, CollaborationFile, User)
|
|
.join(CollaborationFile, CollaborationFile.id == CollaborationEditRequest.file_id)
|
|
.join(User, User.id == CollaborationEditRequest.requester_id)
|
|
.where(
|
|
CollaborationFile.study_id == study_id,
|
|
CollaborationFile.status == "ACTIVE",
|
|
CollaborationFile.deleted_at.is_(None),
|
|
CollaborationEditRequest.status == "PENDING",
|
|
manages_file,
|
|
)
|
|
)).all()
|
|
active_source_ids: set[str] = set()
|
|
for request, item, requester in rows:
|
|
source_id = str(request.id)
|
|
active_source_ids.add(source_id)
|
|
requester_name = str(requester.full_name or requester.email or "项目成员")
|
|
await notification_service.sync_state_notification(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
category="COLLABORATION_EDIT_REQUEST",
|
|
priority="NORMAL",
|
|
title="新的编辑权限申请",
|
|
message=f"{requester_name} 申请编辑“{item.title}”",
|
|
action_path=f"/knowledge/collaboration?editRequestFile={item.id}",
|
|
source_type="COLLABORATION_EDIT_REQUEST",
|
|
source_id=source_id,
|
|
source_version="PENDING",
|
|
active=True,
|
|
dedupe_key=f"collaboration-edit-request:{request.id}",
|
|
)
|
|
await notification_service.resolve_missing_recipient_sources(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
source_type="COLLABORATION_EDIT_REQUEST",
|
|
active_source_ids=active_source_ids,
|
|
)
|
|
|
|
|
|
async def sync_project_reminders(
|
|
db: AsyncSession,
|
|
study_id: uuid.UUID,
|
|
user,
|
|
*,
|
|
now: datetime | None = None,
|
|
) -> None:
|
|
membership = await member_crud.get_member(db, study_id, user.id)
|
|
if not membership or not membership.is_active:
|
|
await notification_service.resolve_recipient_study_notifications(
|
|
db,
|
|
study_id=study_id,
|
|
recipient_id=user.id,
|
|
)
|
|
await db.commit()
|
|
return
|
|
role = membership.role_in_study
|
|
current_time = now or datetime.now(timezone.utc)
|
|
if current_time.tzinfo is None:
|
|
current_time = current_time.replace(tzinfo=timezone.utc)
|
|
else:
|
|
current_time = current_time.astimezone(timezone.utc)
|
|
|
|
await _sync_risk_reminders(
|
|
db,
|
|
study_id=study_id,
|
|
user=user,
|
|
role=role,
|
|
current_time=current_time,
|
|
)
|
|
await _sync_document_distribution_reminders(
|
|
db,
|
|
study_id=study_id,
|
|
user=user,
|
|
role=role,
|
|
current_time=current_time,
|
|
)
|
|
await _sync_milestone_reminders(
|
|
db,
|
|
study_id=study_id,
|
|
user=user,
|
|
role=role,
|
|
current_time=current_time,
|
|
)
|
|
await _sync_visit_window_reminders(
|
|
db,
|
|
study_id=study_id,
|
|
user=user,
|
|
role=role,
|
|
current_time=current_time,
|
|
)
|
|
await _sync_collaboration_edit_request_reminders(
|
|
db,
|
|
study_id=study_id,
|
|
user=user,
|
|
role=role,
|
|
)
|
|
await db.commit()
|