新增电子试验主文件目录与文件归档
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user