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

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
@@ -0,0 +1,88 @@
"""add etmf nodes
Revision ID: 20260527_09
Revises: 20260527_08_precautions
Create Date: 2026-05-27 20:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260527_09"
down_revision: Union[str, None] = "20260527_08_precautions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
return column_name in {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
uuid_type = postgresql.UUID(as_uuid=True)
scope_type = postgresql.ENUM(
"GLOBAL",
"SITE",
"DERIVED",
name="document_scope_type",
create_type=False,
)
if not _table_exists(inspector, "etmf_nodes"):
op.create_table(
"etmf_nodes",
sa.Column("id", uuid_type, nullable=False),
sa.Column("study_id", uuid_type, nullable=False),
sa.Column("parent_id", uuid_type, nullable=True),
sa.Column("code", sa.String(length=50), nullable=False),
sa.Column("name", sa.String(length=200), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_type", scope_type, server_default="GLOBAL", nullable=False),
sa.Column("required", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("expected_doc_type", sa.String(length=50), nullable=True),
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["parent_id"], ["etmf_nodes.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("study_id", "parent_id", "code", name="uq_etmf_nodes_study_parent_code"),
)
op.create_index("ix_etmf_nodes_study_id", "etmf_nodes", ["study_id"])
op.create_index("ix_etmf_nodes_parent_id", "etmf_nodes", ["parent_id"])
op.create_index("ix_etmf_nodes_scope_type", "etmf_nodes", ["scope_type"])
op.create_index("ix_etmf_nodes_is_active", "etmf_nodes", ["is_active"])
if _table_exists(inspector, "documents") and not _column_exists(inspector, "documents", "etmf_node_id"):
op.add_column("documents", sa.Column("etmf_node_id", uuid_type, nullable=True))
op.create_index("ix_documents_etmf_node_id", "documents", ["etmf_node_id"])
op.create_foreign_key("fk_documents_etmf_node_id", "documents", "etmf_nodes", ["etmf_node_id"], ["id"])
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "documents") and _column_exists(inspector, "documents", "etmf_node_id"):
op.drop_constraint("fk_documents_etmf_node_id", "documents", type_="foreignkey")
op.drop_index("ix_documents_etmf_node_id", table_name="documents")
op.drop_column("documents", "etmf_node_id")
if _table_exists(inspector, "etmf_nodes"):
op.drop_index("ix_etmf_nodes_is_active", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_scope_type", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_parent_id", table_name="etmf_nodes")
op.drop_index("ix_etmf_nodes_study_id", table_name="etmf_nodes")
op.drop_table("etmf_nodes")
+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)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import uuid
from types import SimpleNamespace
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.document import Document, DocumentScopeType
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.etmf import EtmfNode
from app.services.etmf_service import calculate_node_status, list_etmf_tree
def test_required_node_without_documents_is_missing() -> None:
node = SimpleNamespace(is_active=True, required=True)
assert calculate_node_status(node, []) == "MISSING"
def test_node_with_document_without_effective_version_is_uploaded() -> None:
node = SimpleNamespace(is_active=True, required=True)
document = SimpleNamespace(current_effective_version_id=None)
assert calculate_node_status(node, [document]) == "UPLOADED"
def test_node_with_current_effective_version_is_effective() -> None:
node = SimpleNamespace(is_active=True, required=True)
document = SimpleNamespace(current_effective_version_id=uuid.uuid4())
assert calculate_node_status(node, [document]) == "EFFECTIVE"
@pytest.mark.asyncio
async def test_etmf_tree_returns_hierarchy_and_status(db_session: AsyncSession) -> None:
study_id = uuid.uuid4()
parent = EtmfNode(
id=uuid.uuid4(),
study_id=study_id,
code="01",
name="项目管理",
scope_type=DocumentScopeType.GLOBAL,
required=False,
sort_order=1,
)
child = EtmfNode(
id=uuid.uuid4(),
study_id=study_id,
parent_id=parent.id,
code="01.01",
name="方案与版本",
scope_type=DocumentScopeType.GLOBAL,
required=True,
sort_order=1,
)
db_session.add_all([parent, child])
await db_session.commit()
tree = await list_etmf_tree(db_session, study_id=study_id, current_user=None)
assert len(tree) == 1
assert tree[0].code == "01"
assert tree[0].children[0].code == "01.01"
assert tree[0].children[0].status == "MISSING"
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import uuid
import pytest
from fastapi import HTTPException
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base_class import Base
from app.models.document import Document, DocumentScopeType
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.user import UserRole, UserStatus
class UserStub:
def __init__(self) -> None:
self.id = uuid.uuid4()
self.role = UserRole.ADMIN
self.status = UserStatus.ACTIVE
def test_etmf_node_table_is_registered() -> None:
import app.db.base # noqa: F401
assert "etmf_nodes" in Base.metadata.tables
@pytest.mark.asyncio
async def test_document_create_persists_etmf_node_id(db_session: AsyncSession) -> None:
from app.models.etmf import EtmfNode
from app.schemas.document import DocumentCreate
from app.services import document_service
study_id = (
await db_session.execute(text("SELECT id FROM studies ORDER BY created_at DESC LIMIT 1"))
).scalar_one()
node = EtmfNode(
study_id=study_id,
code="01.01",
name="方案与版本",
scope_type=DocumentScopeType.GLOBAL,
required=True,
sort_order=1,
)
db_session.add(node)
await db_session.commit()
await db_session.refresh(node)
doc = await document_service.create_document(
db_session,
DocumentCreate(
trial_id=study_id,
doc_no="PROTOCOL-001",
doc_type="Protocol",
title="研究方案",
scope_type=DocumentScopeType.GLOBAL,
etmf_node_id=node.id,
),
UserStub(),
)
assert doc.etmf_node_id == node.id
stored = (
await db_session.execute(select(Document).where(Document.id == doc.id))
).scalar_one()
assert stored.etmf_node_id == node.id
@pytest.mark.asyncio
async def test_site_scoped_etmf_node_requires_site_document(db_session: AsyncSession) -> None:
from app.models.etmf import EtmfNode
from app.schemas.document import DocumentCreate
from app.services import document_service
study_id = (
await db_session.execute(text("SELECT id FROM studies ORDER BY created_at DESC LIMIT 1"))
).scalar_one()
node = EtmfNode(
study_id=study_id,
code="03.01",
name="中心启动文件",
scope_type=DocumentScopeType.SITE,
required=True,
sort_order=1,
)
db_session.add(node)
await db_session.commit()
await db_session.refresh(node)
with pytest.raises(HTTPException) as exc_info:
await document_service.create_document(
db_session,
DocumentCreate(
trial_id=study_id,
doc_no="SITE-STARTUP-001",
doc_type="Startup",
title="中心启动文件",
scope_type=DocumentScopeType.GLOBAL,
etmf_node_id=node.id,
),
UserStub(),
)
assert exc_info.value.status_code == 422
assert "中心" in str(exc_info.value.detail)