文件版本管理-初步优化

This commit is contained in:
Cheng Zhou
2026-01-14 11:35:37 +08:00
parent ef1e67218c
commit f9ef5c109f
46 changed files with 4603 additions and 30 deletions
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
+67
View File
@@ -0,0 +1,67 @@
from __future__ import with_statement
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from app.core.config import settings
from app.db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_database_url() -> str:
return settings.DATABASE_URL
def run_migrations_offline() -> None:
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_database_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+1
View File
@@ -0,0 +1 @@
+214
View File
@@ -0,0 +1,214 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session
from app.crud import document_version as version_crud
from app.schemas.acknowledgement import AcknowledgementCreate, AcknowledgementRead
from app.schemas.common import PaginatedResponse
from app.schemas.distribution import DistributionCreate, DistributionRead
from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, VersionActionRequest, VersionSubmitRequest
from app.schemas.workflow_template import WorkflowTemplateRead
from app.services import document_service
from app.utils.pagination import paginate
router = APIRouter()
@router.post(
"/documents",
response_model=DocumentSummary,
status_code=status.HTTP_201_CREATED,
)
async def create_document(
payload: DocumentCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
doc = await document_service.create_document(db, payload, current_user)
return DocumentSummary.model_validate(doc)
@router.get("/documents", response_model=PaginatedResponse[DocumentSummary])
async def list_documents(
trial_id: uuid.UUID = Query(...),
site_id: Optional[uuid.UUID] = Query(None),
doc_type: Optional[str] = Query(None),
scope_type: Optional[str] = Query(None),
status_value: Optional[str] = Query(None, alias="status"),
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> PaginatedResponse[DocumentSummary]:
items = await document_service.list_documents(
db,
trial_id=trial_id,
site_id=site_id,
doc_type=doc_type,
status=status_value,
scope_type=scope_type,
skip=skip,
limit=limit,
current_user=current_user,
)
return paginate(items, total=len(items))
@router.get("/documents/{document_id}", response_model=DocumentDetail)
async def get_document_detail(
document_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentDetail:
return await document_service.get_document_detail(db, document_id, current_user)
@router.delete("/documents/{document_id}", response_model=DocumentSummary)
async def delete_document(
document_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentSummary:
doc = await document_service.delete_document(db, document_id, current_user)
return DocumentSummary.model_validate(doc)
@router.post(
"/documents/{document_id}/versions",
response_model=DocumentVersionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_version(
document_id: uuid.UUID,
version_no: str = Form(...),
change_summary: Optional[str] = Form(None),
parent_version_id: Optional[uuid.UUID] = Form(None),
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
version = await document_service.create_version(
db,
document_id,
version_no=version_no,
change_summary=change_summary,
parent_version_id=parent_version_id,
file=file,
current_user=current_user,
)
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/submit", response_model=DocumentVersionRead)
async def submit_version(
version_id: uuid.UUID,
payload: VersionSubmitRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.submit_version(db, version_id, payload.template_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/approve", response_model=DocumentVersionRead)
async def approve_version(
version_id: uuid.UUID,
payload: VersionActionRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.approve_version(db, version_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/reject", response_model=DocumentVersionRead)
async def reject_version(
version_id: uuid.UUID,
payload: VersionActionRequest,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
await document_service.reject_version(db, version_id, payload.comment, current_user)
version = await version_crud.get(db, version_id)
if not version:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="版本不存在")
return DocumentVersionRead.model_validate(version)
@router.post("/versions/{version_id}/make-effective", response_model=DocumentVersionRead)
async def make_version_effective(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> DocumentVersionRead:
version = await document_service.make_version_effective(db, version_id, current_user)
return DocumentVersionRead.model_validate(version)
@router.get("/versions/{version_id}/download", response_class=FileResponse)
async def download_version(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
):
return await document_service.get_version_download_response(db, version_id, current_user)
@router.get("/workflow-templates", response_model=list[WorkflowTemplateRead])
async def list_workflow_templates(
trial_id: uuid.UUID | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[WorkflowTemplateRead]:
templates = await document_service.list_workflow_templates(db, trial_id, current_user)
return [WorkflowTemplateRead.model_validate(t) for t in templates]
@router.post(
"/versions/{version_id}/distributions",
response_model=list[DistributionRead],
status_code=status.HTTP_201_CREATED,
)
async def create_distributions(
version_id: uuid.UUID,
payload: DistributionCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DistributionRead]:
distributions = await document_service.create_distributions(db, version_id, payload, current_user)
return [DistributionRead.model_validate(d) for d in distributions]
@router.get("/versions/{version_id}/distributions", response_model=list[DistributionRead])
async def list_distributions(
version_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[DistributionRead]:
return await document_service.list_distributions(db, version_id, current_user)
@router.post(
"/distributions/{distribution_id}/acknowledgements",
response_model=AcknowledgementRead,
status_code=status.HTTP_201_CREATED,
)
async def create_acknowledgement(
distribution_id: uuid.UUID,
payload: AcknowledgementCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> AcknowledgementRead:
ack = await document_service.create_acknowledgement(db, distribution_id, payload, current_user)
return AcknowledgementRead.model_validate(ack)
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -28,3 +28,4 @@ api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/kn
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
api_router.include_router(documents.router, prefix="", tags=["documents"])
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
@dataclass(frozen=True)
class Scope:
trial_id: str | None = None
site_id: str | None = None
doc_type: str | None = None
ROLE_ACTIONS: dict[str, set[str]] = {
"ADMIN": {
"view",
"create_document",
"create_version",
"submit",
"approve",
"distribute",
"ack",
"delete_document",
},
"QA": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack"},
"PM": {"view", "create_document", "create_version", "submit", "approve", "distribute", "ack", "delete_document"},
"CRA": {"view", "create_version", "submit", "ack"},
"PV": {"view", "create_version", "submit", "ack"},
"IMP": {"view", "create_version", "submit", "ack"},
}
def is_allowed(action: str, user, membership) -> bool:
role_value = user.role.value if hasattr(user.role, "value") else str(user.role)
allowed = ROLE_ACTIONS.get(role_value, set())
if action in allowed:
return True
member_role = getattr(membership, "role_in_study", None)
if member_role:
return action in ROLE_ACTIONS.get(member_role, set())
return False
def is_scope_allowed(action: str, scope: Scope, user, membership) -> bool:
return is_allowed(action, user, membership)
+36
View File
@@ -0,0 +1,36 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
async def create(db: AsyncSession, ack: Acknowledgement, *, commit: bool = True) -> Acknowledgement:
db.add(ack)
if commit:
await db.commit()
await db.refresh(ack)
return ack
async def get_existing(
db: AsyncSession,
distribution_id: uuid.UUID,
user_id: uuid.UUID,
ack_type: AcknowledgementType,
) -> Acknowledgement | None:
result = await db.execute(
select(Acknowledgement).where(
Acknowledgement.distribution_id == distribution_id,
Acknowledgement.user_id == user_id,
Acknowledgement.ack_type == ack_type,
)
)
return result.scalar_one_or_none()
async def list_by_distribution(db: AsyncSession, distribution_id: uuid.UUID) -> Sequence[Acknowledgement]:
result = await db.execute(select(Acknowledgement).where(Acknowledgement.distribution_id == distribution_id))
return result.scalars().all()
+25
View File
@@ -0,0 +1,25 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.distribution import Distribution
async def create(db: AsyncSession, distribution: Distribution, *, commit: bool = True) -> Distribution:
db.add(distribution)
if commit:
await db.commit()
await db.refresh(distribution)
return distribution
async def list_by_version(db: AsyncSession, version_id: uuid.UUID) -> Sequence[Distribution]:
result = await db.execute(select(Distribution).where(Distribution.version_id == version_id))
return result.scalars().all()
async def get(db: AsyncSession, distribution_id: uuid.UUID) -> Distribution | None:
result = await db.execute(select(Distribution).where(Distribution.id == distribution_id))
return result.scalar_one_or_none()
+61
View File
@@ -0,0 +1,61 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.document import Document
async def create(db: AsyncSession, doc: Document, *, commit: bool = True) -> Document:
db.add(doc)
if commit:
await db.commit()
await db.refresh(doc)
return doc
async def get(db: AsyncSession, document_id: uuid.UUID) -> Document | None:
result = await db.execute(select(Document).where(Document.id == document_id))
return result.scalar_one_or_none()
async def get_by_trial_doc_no(db: AsyncSession, trial_id: uuid.UUID, doc_no: str) -> Document | None:
result = await db.execute(select(Document).where(Document.trial_id == trial_id, Document.doc_no == doc_no))
return result.scalar_one_or_none()
async def list_documents(
db: AsyncSession,
*,
trial_id: uuid.UUID,
site_id: uuid.UUID | None = None,
doc_type: str | None = None,
status: str | None = None,
scope_type: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[Document]:
stmt = select(Document).where(Document.trial_id == trial_id)
if scope_type:
stmt = stmt.where(Document.scope_type == scope_type)
if doc_type:
stmt = stmt.where(Document.doc_type == doc_type)
if status:
stmt = stmt.where(Document.status == status)
if site_id:
stmt = stmt.where(Document.site_id == site_id)
stmt = stmt.order_by(Document.updated_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update(db: AsyncSession, document_id: uuid.UUID, values: dict, *, commit: bool = True) -> None:
if values:
await db.execute(
sa_update(Document)
.where(Document.id == document_id)
.values(**values)
)
if commit:
await db.commit()
+76
View File
@@ -0,0 +1,76 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.document_version import DocumentVersion, DocumentVersionStatus
async def create(db: AsyncSession, version: DocumentVersion, *, commit: bool = True) -> DocumentVersion:
db.add(version)
if commit:
await db.commit()
await db.refresh(version)
return version
async def get(db: AsyncSession, version_id: uuid.UUID) -> DocumentVersion | None:
result = await db.execute(select(DocumentVersion).where(DocumentVersion.id == version_id))
return result.scalar_one_or_none()
async def get_by_document_version_no(
db: AsyncSession,
document_id: uuid.UUID,
version_no: str,
) -> DocumentVersion | None:
result = await db.execute(
select(DocumentVersion).where(
DocumentVersion.document_id == document_id,
DocumentVersion.version_no == version_no,
)
)
return result.scalar_one_or_none()
async def list_by_document(
db: AsyncSession,
document_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
) -> Sequence[DocumentVersion]:
result = await db.execute(
select(DocumentVersion)
.where(DocumentVersion.document_id == document_id)
.order_by(DocumentVersion.created_at.desc())
.offset(skip)
.limit(limit)
)
return result.scalars().all()
async def list_pending_versions(db: AsyncSession, document_id: uuid.UUID) -> Sequence[DocumentVersion]:
result = await db.execute(
select(DocumentVersion).where(
DocumentVersion.document_id == document_id,
DocumentVersion.status.in_(
[
DocumentVersionStatus.SUBMITTED,
DocumentVersionStatus.APPROVED,
]
),
)
)
return result.scalars().all()
async def update(db: AsyncSession, version_id: uuid.UUID, values: dict, *, commit: bool = True) -> None:
if values:
await db.execute(
sa_update(DocumentVersion)
.where(DocumentVersion.id == version_id)
.values(**values)
)
if commit:
await db.commit()
+30
View File
@@ -0,0 +1,30 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.version_workflow import VersionWorkflow
async def create(db: AsyncSession, workflow: VersionWorkflow, *, commit: bool = True) -> VersionWorkflow:
db.add(workflow)
if commit:
await db.commit()
await db.refresh(workflow)
return workflow
async def get(db: AsyncSession, workflow_id: uuid.UUID) -> VersionWorkflow | None:
result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.id == workflow_id))
return result.scalar_one_or_none()
async def get_by_version(db: AsyncSession, version_id: uuid.UUID) -> VersionWorkflow | None:
result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.version_id == version_id))
return result.scalar_one_or_none()
async def list_by_document(db: AsyncSession, document_id: uuid.UUID) -> Sequence[VersionWorkflow]:
result = await db.execute(select(VersionWorkflow).where(VersionWorkflow.document_id == document_id))
return result.scalars().all()
+11
View File
@@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workflow_action import WorkflowAction
async def create(db: AsyncSession, action: WorkflowAction, *, commit: bool = True) -> WorkflowAction:
db.add(action)
if commit:
await db.commit()
await db.refresh(action)
return action
+26
View File
@@ -0,0 +1,26 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workflow_template import WorkflowTemplate
async def get(db: AsyncSession, template_id: uuid.UUID) -> WorkflowTemplate | None:
result = await db.execute(select(WorkflowTemplate).where(WorkflowTemplate.id == template_id))
return result.scalar_one_or_none()
async def list_active(db: AsyncSession, trial_id: uuid.UUID | None = None) -> Sequence[WorkflowTemplate]:
stmt = (
select(WorkflowTemplate)
.where(WorkflowTemplate.is_active.is_(True))
.options(selectinload(WorkflowTemplate.nodes))
)
if trial_id:
stmt = stmt.where(WorkflowTemplate.trial_id == trial_id)
result = await db.execute(stmt)
return result.scalars().all()
+8
View File
@@ -7,6 +7,14 @@ from app.models.site import Site # noqa: F401
from app.models.study_member import StudyMember # noqa: F401
from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.document import Document # noqa: F401
from app.models.document_version import DocumentVersion # noqa: F401
from app.models.workflow_template import WorkflowTemplate # noqa: F401
from app.models.workflow_node import WorkflowNode # noqa: F401
from app.models.version_workflow import VersionWorkflow # noqa: F401
from app.models.workflow_action import WorkflowAction # noqa: F401
from app.models.distribution import Distribution # noqa: F401
from app.models.acknowledgement import Acknowledgement # noqa: F401
from app.models.milestone import Milestone # noqa: F401
from app.models.subject import Subject # noqa: F401
from app.models.visit import Visit # noqa: F401
+37
View File
@@ -0,0 +1,37 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class AcknowledgementType(str, enum.Enum):
RECEIVED = "RECEIVED"
READ = "READ"
TRAINED = "TRAINED"
class Acknowledgement(Base):
__tablename__ = "acknowledgements"
__table_args__ = (
UniqueConstraint("distribution_id", "user_id", "ack_type", name="uq_ack_distribution_user_type"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
distribution_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("distributions.id"), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True)
ack_type: Mapped[AcknowledgementType] = mapped_column(
Enum(AcknowledgementType, name="acknowledgement_type"),
nullable=False,
)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
acked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
distribution = relationship("Distribution", back_populates="acknowledgements")
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy import DateTime, ForeignKey, Index, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -10,6 +10,7 @@ from app.db.base_class import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
__table_args__ = (Index("ix_audit_logs_entity_at", "entity_type", "entity_id", "created_at"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
+50
View File
@@ -0,0 +1,50 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class DistributionTargetType(str, enum.Enum):
SITE = "SITE"
ROLE = "ROLE"
USER = "USER"
class DistributionStatus(str, enum.Enum):
ACTIVE = "ACTIVE"
CLOSED = "CLOSED"
class Distribution(Base):
__tablename__ = "distributions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document_versions.id"), nullable=False)
target_type: Mapped[DistributionTargetType] = mapped_column(
Enum(DistributionTargetType, name="distribution_target_type"),
nullable=False,
)
target_id: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[DistributionStatus] = mapped_column(
Enum(DistributionStatus, name="distribution_status"),
nullable=False,
server_default=DistributionStatus.ACTIVE.value,
)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
document = relationship("Document")
version = relationship("DocumentVersion", back_populates="distributions")
acknowledgements = relationship(
"Acknowledgement",
back_populates="distribution",
cascade="all, delete-orphan",
)
+69
View File
@@ -0,0 +1,69 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, 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
class DocumentScopeType(str, enum.Enum):
GLOBAL = "GLOBAL"
SITE = "SITE"
DERIVED = "DERIVED"
class DocumentStatus(str, enum.Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
ARCHIVED = "ARCHIVED"
class Document(Base):
__tablename__ = "documents"
__table_args__ = (UniqueConstraint("trial_id", "doc_no", name="uq_documents_trial_doc_no"),)
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[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.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)
scope_type: Mapped[DocumentScopeType] = mapped_column(
Enum(DocumentScopeType, name="document_scope_type"),
nullable=False,
server_default=DocumentScopeType.GLOBAL.value,
)
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
status: Mapped[DocumentStatus] = mapped_column(
Enum(DocumentStatus, name="document_status"),
nullable=False,
server_default=DocumentStatus.ACTIVE.value,
)
current_effective_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("document_versions.id"),
nullable=True,
)
description: Mapped[str | None] = mapped_column(Text, nullable=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(),
)
versions = relationship(
"DocumentVersion",
back_populates="document",
cascade="all, delete-orphan",
foreign_keys="DocumentVersion.document_id",
)
current_effective_version = relationship(
"DocumentVersion",
foreign_keys=[current_effective_version_id],
post_update=True,
)
+61
View File
@@ -0,0 +1,61 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, 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
class DocumentVersionStatus(str, enum.Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
APPROVED = "APPROVED"
EFFECTIVE = "EFFECTIVE"
SUPERSEDED = "SUPERSEDED"
ARCHIVED = "ARCHIVED"
WITHDRAWN = "WITHDRAWN"
class DocumentVersion(Base):
__tablename__ = "document_versions"
__table_args__ = (UniqueConstraint("document_id", "version_no", name="uq_document_versions_doc_no"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
version_no: Mapped[str] = mapped_column(String(50), nullable=False)
parent_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("document_versions.id"),
nullable=True,
)
derived_from_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("document_versions.id"),
nullable=True,
)
status: Mapped[DocumentVersionStatus] = mapped_column(
Enum(DocumentVersionStatus, name="document_version_status"),
nullable=False,
server_default=DocumentVersionStatus.DRAFT.value,
)
effective_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
change_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
document = relationship("Document", back_populates="versions", foreign_keys=[document_id])
parent_version = relationship("DocumentVersion", remote_side=[id], foreign_keys=[parent_version_id])
derived_from_version = relationship("DocumentVersion", remote_side=[id], foreign_keys=[derived_from_version_id])
workflow_instance = relationship("VersionWorkflow", back_populates="version", uselist=False)
distributions = relationship("Distribution", back_populates="version")
+1
View File
@@ -15,6 +15,7 @@ class UserRole(str, enum.Enum):
CRA = "CRA"
PV = "PV"
IMP = "IMP"
QA = "QA"
class UserStatus(str, enum.Enum):
+47
View File
@@ -0,0 +1,47 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowStatus(str, enum.Enum):
PENDING = "PENDING"
APPROVED = "APPROVED"
REJECTED = "REJECTED"
CANCELED = "CANCELED"
class VersionWorkflow(Base):
__tablename__ = "version_workflows"
__table_args__ = (UniqueConstraint("version_id", name="uq_version_workflow_version"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
document_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("documents.id"), nullable=False)
version_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("document_versions.id"), nullable=False)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workflow_templates.id"), nullable=False)
status: Mapped[WorkflowStatus] = mapped_column(
Enum(WorkflowStatus, name="workflow_status"),
nullable=False,
server_default=WorkflowStatus.PENDING.value,
)
current_node: Mapped[int | None] = mapped_column(Integer, nullable=True)
submitted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=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(),
)
template = relationship("WorkflowTemplate")
version = relationship("DocumentVersion", back_populates="workflow_instance")
document = relationship("Document")
actions = relationship("WorkflowAction", back_populates="workflow", cascade="all, delete-orphan")
+32
View File
@@ -0,0 +1,32 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowActionType(str, enum.Enum):
SUBMIT = "SUBMIT"
APPROVE = "APPROVE"
REJECT = "REJECT"
class WorkflowAction(Base):
__tablename__ = "workflow_actions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
workflow_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("version_workflows.id"), nullable=False)
node_order: Mapped[int | None] = mapped_column(Integer, nullable=True)
action: Mapped[WorkflowActionType] = mapped_column(
Enum(WorkflowActionType, name="workflow_action_type"),
nullable=False,
)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
acted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
workflow = relationship("VersionWorkflow", back_populates="actions")
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowNode(Base):
__tablename__ = "workflow_nodes"
__table_args__ = (UniqueConstraint("template_id", "node_order", name="uq_workflow_nodes_order"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("workflow_templates.id"), nullable=False)
node_order: Mapped[int] = mapped_column(Integer, nullable=False)
role: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
template = relationship("WorkflowTemplate", back_populates="nodes")
+33
View File
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base_class import Base
class WorkflowTemplate(Base):
__tablename__ = "workflow_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
trial_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=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(),
)
nodes = relationship(
"WorkflowNode",
back_populates="template",
cascade="all, delete-orphan",
order_by="WorkflowNode.node_order",
)
+29
View File
@@ -0,0 +1,29 @@
import enum
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class AcknowledgementType(str, enum.Enum):
RECEIVED = "RECEIVED"
class AcknowledgementCreate(BaseModel):
ack_type: AcknowledgementType
evidence: Optional[str] = None
class AcknowledgementRead(BaseModel):
id: uuid.UUID
distribution_id: uuid.UUID
user_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
ack_type: AcknowledgementType
due_at: Optional[datetime] = None
acked_at: Optional[datetime] = None
note: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+50
View File
@@ -0,0 +1,50 @@
import enum
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class DistributionTargetType(str, enum.Enum):
SITE = "SITE"
ROLE = "ROLE"
USER = "USER"
class DistributionStatus(str, enum.Enum):
ACTIVE = "ACTIVE"
CLOSED = "CLOSED"
class DistributionTarget(BaseModel):
target_type: DistributionTargetType
target_id: str
class DistributionCreate(BaseModel):
targets: list[DistributionTarget]
due_at: Optional[datetime] = None
class DistributionStats(BaseModel):
total: int = 0
received: int = 0
read: int = 0
trained: int = 0
overdue: int = 0
class DistributionRead(BaseModel):
id: uuid.UUID
document_id: uuid.UUID
version_id: uuid.UUID
target_type: DistributionTargetType
target_id: str
status: DistributionStatus
due_at: Optional[datetime] = None
created_by: Optional[uuid.UUID] = None
created_at: datetime
stats: DistributionStats = Field(default_factory=DistributionStats)
model_config = ConfigDict(from_attributes=True)
+70
View File
@@ -0,0 +1,70 @@
import enum
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.distribution import DistributionStats
from app.schemas.user import UserDisplay
class DocumentScopeType(str, enum.Enum):
GLOBAL = "GLOBAL"
SITE = "SITE"
DERIVED = "DERIVED"
class DocumentStatus(str, enum.Enum):
DRAFT = "DRAFT"
ACTIVE = "ACTIVE"
ARCHIVED = "ARCHIVED"
class DocumentCreate(BaseModel):
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
doc_no: str
doc_type: str
title: str
scope_type: DocumentScopeType = DocumentScopeType.GLOBAL
owner_id: Optional[uuid.UUID] = None
description: Optional[str] = None
class DocumentUpdate(BaseModel):
title: Optional[str] = None
doc_type: Optional[str] = None
scope_type: Optional[DocumentScopeType] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
status: Optional[DocumentStatus] = None
description: Optional[str] = None
class DocumentSummary(BaseModel):
id: uuid.UUID
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
doc_no: str
doc_type: str
title: str
scope_type: DocumentScopeType
owner_id: Optional[uuid.UUID]
status: DocumentStatus
current_effective_version_id: Optional[uuid.UUID]
current_effective_version: Optional[DocumentVersionSummary] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class DocumentDetail(DocumentSummary):
description: Optional[str] = None
owner: Optional[UserDisplay] = None
version_timeline: list[DocumentVersionRead] = Field(default_factory=list)
distribution_stats: DistributionStats = Field(default_factory=DistributionStats)
model_config = ConfigDict(from_attributes=True)
+57
View File
@@ -0,0 +1,57 @@
import enum
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class DocumentVersionStatus(str, enum.Enum):
DRAFT = "DRAFT"
SUBMITTED = "SUBMITTED"
APPROVED = "APPROVED"
EFFECTIVE = "EFFECTIVE"
SUPERSEDED = "SUPERSEDED"
ARCHIVED = "ARCHIVED"
WITHDRAWN = "WITHDRAWN"
class DocumentVersionSummary(BaseModel):
id: uuid.UUID
version_no: str
status: DocumentVersionStatus
effective_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class DocumentVersionRead(BaseModel):
id: uuid.UUID
document_id: uuid.UUID
version_no: str
parent_version_id: Optional[uuid.UUID] = None
derived_from_version_id: Optional[uuid.UUID] = None
status: DocumentVersionStatus
effective_at: Optional[datetime] = None
superseded_at: Optional[datetime] = None
file_uri: str
file_hash: str
file_size: int
mime_type: Optional[str] = None
change_summary: Optional[str] = None
created_by: Optional[uuid.UUID] = None
submitted_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
withdrawn_at: Optional[datetime] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class VersionSubmitRequest(BaseModel):
template_id: uuid.UUID
comment: Optional[str] = None
class VersionActionRequest(BaseModel):
comment: Optional[str] = None
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.user import UserDisplay
StudyRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
StudyRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"]
class StudyMemberCreate(BaseModel):
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
UserRole = Literal["PM", "CRA", "PV", "IMP", "ADMIN"]
UserRole = Literal["PM", "CRA", "PV", "IMP", "QA", "ADMIN"]
RegisterRole = Literal["PM", "CRA", "PV", "IMP"]
UserStatus = Literal["PENDING", "ACTIVE", "REJECTED", "DISABLED"]
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class WorkflowNodeRead(BaseModel):
id: uuid.UUID
node_order: int
role: str
name: str | None = None
is_required: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkflowTemplateRead(BaseModel):
id: uuid.UUID
trial_id: uuid.UUID | None = None
name: str
description: str | None = None
is_active: bool
created_by: uuid.UUID | None = None
created_at: datetime
updated_at: datetime
nodes: list[WorkflowNodeRead] = []
model_config = ConfigDict(from_attributes=True)
+753
View File
@@ -0,0 +1,753 @@
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 update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import rbac
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 study as study_crud
from app.crud import user as user_crud
from app.crud import version_workflow as workflow_crud
from app.crud import workflow_template as template_crud
from app.models.acknowledgement import Acknowledgement, AcknowledgementType
from app.models.audit_log import AuditLog
from app.models.distribution import Distribution, DistributionTargetType
from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.models.version_workflow import VersionWorkflow, WorkflowStatus
from app.models.workflow_action import WorkflowAction, WorkflowActionType
from app.models.workflow_template import WorkflowTemplate
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.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="不是项目成员")
if not rbac.is_allowed(action, current_user, membership):
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
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")
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,
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_no=doc.doc_no,
doc_type=doc.doc_type,
title=doc.title,
scope_type=doc.scope_type,
owner_id=doc.owner_id,
status=doc.status,
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")
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_no=doc.doc_no,
doc_type=doc.doc_type,
title=doc.title,
scope_type=doc.scope_type,
owner_id=doc.owner_id,
status=doc.status,
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,
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")
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)
version = DocumentVersion(
document_id=document_id,
version_no=version_no,
parent_version_id=parent_version_id,
status=DocumentVersionStatus.DRAFT,
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)
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),
)
)
await db.commit()
await db.refresh(version)
return version
async def submit_version(
db: AsyncSession,
version_id: uuid.UUID,
template_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
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="submit")
if version.status != DocumentVersionStatus.DRAFT:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本状态不允许提交")
pending = await version_crud.list_pending_versions(db, version.document_id)
if any(v.id != version.id for v in pending):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="存在待审批版本")
template = await template_crud.get(db, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审批模板不存在")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.SUBMITTED
version.submitted_at = datetime.now(timezone.utc)
workflow = VersionWorkflow(
document_id=version.document_id,
version_id=version.id,
template_id=template_id,
status=WorkflowStatus.PENDING,
current_node=1,
submitted_by=current_user.id,
submitted_at=version.submitted_at,
)
action = WorkflowAction(
workflow=workflow,
node_order=workflow.current_node,
action=WorkflowActionType.SUBMIT,
actor_id=current_user.id,
comment=comment,
)
db.add(version)
db.add(workflow)
db.add(action)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_SUBMITTED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def approve_version(
db: AsyncSession,
version_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
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="approve")
workflow = await workflow_crud.get_by_version(db, version_id)
if not workflow or workflow.status != WorkflowStatus.PENDING:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.APPROVED
version.approved_at = datetime.now(timezone.utc)
workflow.status = WorkflowStatus.APPROVED
workflow.completed_at = version.approved_at
db.add(version)
db.add(workflow)
db.add(
WorkflowAction(
workflow_id=workflow.id,
node_order=workflow.current_node,
action=WorkflowActionType.APPROVE,
actor_id=current_user.id,
comment=comment,
)
)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_APPROVED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def reject_version(
db: AsyncSession,
version_id: uuid.UUID,
comment: str | None,
current_user,
) -> VersionWorkflow:
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="approve")
workflow = await workflow_crud.get_by_version(db, version_id)
if not workflow or workflow.status != WorkflowStatus.PENDING:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="审批流程不可用")
before = _version_snapshot(version)
version.status = DocumentVersionStatus.WITHDRAWN
version.withdrawn_at = datetime.now(timezone.utc)
workflow.status = WorkflowStatus.REJECTED
workflow.completed_at = version.withdrawn_at
db.add(version)
db.add(workflow)
db.add(
WorkflowAction(
workflow_id=workflow.id,
node_order=workflow.current_node,
action=WorkflowActionType.REJECT,
actor_id=current_user.id,
comment=comment,
)
)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_REJECTED",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(workflow)
return workflow
async def make_version_effective(
db: AsyncSession,
version_id: uuid.UUID,
current_user,
) -> DocumentVersion:
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="approve")
if version.status != DocumentVersionStatus.APPROVED:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="版本未审批通过")
now = datetime.now(timezone.utc)
before = _version_snapshot(version)
version.status = DocumentVersionStatus.EFFECTIVE
version.effective_at = now
await db.execute(
sa_update(DocumentVersion)
.where(
DocumentVersion.document_id == version.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 == version.document_id)
.values(current_effective_version_id=version.id)
)
db.add(version)
db.add(
AuditLog(
study_id=doc.trial_id,
entity_type="DOCUMENT_VERSION",
entity_id=version.id,
action="VERSION_EFFECTIVE",
detail=_audit_detail(before, _version_snapshot(version)),
operator_id=current_user.id,
operator_role=_role_value(current_user),
)
)
await db.commit()
await db.refresh(version)
return version
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 list_workflow_templates(
db: AsyncSession,
trial_id: uuid.UUID | None,
current_user,
) -> list[WorkflowTemplate]:
if trial_id:
await _ensure_study_access(db, trial_id, current_user, action="view")
elif current_user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
templates = await template_crud.list_active(db, trial_id)
return list(templates)
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_access(db, doc.trial_id, current_user, action="distribute")
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_access(db, doc.trial_id, current_user, action="view")
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
+1
View File
@@ -13,3 +13,4 @@ pytest==8.1.1
pytest-asyncio==0.23.5
httpx==0.25.2
email-validator==2.1.1
alembic==1.13.1