未知(继上次中断)

This commit is contained in:
Cheng Zhou
2026-02-04 10:52:34 +08:00
parent 8e258d21a7
commit 737f84bf54
99 changed files with 5497 additions and 2143 deletions
+182 -215
View File
@@ -8,10 +8,11 @@ from typing import Iterable
import aiofiles
from fastapi import HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import update as sa_update
from sqlalchemy import delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import rbac
from app.core.deps import get_cra_site_scope
from app.crud import acknowledgement as acknowledgement_crud
from app.crud import distribution as distribution_crud
from app.crud import document as document_crud
@@ -20,20 +21,16 @@ from app.crud import member as member_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.crud import user as user_crud
from app.crud import version_workflow as workflow_crud
from app.crud import workflow_template as template_crud
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
from app.models.audit_log import AuditLog
from app.models.distribution import Distribution, DistributionTargetType
from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType
from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.models.version_workflow import VersionWorkflow, WorkflowStatus
from app.models.workflow_action import WorkflowAction, WorkflowActionType
from app.models.workflow_template import WorkflowTemplate
from app.schemas.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.notification import NotificationItem
from app.schemas.user import UserDisplay
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
@@ -83,6 +80,18 @@ async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_us
return membership
async def _ensure_study_member(db: AsyncSession, trial_id: uuid.UUID, current_user):
study = await study_crud.get(db, trial_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if current_user.role == "ADMIN":
return None
membership = await member_crud.get_member(db, trial_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
return membership
async def create_document(
db: AsyncSession,
doc_in: DocumentCreate,
@@ -140,6 +149,8 @@ async def list_documents(
current_user,
) -> list[DocumentSummary]:
await _ensure_study_access(db, trial_id, current_user, action="view")
cra_scope = await get_cra_site_scope(db, trial_id, current_user)
cra_site_ids = cra_scope[0] if cra_scope else None
docs = await document_crud.list_documents(
db,
trial_id=trial_id,
@@ -147,6 +158,7 @@ async def list_documents(
doc_type=doc_type,
status=status,
scope_type=scope_type,
cra_site_ids=cra_site_ids,
skip=skip,
limit=limit,
)
@@ -185,6 +197,9 @@ async def get_document_detail(
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="view")
cra_scope = await get_cra_site_scope(db, doc.trial_id, current_user)
if cra_scope and doc.scope_type == DocumentScopeType.SITE and doc.site_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
versions = await version_crud.list_by_document(db, document_id)
current_version = None
@@ -223,6 +238,7 @@ async def create_version(
document_id: uuid.UUID,
*,
version_no: str,
version_date: str,
change_summary: str | None,
parent_version_id: uuid.UUID | None,
file: UploadFile,
@@ -233,6 +249,8 @@ async def create_version(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="create_version")
previous_effective_id = doc.current_effective_version_id
existing = await version_crud.get_by_document_version_no(db, document_id, version_no)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本号已存在")
@@ -251,11 +269,21 @@ async def create_version(
async with aiofiles.open(dest_path, "wb") as out_file:
await out_file.write(content)
now = datetime.now(timezone.utc)
effective_at = now
try:
parsed = datetime.fromisoformat(version_date)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
effective_at = parsed
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="版本日期格式不正确")
version = DocumentVersion(
document_id=document_id,
version_no=version_no,
parent_version_id=parent_version_id,
status=DocumentVersionStatus.DRAFT,
status=DocumentVersionStatus.EFFECTIVE,
effective_at=effective_at,
file_uri=str(dest_path),
file_hash=file_hash,
file_size=len(content),
@@ -264,6 +292,21 @@ async def create_version(
created_by=current_user.id,
)
db.add(version)
await db.flush()
await db.execute(
sa_update(DocumentVersion)
.where(
DocumentVersion.document_id == document_id,
DocumentVersion.status == DocumentVersionStatus.EFFECTIVE,
DocumentVersion.id != version.id,
)
.values(status=DocumentVersionStatus.SUPERSEDED, superseded_at=now)
)
await db.execute(
sa_update(Document)
.where(Document.id == document_id)
.values(current_effective_version_id=version.id)
)
db.add(
AuditLog(
study_id=doc.trial_id,
@@ -275,227 +318,98 @@ async def create_version(
operator_role=_role_value(current_user),
)
)
if previous_effective_id:
previous_distributions = await distribution_crud.list_by_version(db, previous_effective_id)
if previous_distributions:
seen: set[tuple[str, str]] = set()
for dist in previous_distributions:
key = (dist.target_type, dist.target_id)
if key in seen:
continue
seen.add(key)
db.add(
Distribution(
document_id=document_id,
version_id=version.id,
target_type=dist.target_type,
target_id=dist.target_id,
created_by=current_user.id,
)
)
await db.commit()
await db.refresh(version)
return version
async def submit_version(
async def delete_version(
db: AsyncSession,
version_id: uuid.UUID,
template_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
) -> None:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="submit")
await _ensure_study_access(db, doc.trial_id, current_user, action="delete_document")
if version.status != DocumentVersionStatus.DRAFT:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本状态不允许提交")
pending = await version_crud.list_pending_versions(db, version.document_id)
if any(v.id != version.id for v in pending):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="存在待审批版本")
template = await template_crud.get(db, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审批模板不存在")
distribution_ids = (
await db.execute(
select(Distribution.id).where(Distribution.version_id == version.id)
)
).scalars().all()
if distribution_ids:
await db.execute(sa_delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
await db.execute(sa_delete(Distribution).where(Distribution.id.in_(distribution_ids)))
before = _version_snapshot(version)
version.status = DocumentVersionStatus.SUBMITTED
version.submitted_at = datetime.now(timezone.utc)
workflow = VersionWorkflow(
document_id=version.document_id,
version_id=version.id,
template_id=template_id,
status=WorkflowStatus.PENDING,
current_node=1,
submitted_by=current_user.id,
submitted_at=version.submitted_at,
)
action = WorkflowAction(
workflow=workflow,
node_order=workflow.current_node,
action=WorkflowActionType.SUBMIT,
actor_id=current_user.id,
comment=comment,
)
db.add(version)
db.add(workflow)
db.add(action)
if doc.current_effective_version_id == version.id:
candidates = await version_crud.list_by_document(db, version.document_id)
candidate = next((item for item in candidates if item.id != version.id), None)
if candidate:
update_values: dict[str, object] = {"status": DocumentVersionStatus.EFFECTIVE, "superseded_at": None}
if not candidate.effective_at:
update_values["effective_at"] = datetime.now(timezone.utc)
await db.execute(
sa_update(DocumentVersion)
.where(DocumentVersion.id == candidate.id)
.values(**update_values)
)
await db.execute(
sa_update(Document)
.where(Document.id == doc.id)
.values(current_effective_version_id=candidate.id)
)
else:
await db.execute(
sa_update(Document)
.where(Document.id == doc.id)
.values(current_effective_version_id=None)
)
await db.execute(sa_delete(DocumentVersion).where(DocumentVersion.id == version.id))
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_SUBMITTED",
detail=_audit_detail(before, _version_snapshot(version)),
action="VERSION_DELETED",
detail=_audit_detail(before, None),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def approve_version(
db: AsyncSession,
version_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="approve")
workflow = await workflow_crud.get_by_version(db, version_id)
if not workflow or workflow.status != WorkflowStatus.PENDING:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.APPROVED
version.approved_at = datetime.now(timezone.utc)
workflow.status = WorkflowStatus.APPROVED
workflow.completed_at = version.approved_at
db.add(version)
db.add(workflow)
db.add(
WorkflowAction(
workflow_id=workflow.id,
node_order=workflow.current_node,
action=WorkflowActionType.APPROVE,
actor_id=current_user.id,
comment=comment,
)
)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_APPROVED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def reject_version(
db: AsyncSession,
version_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="approve")
workflow = await workflow_crud.get_by_version(db, version_id)
if not workflow or workflow.status != WorkflowStatus.PENDING:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.WITHDRAWN
version.withdrawn_at = datetime.now(timezone.utc)
workflow.status = WorkflowStatus.REJECTED
workflow.completed_at = version.withdrawn_at
db.add(version)
db.add(workflow)
db.add(
WorkflowAction(
workflow_id=workflow.id,
node_order=workflow.current_node,
action=WorkflowActionType.REJECT,
actor_id=current_user.id,
comment=comment,
)
)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_REJECTED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def make_version_effective(
db: AsyncSession,
version_id: uuid.UUID,
current_user,
) -> DocumentVersion:
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="approve")
if version.status != DocumentVersionStatus.APPROVED:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本未审批通过")
now = datetime.now(timezone.utc)
before = _version_snapshot(version)
version.status = DocumentVersionStatus.EFFECTIVE
version.effective_at = now
await db.execute(
sa_update(DocumentVersion)
.where(
DocumentVersion.document_id == version.document_id,
DocumentVersion.status == DocumentVersionStatus.EFFECTIVE,
DocumentVersion.id != version.id,
)
.values(status=DocumentVersionStatus.SUPERSEDED, superseded_at=now)
)
await db.execute(
sa_update(Document)
.where(Document.id == version.document_id)
.values(current_effective_version_id=version.id)
)
db.add(version)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_EFFECTIVE",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(version)
return version
file_path = Path(version.file_uri)
if file_path.exists():
try:
file_path.unlink()
except OSError:
pass
async def delete_document(
@@ -555,19 +469,6 @@ async def get_version_download_response(
)
async def list_workflow_templates(
db: AsyncSession,
trial_id: uuid.UUID | None,
current_user,
) -> list[WorkflowTemplate]:
if trial_id:
await _ensure_study_access(db, trial_id, current_user, action="view")
elif current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
templates = await template_crud.list_active(db, trial_id)
return list(templates)
async def create_distributions(
db: AsyncSession,
version_id: uuid.UUID,
@@ -580,7 +481,7 @@ async def create_distributions(
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="distribute")
await _ensure_study_member(db, doc.trial_id, current_user)
if version.status != DocumentVersionStatus.EFFECTIVE:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发")
@@ -625,7 +526,7 @@ async def list_distributions(
doc = await document_crud.get(db, version.document_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文档不存在")
await _ensure_study_access(db, doc.trial_id, current_user, action="view")
await _ensure_study_member(db, doc.trial_id, current_user)
distributions = await distribution_crud.list_by_version(db, version_id)
result: list[DistributionRead] = []
@@ -752,3 +653,69 @@ def _aggregate_ack_stats(
if due_at < now and not acks:
stats.overdue = target_count
return stats
async def list_distribution_notifications(
db: AsyncSession,
study_id: uuid.UUID,
current_user,
*,
skip: int = 0,
limit: int = 20,
) -> list[NotificationItem]:
role_value = _role_value(current_user)
membership = None
if role_value != "ADMIN":
membership = await member_crud.get_member(db, study_id, current_user.id)
role_in_study = membership.role_in_study if membership else None
target_filters = [
(Distribution.target_type == DistributionTargetType.USER)
& (Distribution.target_id == str(current_user.id))
]
if role_in_study:
target_filters.append(
(Distribution.target_type == DistributionTargetType.ROLE)
& (Distribution.target_id == role_in_study)
)
stmt = (
select(
Distribution.id,
Distribution.document_id,
Distribution.version_id,
Distribution.created_at,
Document.title,
Document.doc_no,
DocumentVersion.version_no,
DocumentVersion.change_summary,
DocumentVersion.effective_at,
)
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Document, Distribution.document_id == Document.id)
.where(
Document.trial_id == study_id,
Distribution.status == DistributionStatus.ACTIVE,
or_(*target_filters),
)
.order_by(Distribution.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
items: list[NotificationItem] = []
for row in result.fetchall():
items.append(
NotificationItem(
id=row.id,
document_id=row.document_id,
version_id=row.version_id,
document_title=row.title,
document_no=row.doc_no,
version_no=row.version_no,
change_summary=row.change_summary,
effective_at=row.effective_at,
created_at=row.created_at,
)
)
return items
+24
View File
@@ -0,0 +1,24 @@
import asyncio
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from app.crud import visit as visit_crud
from app.db.session import SessionLocal
async def _run_mark_lost_once() -> None:
async with SessionLocal() as session:
await visit_crud.mark_overdue_as_lost_global(session)
async def run_daily_lost_visit_job(stop_event: asyncio.Event) -> None:
tz = ZoneInfo("Asia/Shanghai")
while not stop_event.is_set():
now = datetime.now(tz)
next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
sleep_seconds = max(1, (next_midnight - now).total_seconds())
try:
await asyncio.wait_for(stop_event.wait(), timeout=sleep_seconds)
break
except asyncio.TimeoutError:
await _run_mark_lost_once()