文件版本管理-初步优化
This commit is contained in:
@@ -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
|
||||
@@ -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())
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -15,6 +15,7 @@ class UserRole(str, enum.Enum):
|
||||
CRA = "CRA"
|
||||
PV = "PV"
|
||||
IMP = "IMP"
|
||||
QA = "QA"
|
||||
|
||||
|
||||
class UserStatus(str, enum.Enum):
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user