import hashlib import json import uuid from datetime import datetime, timezone from pathlib import Path from typing import Iterable import aiofiles from fastapi import HTTPException, UploadFile, status from fastapi.responses import FileResponse from sqlalchemy import delete as sa_delete, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope from app.core.project_permissions import role_has_project_permission from app.crud import acknowledgement as acknowledgement_crud from app.crud import distribution as distribution_crud from app.crud import document as document_crud from app.crud import document_version as version_crud 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.models.acknowledgement import Acknowledgement, AcknowledgementType from app.models.audit_log import AuditLog 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.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" def _role_value(user) -> str: return user.role.value if hasattr(user.role, "value") else str(user.role) def _audit_detail(before: dict | None, after: dict | None) -> str: payload = {"before": before, "after": after} return json.dumps(payload, ensure_ascii=True) def _doc_snapshot(doc: Document) -> dict: return { "id": str(doc.id), "trial_id": str(doc.trial_id), "site_id": str(doc.site_id) if doc.site_id else None, "doc_no": doc.doc_no, "status": str(doc.status), "current_effective_version_id": str(doc.current_effective_version_id) if doc.current_effective_version_id else None, } def _version_snapshot(version: DocumentVersion) -> dict: return { "id": str(version.id), "document_id": str(version.document_id), "version_no": version.version_no, "status": str(version.status), "file_hash": version.file_hash, } async def _ensure_study_access(db: AsyncSession, trial_id: uuid.UUID, current_user, action: str): 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="不是项目成员") module = "file_versions" permission_action = "read" if action in {"view", "ack"} else "write" allowed = await role_has_project_permission(db, trial_id, membership.role_in_study, module, permission_action) if not allowed: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") 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, current_user, ) -> Document: await _ensure_study_access(db, doc_in.trial_id, current_user, action="create_document") if doc_in.scope_type == DocumentScopeType.SITE and not doc_in.site_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="分中心文件需指定分中心") site_id = doc_in.site_id if doc_in.scope_type == DocumentScopeType.SITE else None if site_id: site = await site_crud.get_site(db, site_id) if not site or not site.is_active: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心已停用") existing = await document_crud.get_by_trial_doc_no(db, doc_in.trial_id, doc_in.doc_no) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="文档编号已存在") doc = Document( trial_id=doc_in.trial_id, site_id=site_id, doc_no=doc_in.doc_no, doc_type=doc_in.doc_type, title=doc_in.title, scope_type=doc_in.scope_type, owner_id=doc_in.owner_id, description=doc_in.description, ) db.add(doc) db.add( AuditLog( study_id=doc_in.trial_id, entity_type="DOCUMENT", entity_id=doc.id, action="DOCUMENT_CREATED", detail=_audit_detail(None, _doc_snapshot(doc)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(doc) return doc async def list_documents( db: AsyncSession, *, trial_id: uuid.UUID, site_id: uuid.UUID | None, doc_type: str | None, status: str | None, scope_type: str | None, skip: int, limit: int, 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, site_id=site_id, doc_type=doc_type, status=status, scope_type=scope_type, cra_site_ids=cra_site_ids, skip=skip, limit=limit, ) summaries: list[DocumentSummary] = [] for doc in docs: current_version = None if doc.current_effective_version_id: version = await version_crud.get(db, doc.current_effective_version_id) if version: current_version = DocumentVersionSummary.model_validate(version) summaries.append( DocumentSummary( id=doc.id, trial_id=doc.trial_id, site_id=doc.site_id, doc_type=doc.doc_type, title=doc.title, scope_type=doc.scope_type, owner_id=doc.owner_id, current_effective_version_id=doc.current_effective_version_id, current_effective_version=current_version, created_at=doc.created_at, updated_at=doc.updated_at, ) ) return summaries async def get_document_detail( db: AsyncSession, document_id: uuid.UUID, current_user, ) -> DocumentDetail: doc = await document_crud.get(db, 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") 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 if doc.current_effective_version_id: version = await version_crud.get(db, doc.current_effective_version_id) if version: current_version = DocumentVersionSummary.model_validate(version) distribution_stats = await _aggregate_distribution_stats(db, doc.current_effective_version_id) owner = None if doc.owner_id: owner_obj = await user_crud.get_by_id(db, doc.owner_id) if owner_obj: owner = UserDisplay.model_validate(owner_obj) return DocumentDetail( id=doc.id, trial_id=doc.trial_id, site_id=doc.site_id, doc_type=doc.doc_type, title=doc.title, scope_type=doc.scope_type, owner_id=doc.owner_id, current_effective_version_id=doc.current_effective_version_id, current_effective_version=current_version, description=doc.description, created_at=doc.created_at, updated_at=doc.updated_at, owner=owner, version_timeline=[DocumentVersionRead.model_validate(v) for v in versions], distribution_stats=distribution_stats, ) async def create_version( db: AsyncSession, document_id: uuid.UUID, *, version_no: str, version_date: str, change_summary: str | None, parent_version_id: uuid.UUID | None, file: UploadFile, current_user, ) -> DocumentVersion: doc = await document_crud.get(db, 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="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="版本号已存在") if parent_version_id: parent = await version_crud.get(db, parent_version_id) if not parent or parent.document_id != document_id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父版本不存在或不属于该文档") content = await file.read() file_hash = hashlib.sha256(content).hexdigest() dest_dir = UPLOAD_ROOT / str(document_id) dest_dir.mkdir(parents=True, exist_ok=True) unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" dest_path = dest_dir / unique_name 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.EFFECTIVE, effective_at=effective_at, file_uri=str(dest_path), file_hash=file_hash, file_size=len(content), mime_type=file.content_type, change_summary=change_summary, 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, entity_type="DOCUMENT_VERSION", entity_id=version.id, action="VERSION_CREATED", detail=_audit_detail(None, _version_snapshot(version)), operator_id=current_user.id, 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 delete_version( db: AsyncSession, version_id: uuid.UUID, current_user, ) -> 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="delete_document") 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) 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_DELETED", detail=_audit_detail(before, None), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() file_path = Path(version.file_uri) if file_path.exists(): try: file_path.unlink() except OSError: pass async def delete_document( db: AsyncSession, document_id: uuid.UUID, current_user, ) -> Document: doc = await document_crud.get(db, 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="delete_document") if doc.status == DocumentStatus.ARCHIVED: return doc before = _doc_snapshot(doc) doc.status = DocumentStatus.ARCHIVED db.add(doc) db.add( AuditLog( study_id=doc.trial_id, entity_type="DOCUMENT", entity_id=doc.id, action="DOCUMENT_ARCHIVED", detail=_audit_detail(before, _doc_snapshot(doc)), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(doc) return doc async def get_version_download_response( db: AsyncSession, version_id: uuid.UUID, current_user, ) -> FileResponse: 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="view") file_path = Path(version.file_uri) if not file_path.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在") filename = file_path.name return FileResponse( path=str(file_path), filename=filename, media_type=version.mime_type or "application/octet-stream", headers={"Content-Disposition": f'inline; filename="{filename}"'}, ) async def create_distributions( db: AsyncSession, version_id: uuid.UUID, payload: DistributionCreate, current_user, ) -> list[Distribution]: 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_member(db, doc.trial_id, current_user) if version.status != DocumentVersionStatus.EFFECTIVE: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="只有生效版本可分发") created: list[Distribution] = [] for target in payload.targets: distribution = Distribution( document_id=version.document_id, version_id=version.id, target_type=target.target_type, target_id=target.target_id, due_at=payload.due_at, created_by=current_user.id, ) db.add(distribution) created.append(distribution) db.add( AuditLog( study_id=doc.trial_id, entity_type="DISTRIBUTION", entity_id=distribution.id, action="DISTRIBUTION_CREATED", detail=_audit_detail(None, {"version_id": str(version.id), "target_id": target.target_id}), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() for distribution in created: await db.refresh(distribution) return created async def list_distributions( db: AsyncSession, version_id: uuid.UUID, current_user, ) -> list[DistributionRead]: 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_member(db, doc.trial_id, current_user) distributions = await distribution_crud.list_by_version(db, version_id) result: list[DistributionRead] = [] for distribution in distributions: stats = await _stats_for_distribution(db, distribution.id, distribution.due_at) result.append( DistributionRead( id=distribution.id, document_id=distribution.document_id, version_id=distribution.version_id, target_type=distribution.target_type, target_id=distribution.target_id, status=distribution.status, due_at=distribution.due_at, created_by=distribution.created_by, created_at=distribution.created_at, stats=stats, ) ) return result async def create_acknowledgement( db: AsyncSession, distribution_id: uuid.UUID, payload: AcknowledgementCreate, current_user, ) -> Acknowledgement: distribution = await distribution_crud.get(db, distribution_id) if not distribution: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分发不存在") version = await version_crud.get(db, distribution.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="文档不存在") membership = await _ensure_study_access(db, doc.trial_id, current_user, action="ack") if distribution.target_type == DistributionTargetType.USER and distribution.target_id != str(current_user.id): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") if distribution.target_type == DistributionTargetType.ROLE: role_value = _role_value(current_user) if distribution.target_id not in (role_value, getattr(membership, "role_in_study", "")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") if payload.ack_type != AcknowledgementType.RECEIVED: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执") existing = await acknowledgement_crud.get_existing( db, distribution_id=distribution_id, user_id=current_user.id, ack_type=payload.ack_type, ) if existing: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="回执已存在") ack = Acknowledgement( distribution_id=distribution_id, user_id=current_user.id, site_id=None, ack_type=payload.ack_type, due_at=distribution.due_at, acked_at=datetime.now(timezone.utc), note=payload.evidence, ) db.add(ack) db.add( AuditLog( study_id=doc.trial_id, entity_type="ACKNOWLEDGEMENT", entity_id=ack.id, action="ACK_CREATED", detail=_audit_detail(None, {"distribution_id": str(distribution.id), "ack_type": payload.ack_type}), operator_id=current_user.id, operator_role=_role_value(current_user), ) ) await db.commit() await db.refresh(ack) return ack async def _stats_for_distribution( db: AsyncSession, distribution_id: uuid.UUID, due_at: datetime | None, ) -> DistributionStats: acks = await acknowledgement_crud.list_by_distribution(db, distribution_id) return _aggregate_ack_stats(acks, due_at, target_count=1) async def _aggregate_distribution_stats( db: AsyncSession, version_id: uuid.UUID | None, ) -> DistributionStats: if not version_id: return DistributionStats() distributions = await distribution_crud.list_by_version(db, version_id) stats = DistributionStats() for distribution in distributions: acks = await acknowledgement_crud.list_by_distribution(db, distribution.id) dist_stats = _aggregate_ack_stats(acks, distribution.due_at, target_count=1) stats.total += dist_stats.total stats.received += dist_stats.received # only RECEIVED is supported stats.overdue += dist_stats.overdue return stats def _aggregate_ack_stats( acks: Iterable[Acknowledgement], due_at: datetime | None, target_count: int, ) -> DistributionStats: stats = DistributionStats() stats.total = target_count for ack in acks: if ack.ack_type == AcknowledgementType.RECEIVED: stats.received += 1 if due_at: now = datetime.now(timezone.utc) 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