939fc70532
新增项目级角色权限矩阵,覆盖 PM、CRA、PV、IMP、QA 等项目角色,并通过迁移表持久化各业务模块的读写权限。 后端将参与者、合同费用、物资、启动、里程碑、风险问题、监查稽查、FAQ、共享库、文件版本等接口接入矩阵校验,修复审计日志导出权限和登录 502 的依赖导入问题。 前端新增权限管理页面和项目路由权限映射,按矩阵控制导航显示、直接访问拦截、操作按钮启停,并限制管理后台仅 admin 和授权 PM 可进入。 补充后端权限归一化与接口静态测试、前端路由/权限工具/权限管理页面/工作台等回归测试。
725 lines
26 KiB
Python
725 lines
26 KiB
Python
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
|