新增电子试验主文件目录与文件归档
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
@@ -16,6 +18,7 @@ from app.core.project_permissions import role_has_api_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 etmf as etmf_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
|
||||
@@ -58,6 +61,7 @@ def _doc_snapshot(doc: Document) -> dict:
|
||||
"id": str(doc.id),
|
||||
"trial_id": str(doc.trial_id),
|
||||
"site_id": str(doc.site_id) if doc.site_id else None,
|
||||
"etmf_node_id": str(doc.etmf_node_id) if doc.etmf_node_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,
|
||||
@@ -108,9 +112,20 @@ async def create_document(
|
||||
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:
|
||||
etmf_node = None
|
||||
if doc_in.etmf_node_id:
|
||||
etmf_node = await etmf_crud.get_node(db, doc_in.etmf_node_id)
|
||||
if not etmf_node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
|
||||
if etmf_node.study_id != doc_in.trial_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="eTMF目录不属于当前项目")
|
||||
if etmf_node.scope_type == DocumentScopeType.SITE and not doc_in.site_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="中心级eTMF目录需指定中心")
|
||||
|
||||
scope_type = etmf_node.scope_type if etmf_node else doc_in.scope_type
|
||||
if 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
|
||||
site_id = doc_in.site_id if 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:
|
||||
@@ -122,10 +137,11 @@ async def create_document(
|
||||
doc = Document(
|
||||
trial_id=doc_in.trial_id,
|
||||
site_id=site_id,
|
||||
etmf_node_id=doc_in.etmf_node_id,
|
||||
doc_no=doc_in.doc_no,
|
||||
doc_type=doc_in.doc_type,
|
||||
title=doc_in.title,
|
||||
scope_type=doc_in.scope_type,
|
||||
scope_type=scope_type,
|
||||
owner_id=doc_in.owner_id,
|
||||
description=doc_in.description,
|
||||
)
|
||||
@@ -154,6 +170,7 @@ async def list_documents(
|
||||
doc_type: str | None,
|
||||
status: str | None,
|
||||
scope_type: str | None,
|
||||
etmf_node_id: uuid.UUID | None = None,
|
||||
skip: int,
|
||||
limit: int,
|
||||
current_user,
|
||||
@@ -168,6 +185,7 @@ async def list_documents(
|
||||
doc_type=doc_type,
|
||||
status=status,
|
||||
scope_type=scope_type,
|
||||
etmf_node_id=etmf_node_id,
|
||||
cra_site_ids=cra_site_ids,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
@@ -185,6 +203,7 @@ async def list_documents(
|
||||
id=doc.id,
|
||||
trial_id=doc.trial_id,
|
||||
site_id=doc.site_id,
|
||||
etmf_node_id=doc.etmf_node_id,
|
||||
doc_type=doc.doc_type,
|
||||
title=doc.title,
|
||||
scope_type=doc.scope_type,
|
||||
@@ -228,6 +247,7 @@ async def get_document_detail(
|
||||
id=doc.id,
|
||||
trial_id=doc.trial_id,
|
||||
site_id=doc.site_id,
|
||||
etmf_node_id=doc.etmf_node_id,
|
||||
doc_type=doc.doc_type,
|
||||
title=doc.title,
|
||||
scope_type=doc.scope_type,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from typing import Iterable
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import document as document_crud
|
||||
from app.crud import etmf as etmf_crud
|
||||
from app.models.document import Document, DocumentStatus
|
||||
from app.models.etmf import EtmfNode
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.schemas.document import DocumentCreate, DocumentSummary
|
||||
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeStatus, EtmfTreeNode, EtmfNodeUpdate
|
||||
|
||||
|
||||
def _role_value(user) -> str:
|
||||
if user is None:
|
||||
return "SYSTEM"
|
||||
return user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
|
||||
|
||||
def calculate_node_status(node: EtmfNode, documents: Iterable[Document]) -> EtmfNodeStatus:
|
||||
docs = list(documents)
|
||||
if not node.is_active:
|
||||
return EtmfNodeStatus.INACTIVE
|
||||
if any(doc.current_effective_version_id for doc in docs):
|
||||
return EtmfNodeStatus.EFFECTIVE
|
||||
if docs:
|
||||
return EtmfNodeStatus.UPLOADED
|
||||
if node.required:
|
||||
return EtmfNodeStatus.MISSING
|
||||
return EtmfNodeStatus.NOT_REQUIRED
|
||||
|
||||
|
||||
def _tree_node(node: EtmfNode, documents: list[Document], children: list[EtmfTreeNode]) -> EtmfTreeNode:
|
||||
return EtmfTreeNode(
|
||||
id=node.id,
|
||||
study_id=node.study_id,
|
||||
parent_id=node.parent_id,
|
||||
code=node.code,
|
||||
name=node.name,
|
||||
description=node.description,
|
||||
scope_type=node.scope_type,
|
||||
required=node.required,
|
||||
expected_doc_type=node.expected_doc_type,
|
||||
sort_order=node.sort_order,
|
||||
is_active=node.is_active,
|
||||
created_at=node.created_at,
|
||||
updated_at=node.updated_at,
|
||||
status=calculate_node_status(node, documents),
|
||||
document_count=len(documents),
|
||||
effective_document_count=sum(1 for doc in documents if doc.current_effective_version_id),
|
||||
children=children,
|
||||
)
|
||||
|
||||
|
||||
async def _documents_by_node(db: AsyncSession, study_id: uuid.UUID) -> dict[uuid.UUID, list[Document]]:
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.trial_id == study_id,
|
||||
Document.etmf_node_id.is_not(None),
|
||||
Document.status != DocumentStatus.ARCHIVED,
|
||||
)
|
||||
)
|
||||
grouped: dict[uuid.UUID, list[Document]] = defaultdict(list)
|
||||
for document in result.scalars().all():
|
||||
if document.etmf_node_id:
|
||||
grouped[document.etmf_node_id].append(document)
|
||||
return grouped
|
||||
|
||||
|
||||
async def list_etmf_tree(db: AsyncSession, *, study_id: uuid.UUID, current_user) -> list[EtmfTreeNode]:
|
||||
if current_user is not None:
|
||||
from app.services import document_service
|
||||
|
||||
await document_service._ensure_study_access(db, study_id, current_user, action="view")
|
||||
nodes = list(await etmf_crud.list_nodes_by_study(db, study_id))
|
||||
documents = await _documents_by_node(db, study_id)
|
||||
children_by_parent: dict[uuid.UUID | None, list[EtmfNode]] = defaultdict(list)
|
||||
for node in nodes:
|
||||
children_by_parent[node.parent_id].append(node)
|
||||
|
||||
def build(node: EtmfNode) -> EtmfTreeNode:
|
||||
children = [build(child) for child in children_by_parent.get(node.id, [])]
|
||||
return _tree_node(node, documents.get(node.id, []), children)
|
||||
|
||||
return [build(node) for node in children_by_parent.get(None, [])]
|
||||
|
||||
|
||||
async def create_node(db: AsyncSession, payload: EtmfNodeCreate, current_user) -> EtmfNode:
|
||||
from app.services import document_service
|
||||
|
||||
await document_service._ensure_study_access(db, payload.study_id, current_user, action="create_document")
|
||||
if payload.parent_id:
|
||||
parent = await etmf_crud.get_node(db, payload.parent_id)
|
||||
if not parent or parent.study_id != payload.study_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不存在或不属于当前项目")
|
||||
node = EtmfNode(**payload.model_dump())
|
||||
db.add(node)
|
||||
db.add(
|
||||
AuditLog(
|
||||
study_id=payload.study_id,
|
||||
entity_type="ETMF_NODE",
|
||||
entity_id=node.id,
|
||||
action="ETMF_NODE_CREATED",
|
||||
detail=f'{{"code":"{node.code}","name":"{node.name}"}}',
|
||||
operator_id=current_user.id,
|
||||
operator_role=_role_value(current_user),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
return node
|
||||
|
||||
|
||||
async def update_node(db: AsyncSession, node_id: uuid.UUID, payload: EtmfNodeUpdate, current_user) -> EtmfNode:
|
||||
from app.services import document_service
|
||||
|
||||
node = await etmf_crud.get_node(db, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
|
||||
await document_service._ensure_study_access(db, node.study_id, current_user, action="create_version")
|
||||
values = payload.model_dump(exclude_unset=True)
|
||||
if "parent_id" in values and values["parent_id"]:
|
||||
parent = await etmf_crud.get_node(db, values["parent_id"])
|
||||
if not parent or parent.study_id != node.study_id or parent.id == node.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="父级eTMF目录不合法")
|
||||
await etmf_crud.update_node(db, node_id, values, commit=False)
|
||||
db.add(
|
||||
AuditLog(
|
||||
study_id=node.study_id,
|
||||
entity_type="ETMF_NODE",
|
||||
entity_id=node.id,
|
||||
action="ETMF_NODE_UPDATED",
|
||||
detail="{}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=_role_value(current_user),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
refreshed = await etmf_crud.get_node(db, node_id)
|
||||
if not refreshed:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
|
||||
return refreshed
|
||||
|
||||
|
||||
async def list_node_documents(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
node_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None,
|
||||
current_user,
|
||||
) -> list[DocumentSummary]:
|
||||
from app.services import document_service
|
||||
|
||||
node = await etmf_crud.get_node(db, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
|
||||
return await document_service.list_documents(
|
||||
db,
|
||||
trial_id=node.study_id,
|
||||
site_id=site_id,
|
||||
doc_type=None,
|
||||
status=None,
|
||||
scope_type=None,
|
||||
etmf_node_id=node_id,
|
||||
skip=0,
|
||||
limit=500,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
async def create_node_document(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
node_id: uuid.UUID,
|
||||
payload: DocumentCreate,
|
||||
current_user,
|
||||
) -> DocumentSummary:
|
||||
from app.services import document_service
|
||||
|
||||
node = await etmf_crud.get_node(db, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="eTMF目录不存在")
|
||||
doc_payload = payload.model_copy(update={"trial_id": node.study_id, "etmf_node_id": node.id, "scope_type": node.scope_type})
|
||||
document = await document_service.create_document(db, doc_payload, current_user)
|
||||
return DocumentSummary.model_validate(document)
|
||||
Reference in New Issue
Block a user