新增电子试验主文件目录与文件归档

This commit is contained in:
Cheng Zhou
2026-05-28 10:54:13 +08:00
parent 31fcb7e6f2
commit fa961f1391
20 changed files with 2226 additions and 10 deletions
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.schemas.document import DocumentCreate, DocumentSummary
from app.schemas.etmf import EtmfNodeCreate, EtmfNodeRead, EtmfNodeUpdate, EtmfTreeNode
from app.services import etmf_service
router = APIRouter()
@router.get("/tree", response_model=list[EtmfTreeNode])
async def get_etmf_tree(
study_id: uuid.UUID = Query(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[EtmfTreeNode]:
return await etmf_service.list_etmf_tree(db, study_id=study_id, current_user=current_user)
@router.post("/nodes", response_model=EtmfNodeRead, status_code=status.HTTP_201_CREATED)
async def create_etmf_node(
payload: EtmfNodeCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> EtmfNodeRead:
node = await etmf_service.create_node(db, payload, current_user)
return EtmfNodeRead.model_validate(node)
@router.patch("/nodes/{node_id}", response_model=EtmfNodeRead)
async def update_etmf_node(
node_id: uuid.UUID,
payload: EtmfNodeUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> EtmfNodeRead:
node = await etmf_service.update_node(db, node_id, payload, current_user)
return EtmfNodeRead.model_validate(node)
@router.get("/nodes/{node_id}/documents", response_model=list[DocumentSummary])
async def list_etmf_node_documents(
node_id: uuid.UUID,
site_id: uuid.UUID | None = Query(None),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DocumentSummary]:
return await etmf_service.list_node_documents(db, node_id=node_id, site_id=site_id, current_user=current_user)
@router.post("/nodes/{node_id}/documents", response_model=DocumentSummary, status_code=status.HTTP_201_CREATED)
async def create_etmf_node_document(
node_id: uuid.UUID,
payload: DocumentCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
return await etmf_service.create_node_document(db, node_id=node_id, payload=payload, current_user=current_user)
+5
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import uuid
from typing import Sequence
@@ -33,6 +35,7 @@ async def list_documents(
doc_type: str | None = None,
status: str | None = None,
scope_type: str | None = None,
etmf_node_id: uuid.UUID | None = None,
cra_site_ids: set[uuid.UUID] | None = None,
skip: int = 0,
limit: int = 100,
@@ -51,6 +54,8 @@ async def list_documents(
stmt = stmt.where(Document.status == status)
if site_id:
stmt = stmt.where(Document.site_id == site_id)
if etmf_node_id:
stmt = stmt.where(Document.etmf_node_id == etmf_node_id)
stmt = stmt.order_by(Document.updated_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.etmf import EtmfNode
async def get_node(db: AsyncSession, node_id: uuid.UUID) -> EtmfNode | None:
result = await db.execute(select(EtmfNode).where(EtmfNode.id == node_id))
return result.scalar_one_or_none()
async def list_nodes_by_study(db: AsyncSession, study_id: uuid.UUID, *, include_inactive: bool = True) -> Sequence[EtmfNode]:
stmt = select(EtmfNode).where(EtmfNode.study_id == study_id)
if not include_inactive:
stmt = stmt.where(EtmfNode.is_active.is_(True))
stmt = stmt.order_by(EtmfNode.sort_order.asc(), EtmfNode.code.asc(), EtmfNode.name.asc())
result = await db.execute(stmt)
return result.scalars().all()
async def create_node(db: AsyncSession, node: EtmfNode, *, commit: bool = True) -> EtmfNode:
db.add(node)
if commit:
await db.commit()
await db.refresh(node)
return node
async def update_node(db: AsyncSession, node_id: uuid.UUID, values: dict, *, commit: bool = True) -> None:
if values:
await db.execute(sa_update(EtmfNode).where(EtmfNode.id == node_id).values(**values))
if commit:
await db.commit()
+1
View File
@@ -31,6 +31,7 @@ class Document(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
trial_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
site_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
etmf_node_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("etmf_nodes.id"), nullable=True)
doc_no: Mapped[str] = mapped_column(String(50), nullable=False)
doc_type: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(200), nullable=False)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
from app.models.document import DocumentScopeType
class EtmfNode(Base):
__tablename__ = "etmf_nodes"
__table_args__ = (
UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False)
parent_id: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("etmf_nodes.id"), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
scope_type: Mapped[DocumentScopeType] = mapped_column(
Enum(DocumentScopeType, name="document_scope_type"),
nullable=False,
server_default=DocumentScopeType.GLOBAL.value,
)
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
expected_doc_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
parent = relationship("EtmfNode", remote_side=[id], foreign_keys=[parent_id])
+3
View File
@@ -25,6 +25,7 @@ class DocumentStatus(str, enum.Enum):
class DocumentCreate(BaseModel):
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
etmf_node_id: Optional[uuid.UUID] = None
doc_no: str
doc_type: str
title: str
@@ -39,6 +40,7 @@ class DocumentUpdate(BaseModel):
scope_type: Optional[DocumentScopeType] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
etmf_node_id: Optional[uuid.UUID] = None
status: Optional[DocumentStatus] = None
description: Optional[str] = None
@@ -47,6 +49,7 @@ class DocumentSummary(BaseModel):
id: uuid.UUID
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
etmf_node_id: Optional[uuid.UUID] = None
doc_type: str
title: str
scope_type: DocumentScopeType
+72
View File
@@ -0,0 +1,72 @@
import enum
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.document import DocumentScopeType, DocumentSummary
class EtmfNodeStatus(str, enum.Enum):
NOT_REQUIRED = "NOT_REQUIRED"
MISSING = "MISSING"
UPLOADED = "UPLOADED"
EFFECTIVE = "EFFECTIVE"
INACTIVE = "INACTIVE"
class EtmfNodeBase(BaseModel):
parent_id: Optional[uuid.UUID] = None
code: str
name: str
description: Optional[str] = None
scope_type: DocumentScopeType = DocumentScopeType.GLOBAL
required: bool = False
expected_doc_type: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class EtmfNodeCreate(EtmfNodeBase):
study_id: uuid.UUID
class EtmfNodeUpdate(BaseModel):
parent_id: Optional[uuid.UUID] = None
code: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
scope_type: Optional[DocumentScopeType] = None
required: Optional[bool] = None
expected_doc_type: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class EtmfNodeRead(EtmfNodeBase):
id: uuid.UUID
study_id: uuid.UUID
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class EtmfNodeStatusSummary(BaseModel):
status: EtmfNodeStatus
document_count: int = 0
effective_document_count: int = 0
missing_count: int = 0
class EtmfTreeNode(EtmfNodeRead):
status: EtmfNodeStatus = EtmfNodeStatus.NOT_REQUIRED
document_count: int = 0
effective_document_count: int = 0
children: list["EtmfTreeNode"] = Field(default_factory=list)
class EtmfNodeDocuments(BaseModel):
node: EtmfNodeRead
documents: list[DocumentSummary] = Field(default_factory=list)
+23 -3
View File
@@ -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,
+191
View File
@@ -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)