信息架构/菜单框架大重构-20260109
This commit is contained in:
@@ -179,3 +179,34 @@ async def update_ae(
|
|||||||
data = AERead.model_validate(updated)
|
data = AERead.model_validate(updated)
|
||||||
data.is_overdue = _is_overdue(data)
|
data.is_overdue = _is_overdue(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{ae_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def delete_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
ae_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
ae = await ae_crud.get_ae(db, ae_id)
|
||||||
|
if not ae or ae.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found")
|
||||||
|
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||||
|
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
await ae_crud.delete_ae(db, ae)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="ae",
|
||||||
|
entity_id=ae_id,
|
||||||
|
action="DELETE_AE",
|
||||||
|
detail=f"AE {ae_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member
|
|
||||||
from app.crud import comment as comment_crud
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.comment import CommentCreate, CommentRead, CommentQuote
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=CommentRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def create_comment(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
entity_type: str,
|
|
||||||
entity_id: uuid.UUID,
|
|
||||||
comment_in: CommentCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> CommentRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
quote = None
|
|
||||||
if comment_in.quote_comment_id:
|
|
||||||
quote = await comment_crud.get_comment(db, comment_in.quote_comment_id, include_deleted=True)
|
|
||||||
if (
|
|
||||||
not quote
|
|
||||||
or quote.study_id != study_id
|
|
||||||
or quote.entity_type != entity_type
|
|
||||||
or quote.entity_id != entity_id
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用评论不存在")
|
|
||||||
if quote.is_deleted:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用评论已删除")
|
|
||||||
comment = await comment_crud.create_comment(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=entity_id,
|
|
||||||
comment_in=comment_in,
|
|
||||||
created_by=current_user.id,
|
|
||||||
)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=entity_id,
|
|
||||||
action="CREATE_COMMENT",
|
|
||||||
detail=f"Comment created by {current_user.full_name}",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return CommentRead(
|
|
||||||
id=comment.id,
|
|
||||||
content=comment.content,
|
|
||||||
created_by=comment.created_by,
|
|
||||||
created_at=comment.created_at,
|
|
||||||
quote_comment_id=comment.quote_comment_id,
|
|
||||||
quote=CommentQuote.model_validate(quote) if quote else None,
|
|
||||||
is_deleted=comment.is_deleted,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[CommentRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_comments(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
entity_type: str,
|
|
||||||
entity_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[CommentRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
comments = await comment_crud.list_comments(db, study_id, entity_type, entity_id)
|
|
||||||
quote_ids = {c.quote_comment_id for c in comments if c.quote_comment_id}
|
|
||||||
quote_map = await comment_crud.get_comments_by_ids(db, quote_ids, include_deleted=True)
|
|
||||||
return [
|
|
||||||
CommentRead(
|
|
||||||
id=c.id,
|
|
||||||
content=c.content,
|
|
||||||
created_by=c.created_by,
|
|
||||||
created_at=c.created_at,
|
|
||||||
quote_comment_id=c.quote_comment_id,
|
|
||||||
quote=CommentQuote.model_validate(quote_map.get(c.quote_comment_id)) if c.quote_comment_id else None,
|
|
||||||
is_deleted=c.is_deleted,
|
|
||||||
)
|
|
||||||
for c in comments
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{comment_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def delete_comment(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
entity_type: str,
|
|
||||||
entity_id: uuid.UUID,
|
|
||||||
comment_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
):
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
comment = await comment_crud.get_comment(db, comment_id, include_deleted=True)
|
|
||||||
if (
|
|
||||||
not comment
|
|
||||||
or comment.study_id != study_id
|
|
||||||
or comment.entity_type != entity_type
|
|
||||||
or comment.entity_id != entity_id
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
|
||||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
|
||||||
if role_value != "ADMIN" and comment.created_by != current_user.id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
if comment.is_deleted:
|
|
||||||
return
|
|
||||||
await comment_crud.soft_delete_comment(db, comment)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=entity_id,
|
|
||||||
action="DELETE_COMMENT",
|
|
||||||
detail=f"Comment deleted by {current_user.full_name}",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from fastapi import APIRouter, Depends
|
|
||||||
|
|
||||||
from app.constants import ae, finance, imp, issue, subject
|
|
||||||
from app.core.deps import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter(tags=["FAQ"], dependencies=[Depends(get_current_user)])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
summary="获取前端常量字典",
|
|
||||||
description="返回各模块状态/类型常量,便于前端一次性加载。",
|
|
||||||
)
|
|
||||||
async def get_constants():
|
|
||||||
return {
|
|
||||||
"subject": {"status": subject.SUBJECT_STATUS, "visit_status": subject.VISIT_STATUS},
|
|
||||||
"ae": {"seriousness": ae.AE_SERIOUSNESS, "severity": ae.AE_SEVERITY, "status": ae.AE_STATUS},
|
|
||||||
"issue": {"category": issue.ISSUE_CATEGORY, "level": issue.ISSUE_LEVEL, "status": issue.ISSUE_STATUS},
|
|
||||||
"finance": {"status": finance.FINANCE_STATUS, "category": finance.FINANCE_CATEGORY},
|
|
||||||
"imp": {"tx_types": imp.IMP_TX_TYPES, "batch_status": imp.IMP_BATCH_STATUS},
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import data_query as dq_crud
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.data_query import DataQueryCreate, DataQueryRead, DataQueryUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_overdue(dq: DataQueryRead) -> bool:
|
|
||||||
return bool(dq.due_date and date.today() > dq.due_date and dq.status != "CLOSED")
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=DataQueryRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
|
||||||
)
|
|
||||||
async def create_query(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
query_in: DataQueryCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> DataQueryRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
try:
|
|
||||||
dq = await dq_crud.create_query(db, study_id, query_in, created_by=current_user.id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="data_query",
|
|
||||||
entity_id=dq.id,
|
|
||||||
action="CREATE_DATA_QUERY",
|
|
||||||
detail=f"DataQuery {dq.id} created",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
obj = DataQueryRead.model_validate(dq)
|
|
||||||
obj.is_overdue = _is_overdue(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[DataQueryRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_queries(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
status_filter: str | None = None,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
assigned_to: uuid.UUID | None = None,
|
|
||||||
overdue: bool | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
priority: str | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[DataQueryRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
queries = await dq_crud.list_queries(
|
|
||||||
db,
|
|
||||||
study_id,
|
|
||||||
status=status_filter,
|
|
||||||
site_id=site_id,
|
|
||||||
subject_id=subject_id,
|
|
||||||
assigned_to=assigned_to,
|
|
||||||
overdue=overdue,
|
|
||||||
category=category,
|
|
||||||
priority=priority,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
result: list[DataQueryRead] = []
|
|
||||||
for q in queries:
|
|
||||||
obj = DataQueryRead.model_validate(q)
|
|
||||||
obj.is_overdue = _is_overdue(obj)
|
|
||||||
result.append(obj)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{query_id}",
|
|
||||||
response_model=DataQueryRead,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def get_query(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
query_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> DataQueryRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
dq = await dq_crud.get_query(db, query_id)
|
|
||||||
if not dq or dq.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DataQuery not found")
|
|
||||||
obj = DataQueryRead.model_validate(dq)
|
|
||||||
obj.is_overdue = _is_overdue(obj)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{query_id}",
|
|
||||||
response_model=DataQueryRead,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
|
||||||
)
|
|
||||||
async def update_query(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
query_id: uuid.UUID,
|
|
||||||
query_in: DataQueryUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> DataQueryRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
dq = await dq_crud.get_query(db, query_id)
|
|
||||||
if not dq or dq.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DataQuery not found")
|
|
||||||
old_status = dq.status
|
|
||||||
updated = await dq_crud.update_query(db, dq, query_in)
|
|
||||||
detail = None
|
|
||||||
action = "UPDATE_DATA_QUERY"
|
|
||||||
if query_in.status and query_in.status != old_status:
|
|
||||||
detail = f"DataQuery {query_id} status {old_status} -> {query_in.status}"
|
|
||||||
action = "DATA_QUERY_STATUS_CHANGE"
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="data_query",
|
|
||||||
entity_id=query_id,
|
|
||||||
action=action,
|
|
||||||
detail=detail or "DataQuery updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
obj = DataQueryRead.model_validate(updated)
|
|
||||||
obj.is_overdue = _is_overdue(obj)
|
|
||||||
return obj
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import drug_shipment as shipment_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/shipments",
|
||||||
|
response_model=DrugShipmentRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_shipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
shipment_in: DrugShipmentCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> DrugShipmentRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="drug_shipment",
|
||||||
|
entity_id=shipment.id,
|
||||||
|
action="CREATE_DRUG_SHIPMENT",
|
||||||
|
detail=f"Drug shipment {shipment.id} created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return DrugShipmentRead.model_validate(shipment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/shipments",
|
||||||
|
response_model=list[DrugShipmentRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_shipments(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
tracking_no: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[DrugShipmentRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await shipment_crud.list_shipments(
|
||||||
|
db, study_id, site_name=site_name, tracking_no=tracking_no, status=status, skip=skip, limit=limit
|
||||||
|
)
|
||||||
|
return [DrugShipmentRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/shipments/{shipment_id}",
|
||||||
|
response_model=DrugShipmentRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_shipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
shipment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> DrugShipmentRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||||
|
if not shipment or shipment.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found")
|
||||||
|
return DrugShipmentRead.model_validate(shipment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/shipments/{shipment_id}",
|
||||||
|
response_model=DrugShipmentRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_shipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
shipment_id: uuid.UUID,
|
||||||
|
shipment_in: DrugShipmentUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> DrugShipmentRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||||
|
if not shipment or shipment.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found")
|
||||||
|
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="drug_shipment",
|
||||||
|
entity_id=shipment_id,
|
||||||
|
action="UPDATE_DRUG_SHIPMENT",
|
||||||
|
detail=f"Drug shipment {shipment_id} updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return DrugShipmentRead.model_validate(shipment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/shipments/{shipment_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_shipment(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
shipment_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||||
|
if not shipment or shipment.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found")
|
||||||
|
await shipment_crud.delete_shipment(db, shipment)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="drug_shipment",
|
||||||
|
entity_id=shipment_id,
|
||||||
|
action="DELETE_DRUG_SHIPMENT",
|
||||||
|
detail=f"Drug shipment {shipment_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import finance as finance_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.finance import FinanceCreate, FinanceRead, FinanceStatusUpdate, FinanceUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
def _with_month(item: FinanceRead) -> FinanceRead:
|
|
||||||
item.month = item.occur_date.strftime("%Y-%m")
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/items",
|
|
||||||
response_model=FinanceRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
|
||||||
)
|
|
||||||
async def create_finance_item(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
item_in: FinanceCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> FinanceRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
try:
|
|
||||||
item = await finance_crud.create_item(db, study_id, item_in, created_by=current_user.id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="finance",
|
|
||||||
entity_id=item.id,
|
|
||||||
action="CREATE_FINANCE_ITEM",
|
|
||||||
detail=f"Finance {item.id} created",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return _with_month(FinanceRead.model_validate(item))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/items",
|
|
||||||
response_model=list[FinanceRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_finance_items(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
status_filter: str | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
date_from: date | None = None,
|
|
||||||
date_to: date | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[FinanceRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
items = await finance_crud.list_items(
|
|
||||||
db,
|
|
||||||
study_id,
|
|
||||||
status=status_filter,
|
|
||||||
category=category,
|
|
||||||
site_id=site_id,
|
|
||||||
subject_id=subject_id,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
return [_with_month(FinanceRead.model_validate(i)) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/items/{item_id}",
|
|
||||||
response_model=FinanceRead,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def get_finance_item(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
item_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> FinanceRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
item = await finance_crud.get_item(db, item_id)
|
|
||||||
if not item or item.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
|
|
||||||
return _with_month(FinanceRead.model_validate(item))
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/items/{item_id}",
|
|
||||||
response_model=FinanceRead,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
|
||||||
)
|
|
||||||
async def update_finance_item(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
item_id: uuid.UUID,
|
|
||||||
item_in: FinanceUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> FinanceRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
item = await finance_crud.get_item(db, item_id)
|
|
||||||
if not item or item.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
|
|
||||||
try:
|
|
||||||
updated = await finance_crud.update_item_draft(db, item, item_in)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="finance",
|
|
||||||
entity_id=item_id,
|
|
||||||
action="UPDATE_FINANCE_ITEM",
|
|
||||||
detail=f"Finance {item_id} updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return _with_month(FinanceRead.model_validate(updated))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/items/{item_id}/status",
|
|
||||||
response_model=FinanceRead,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def change_finance_status(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
item_id: uuid.UUID,
|
|
||||||
status_in: FinanceStatusUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> FinanceRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
item = await finance_crud.get_item(db, item_id)
|
|
||||||
if not item or item.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
|
|
||||||
|
|
||||||
# permission by status target
|
|
||||||
from app.crud import member as member_crud
|
|
||||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
|
||||||
member_role = member.role_in_study if member else None
|
|
||||||
|
|
||||||
target = status_in.status
|
|
||||||
if target in {"SUBMITTED"}:
|
|
||||||
if current_user.role != "ADMIN" and member_role not in {"PM", "CRA"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
elif target in {"APPROVED", "REJECTED", "PAID"}:
|
|
||||||
if current_user.role != "ADMIN" and member_role not in {"PM"}:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported status")
|
|
||||||
|
|
||||||
old_status = item.status
|
|
||||||
try:
|
|
||||||
updated = await finance_crud.change_status(db, item, status_in, operator_id=current_user.id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
detail = f"Finance {item_id} {old_status} -> {status_in.status}"
|
|
||||||
action = "FINANCE_STATUS_CHANGE"
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="finance",
|
|
||||||
entity_id=item_id,
|
|
||||||
action=action,
|
|
||||||
detail=detail,
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return _with_month(FinanceRead.model_validate(updated))
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import finance_contract as contract_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/contracts",
|
||||||
|
response_model=FinanceContractRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_contract(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
contract_in: FinanceContractCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> FinanceContractRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="finance_contract",
|
||||||
|
entity_id=contract.id,
|
||||||
|
action="CREATE_FINANCE_CONTRACT",
|
||||||
|
detail=f"Finance contract {contract.contract_no} created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return FinanceContractRead.model_validate(contract)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/contracts",
|
||||||
|
response_model=list[FinanceContractRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_contracts(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
contract_no: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[FinanceContractRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await contract_crud.list_contracts(db, study_id, site_name=site_name, contract_no=contract_no, skip=skip, limit=limit)
|
||||||
|
return [FinanceContractRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/contracts/{contract_id}",
|
||||||
|
response_model=FinanceContractRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_contract(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
contract_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> FinanceContractRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
contract = await contract_crud.get_contract(db, contract_id)
|
||||||
|
if not contract or contract.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found")
|
||||||
|
return FinanceContractRead.model_validate(contract)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/contracts/{contract_id}",
|
||||||
|
response_model=FinanceContractRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_contract(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
contract_id: uuid.UUID,
|
||||||
|
contract_in: FinanceContractUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> FinanceContractRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
contract = await contract_crud.get_contract(db, contract_id)
|
||||||
|
if not contract or contract.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found")
|
||||||
|
contract = await contract_crud.update_contract(db, contract, contract_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="finance_contract",
|
||||||
|
entity_id=contract_id,
|
||||||
|
action="UPDATE_FINANCE_CONTRACT",
|
||||||
|
detail=f"Finance contract {contract_id} updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return FinanceContractRead.model_validate(contract)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/contracts/{contract_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_contract(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
contract_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
contract = await contract_crud.get_contract(db, contract_id)
|
||||||
|
if not contract or contract.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found")
|
||||||
|
await contract_crud.delete_contract(db, contract)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="finance_contract",
|
||||||
|
entity_id=contract_id,
|
||||||
|
action="DELETE_FINANCE_CONTRACT",
|
||||||
|
detail=f"Finance contract {contract_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import finance_special as special_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/specials",
|
||||||
|
response_model=FinanceSpecialRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_special(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
special_in: FinanceSpecialCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> FinanceSpecialRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="finance_special",
|
||||||
|
entity_id=item.id,
|
||||||
|
action="CREATE_FINANCE_SPECIAL",
|
||||||
|
detail=f"Finance special {item.id} created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return FinanceSpecialRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/specials",
|
||||||
|
response_model=list[FinanceSpecialRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_specials(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
fee_type: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[FinanceSpecialRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await special_crud.list_specials(db, study_id, site_name=site_name, fee_type=fee_type, skip=skip, limit=limit)
|
||||||
|
return [FinanceSpecialRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/specials/{special_id}",
|
||||||
|
response_model=FinanceSpecialRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_special(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
special_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> FinanceSpecialRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await special_crud.get_special(db, special_id)
|
||||||
|
if not item or item.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found")
|
||||||
|
return FinanceSpecialRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/specials/{special_id}",
|
||||||
|
response_model=FinanceSpecialRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_special(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
special_id: uuid.UUID,
|
||||||
|
special_in: FinanceSpecialUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> FinanceSpecialRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await special_crud.get_special(db, special_id)
|
||||||
|
if not item or item.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found")
|
||||||
|
item = await special_crud.update_special(db, item, special_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="finance_special",
|
||||||
|
entity_id=special_id,
|
||||||
|
action="UPDATE_FINANCE_SPECIAL",
|
||||||
|
detail=f"Finance special {special_id} updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return FinanceSpecialRead.model_validate(item)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/specials/{special_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_special(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
special_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
item = await special_crud.get_special(db, special_id)
|
||||||
|
if not item or item.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found")
|
||||||
|
await special_crud.delete_special(db, item)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="finance_special",
|
||||||
|
entity_id=special_id,
|
||||||
|
action="DELETE_FINANCE_SPECIAL",
|
||||||
|
detail=f"Finance special {special_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_db_session
|
|
||||||
from app.schemas.imp import TxCreate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import imp_batch as batch_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.imp import BatchCreate, BatchRead, BatchUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=BatchRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
|
||||||
)
|
|
||||||
async def create_batch(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
batch_in: BatchCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> BatchRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
try:
|
|
||||||
batch = await batch_crud.create_batch(db, study_id, batch_in)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="imp_batch",
|
|
||||||
entity_id=batch.id,
|
|
||||||
action="CREATE_IMP_BATCH",
|
|
||||||
detail=f"IMP batch {batch.batch_no} created",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return BatchRead.model_validate(batch)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[BatchRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_batches(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
product_id: uuid.UUID | None = None,
|
|
||||||
status_filter: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[BatchRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
batches = await batch_crud.list_batches(db, study_id, product_id=product_id, status=status_filter)
|
|
||||||
return [BatchRead.model_validate(b) for b in batches]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{batch_id}",
|
|
||||||
response_model=BatchRead,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
|
||||||
)
|
|
||||||
async def update_batch(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
batch_id: uuid.UUID,
|
|
||||||
batch_in: BatchUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> BatchRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
batch = await batch_crud.get_batch(db, batch_id)
|
|
||||||
if not batch or batch.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Batch not found")
|
|
||||||
updated = await batch_crud.update_batch(db, batch, batch_in)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="imp_batch",
|
|
||||||
entity_id=batch_id,
|
|
||||||
action="UPDATE_IMP_BATCH",
|
|
||||||
detail=f"IMP batch {batch_id} updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return BatchRead.model_validate(updated)
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_db_session, require_study_member
|
|
||||||
from app.crud import imp_inventory as inventory_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.imp import InventoryRead
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[InventoryRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_inventory(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
product_id: uuid.UUID | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[InventoryRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
inv = await inventory_crud.list_inventory(db, study_id, site_id=site_id, product_id=product_id)
|
|
||||||
return [InventoryRead.model_validate(i) for i in inv]
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import imp_product as product_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.imp import ProductCreate, ProductRead, ProductUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=ProductRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
|
||||||
)
|
|
||||||
async def create_product(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
product_in: ProductCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> ProductRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
product = await product_crud.create_product(db, study_id, product_in)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="imp_product",
|
|
||||||
entity_id=product.id,
|
|
||||||
action="CREATE_IMP_PRODUCT",
|
|
||||||
detail=f"IMP product {product.name} created",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return ProductRead.model_validate(product)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[ProductRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_products(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
is_active: bool | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[ProductRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
products = await product_crud.list_products(db, study_id, is_active=is_active)
|
|
||||||
return [ProductRead.model_validate(p) for p in products]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{product_id}",
|
|
||||||
response_model=ProductRead,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
|
||||||
)
|
|
||||||
async def update_product(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
product_id: uuid.UUID,
|
|
||||||
product_in: ProductUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> ProductRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
product = await product_crud.get_product(db, product_id)
|
|
||||||
if not product or product.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
|
|
||||||
updated = await product_crud.update_product(db, product, product_in)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="imp_product",
|
|
||||||
entity_id=product_id,
|
|
||||||
action="UPDATE_IMP_PRODUCT",
|
|
||||||
detail=f"IMP product {product_id} updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return ProductRead.model_validate(updated)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import imp_batch as batch_crud
|
|
||||||
from app.crud import imp_tx as tx_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.crud import subject as subject_crud
|
|
||||||
from app.crud import member as member_crud
|
|
||||||
from app.schemas.imp import TxCreate, TxRead
|
|
||||||
from app.services.imp import create_transaction_and_apply_inventory, NEGATIVE_TYPES
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
|
|
||||||
batch = await batch_crud.get_batch(db, batch_id)
|
|
||||||
if not batch or batch.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Batch not found")
|
|
||||||
from app.models.site import Site
|
|
||||||
site = await db.get(Site, site_id)
|
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
|
||||||
if subject_id:
|
|
||||||
subj = await subject_crud.get_subject(db, subject_id)
|
|
||||||
if not subj or subj.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
|
|
||||||
if batch.expiry_date and date.today() > batch.expiry_date and batch_id:
|
|
||||||
# forbid outflow when expired
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=TxRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
|
||||||
)
|
|
||||||
async def create_transaction(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
tx_in: TxCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> TxRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
await _validate_entities(db, study_id, tx_in.site_id, tx_in.batch_id, tx_in.subject_id)
|
|
||||||
if tx_in.tx_type in NEGATIVE_TYPES:
|
|
||||||
batch = await batch_crud.get_batch(db, tx_in.batch_id)
|
|
||||||
if batch and batch.status == "EXPIRED":
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot dispense from expired batch")
|
|
||||||
try:
|
|
||||||
tx = await create_transaction_and_apply_inventory(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=tx_in.site_id,
|
|
||||||
batch_id=tx_in.batch_id,
|
|
||||||
subject_id=tx_in.subject_id,
|
|
||||||
tx_type=tx_in.tx_type,
|
|
||||||
quantity=tx_in.quantity,
|
|
||||||
tx_date=tx_in.tx_date,
|
|
||||||
reference=tx_in.reference,
|
|
||||||
notes=tx_in.notes,
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
return TxRead.model_validate(tx)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[TxRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_transactions(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
batch_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
tx_type: str | None = None,
|
|
||||||
date_from: date | None = None,
|
|
||||||
date_to: date | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[TxRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
txs = await tx_crud.list_txs(
|
|
||||||
db,
|
|
||||||
study_id,
|
|
||||||
site_id=site_id,
|
|
||||||
batch_id=batch_id,
|
|
||||||
subject_id=subject_id,
|
|
||||||
tx_type=tx_type,
|
|
||||||
date_from=date_from,
|
|
||||||
date_to=date_to,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
return [TxRead.model_validate(t) for t in txs]
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import issue as issue_crud
|
|
||||||
from app.crud import member as member_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.issue import IssueCreate, IssueRead, IssueUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
ALLOWED_EDIT_ROLES = {"PM", "PV"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
|
|
||||||
member = await member_crud.get_member(db, study_id, user_id)
|
|
||||||
return member.role_in_study if member else None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_overdue(issue: IssueRead) -> bool:
|
|
||||||
return bool(issue.due_date and date.today() > issue.due_date and issue.status != "CLOSED")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=IssueRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def create_issue(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
issue_in: IssueCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> IssueRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
|
||||||
if current_user.role != "ADMIN" and member_role not in ALLOWED_EDIT_ROLES:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
try:
|
|
||||||
issue = await issue_crud.create_issue(db, study_id, issue_in, created_by=current_user.id)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="issue",
|
|
||||||
entity_id=issue.id,
|
|
||||||
action="CREATE_ISSUE",
|
|
||||||
detail=f"Issue {issue.id} created",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
data = IssueRead.model_validate(issue)
|
|
||||||
data.is_overdue = _is_overdue(data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[IssueRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_issues(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
status_filter: str | None = None,
|
|
||||||
level: str | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
overdue: bool | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[IssueRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
issues = await issue_crud.list_issues(db, study_id, status=status_filter, level=level, category=category, overdue=overdue)
|
|
||||||
result: list[IssueRead] = []
|
|
||||||
for item in issues:
|
|
||||||
obj = IssueRead.model_validate(item)
|
|
||||||
obj.is_overdue = _is_overdue(obj)
|
|
||||||
result.append(obj)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/{issue_id}",
|
|
||||||
response_model=IssueRead,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def get_issue(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
issue_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> IssueRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
issue = await issue_crud.get_issue(db, issue_id)
|
|
||||||
if not issue or issue.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
|
||||||
data = IssueRead.model_validate(issue)
|
|
||||||
data.is_overdue = _is_overdue(data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{issue_id}",
|
|
||||||
response_model=IssueRead,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def update_issue(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
issue_id: uuid.UUID,
|
|
||||||
issue_in: IssueUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> IssueRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
issue = await issue_crud.get_issue(db, issue_id)
|
|
||||||
if not issue or issue.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
|
||||||
|
|
||||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
|
||||||
if current_user.role != "ADMIN" and member_role not in ALLOWED_EDIT_ROLES:
|
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
|
||||||
|
|
||||||
old_status = issue.status
|
|
||||||
old_level = issue.level
|
|
||||||
updated = await issue_crud.update_issue(db, issue, issue_in)
|
|
||||||
|
|
||||||
detail = None
|
|
||||||
action = "UPDATE_ISSUE"
|
|
||||||
if issue_in.status and issue_in.status != old_status:
|
|
||||||
detail = f"Issue {issue_id} status {old_status} -> {issue_in.status}"
|
|
||||||
action = "ISSUE_STATUS_CHANGE"
|
|
||||||
elif issue_in.level and issue_in.level != old_level:
|
|
||||||
detail = f"Issue {issue_id} level {old_level} -> {issue_in.level}"
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="issue",
|
|
||||||
entity_id=issue_id,
|
|
||||||
action=action,
|
|
||||||
detail=detail or "Issue updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
data = IssueRead.model_validate(updated)
|
|
||||||
data.is_overdue = _is_overdue(data)
|
|
||||||
return data
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import knowledge_note as note_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/notes",
|
||||||
|
response_model=KnowledgeNoteRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_note(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
note_in: KnowledgeNoteCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> KnowledgeNoteRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="knowledge_note",
|
||||||
|
entity_id=note.id,
|
||||||
|
action="CREATE_KNOWLEDGE_NOTE",
|
||||||
|
detail=f"Knowledge note {note.title} created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return KnowledgeNoteRead.model_validate(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notes",
|
||||||
|
response_model=list[KnowledgeNoteRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_notes(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[KnowledgeNoteRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await note_crud.list_notes(db, study_id, site_name=site_name, keyword=keyword, skip=skip, limit=limit)
|
||||||
|
return [KnowledgeNoteRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/notes/{note_id}",
|
||||||
|
response_model=KnowledgeNoteRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_note(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
note_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> KnowledgeNoteRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
note = await note_crud.get_note(db, note_id)
|
||||||
|
if not note or note.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||||
|
return KnowledgeNoteRead.model_validate(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/notes/{note_id}",
|
||||||
|
response_model=KnowledgeNoteRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_note(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
note_id: uuid.UUID,
|
||||||
|
note_in: KnowledgeNoteUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> KnowledgeNoteRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
note = await note_crud.get_note(db, note_id)
|
||||||
|
if not note or note.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||||
|
note = await note_crud.update_note(db, note, note_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="knowledge_note",
|
||||||
|
entity_id=note_id,
|
||||||
|
action="UPDATE_KNOWLEDGE_NOTE",
|
||||||
|
detail=f"Knowledge note {note_id} updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return KnowledgeNoteRead.model_validate(note)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/notes/{note_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_note(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
note_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
note = await note_crud.get_note(db, note_id)
|
||||||
|
if not note or note.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||||
|
await note_crud.delete_note(db, note)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="knowledge_note",
|
||||||
|
entity_id=note_id,
|
||||||
|
action="DELETE_KNOWLEDGE_NOTE",
|
||||||
|
detail=f"Knowledge note {note_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import attachment as attachment_crud
|
|
||||||
from app.crud import milestone as milestone_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.crud import user as user_crud
|
|
||||||
from app.crud import site as site_crud
|
|
||||||
from app.schemas.milestone import MilestoneCreate, MilestoneRead, MilestoneUpdate
|
|
||||||
from app.schemas.user import UserDisplay
|
|
||||||
from app.schemas.milestone import SiteDisplay
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/",
|
|
||||||
response_model=MilestoneRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
|
||||||
)
|
|
||||||
async def create_milestone(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
milestone_in: MilestoneCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> MilestoneRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
if milestone_in.planned_date and milestone_in.planned_date < date.today():
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划日期不得早于今天")
|
|
||||||
milestone = await milestone_crud.create(db, study_id, milestone_in)
|
|
||||||
owner = None
|
|
||||||
site = None
|
|
||||||
if milestone.owner_id:
|
|
||||||
owner = await user_crud.get_by_id(db, milestone.owner_id)
|
|
||||||
if milestone.site_id:
|
|
||||||
site = await site_crud.get_site(db, milestone.site_id)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="milestone",
|
|
||||||
entity_id=milestone.id,
|
|
||||||
action="CREATE_MILESTONE",
|
|
||||||
detail=f"Milestone {milestone.id} created",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return MilestoneRead(
|
|
||||||
id=milestone.id,
|
|
||||||
study_id=milestone.study_id,
|
|
||||||
type=milestone.type,
|
|
||||||
name=milestone.name,
|
|
||||||
planned_date=milestone.planned_date,
|
|
||||||
actual_date=milestone.actual_date,
|
|
||||||
status=milestone.status,
|
|
||||||
owner_id=milestone.owner_id,
|
|
||||||
site_id=milestone.site_id,
|
|
||||||
owner=UserDisplay.model_validate(owner) if owner else None,
|
|
||||||
site=SiteDisplay.model_validate(site) if site else None,
|
|
||||||
notes=milestone.notes,
|
|
||||||
created_at=milestone.created_at,
|
|
||||||
updated_at=milestone.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[MilestoneRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_milestones(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[MilestoneRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
milestones = await milestone_crud.list_milestones(db, study_id)
|
|
||||||
owner_ids = {m.owner_id for m in milestones if m.owner_id}
|
|
||||||
site_ids = {m.site_id for m in milestones if m.site_id}
|
|
||||||
users_map = await user_crud.get_users_by_ids(db, owner_ids)
|
|
||||||
sites_map = await site_crud.get_sites_by_ids(db, site_ids)
|
|
||||||
result: list[MilestoneRead] = []
|
|
||||||
for m in milestones:
|
|
||||||
owner = users_map.get(m.owner_id)
|
|
||||||
site = sites_map.get(m.site_id)
|
|
||||||
result.append(
|
|
||||||
MilestoneRead(
|
|
||||||
id=m.id,
|
|
||||||
study_id=m.study_id,
|
|
||||||
type=m.type,
|
|
||||||
name=m.name,
|
|
||||||
planned_date=m.planned_date,
|
|
||||||
actual_date=m.actual_date,
|
|
||||||
status=m.status,
|
|
||||||
owner_id=m.owner_id,
|
|
||||||
site_id=m.site_id,
|
|
||||||
owner=UserDisplay.model_validate(owner) if owner else None,
|
|
||||||
site=SiteDisplay.model_validate(site) if site else None,
|
|
||||||
notes=m.notes,
|
|
||||||
created_at=m.created_at,
|
|
||||||
updated_at=m.updated_at,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{milestone_id}",
|
|
||||||
response_model=MilestoneRead,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
|
||||||
)
|
|
||||||
async def update_milestone(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
milestone_id: uuid.UUID,
|
|
||||||
milestone_in: MilestoneUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> MilestoneRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
milestone = await milestone_crud.get(db, milestone_id)
|
|
||||||
if not milestone or milestone.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
|
||||||
if milestone_in.planned_date and milestone_in.planned_date < date.today():
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划日期不得早于今天")
|
|
||||||
if milestone_in.actual_date and milestone_in.actual_date < date.today():
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际日期不得早于今天")
|
|
||||||
if milestone_in.actual_date:
|
|
||||||
attachments = await attachment_crud.list_attachments(db, study_id, "ethics_node", milestone_id)
|
|
||||||
if not attachments:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先上传伦理批件后再更新实际日期")
|
|
||||||
milestone_in.status = "DONE"
|
|
||||||
old_status = milestone.status
|
|
||||||
updated = await milestone_crud.update(db, milestone, milestone_in)
|
|
||||||
owner = None
|
|
||||||
site = None
|
|
||||||
if updated.owner_id:
|
|
||||||
owner = await user_crud.get_by_id(db, updated.owner_id)
|
|
||||||
if updated.site_id:
|
|
||||||
site = await site_crud.get_site(db, updated.site_id)
|
|
||||||
detail = None
|
|
||||||
if milestone_in.status and milestone_in.status != old_status:
|
|
||||||
detail = f"milestone {milestone_id} status {old_status} -> {milestone_in.status}"
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="milestone",
|
|
||||||
entity_id=milestone_id,
|
|
||||||
action="UPDATE_MILESTONE",
|
|
||||||
detail=detail or "Milestone updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return MilestoneRead(
|
|
||||||
id=updated.id,
|
|
||||||
study_id=updated.study_id,
|
|
||||||
type=updated.type,
|
|
||||||
name=updated.name,
|
|
||||||
planned_date=updated.planned_date,
|
|
||||||
actual_date=updated.actual_date,
|
|
||||||
status=updated.status,
|
|
||||||
owner_id=updated.owner_id,
|
|
||||||
site_id=updated.site_id,
|
|
||||||
owner=UserDisplay.model_validate(owner) if owner else None,
|
|
||||||
site=SiteDisplay.model_validate(site) if site else None,
|
|
||||||
notes=updated.notes,
|
|
||||||
created_at=updated.created_at,
|
|
||||||
updated_at=updated.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{milestone_id}",
|
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def delete_milestone(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
milestone_id: uuid.UUID,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> None:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
milestone = await milestone_crud.get(db, milestone_id)
|
|
||||||
if not milestone or milestone.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
|
||||||
await milestone_crud.delete_milestone(db, milestone)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="milestone",
|
|
||||||
entity_id=milestone_id,
|
|
||||||
action="DELETE_MILESTONE",
|
|
||||||
detail=f"Milestone {milestone_id} deleted",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
|
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, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
@@ -9,24 +9,19 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
|||||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||||
api_router.include_router(comments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/comments", tags=["comments"])
|
|
||||||
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
||||||
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
||||||
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
||||||
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
|
|
||||||
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
||||||
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
|
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
|
||||||
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
|
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
|
||||||
api_router.include_router(issues.router, prefix="/studies/{study_id}/issues", tags=["issues"])
|
|
||||||
api_router.include_router(data_queries.router, prefix="/studies/{study_id}/data-queries", tags=["data-queries"])
|
|
||||||
api_router.include_router(verifications.router, prefix="/studies/{study_id}/verifications", tags=["verifications"])
|
|
||||||
api_router.include_router(imp_products.router, prefix="/studies/{study_id}/imp/products", tags=["imp-products"])
|
|
||||||
api_router.include_router(imp_batches.router, prefix="/studies/{study_id}/imp/batches", tags=["imp-batches"])
|
|
||||||
api_router.include_router(imp_inventory.router, prefix="/studies/{study_id}/imp/inventory", tags=["imp-inventory"])
|
|
||||||
api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"])
|
|
||||||
api_router.include_router(finance.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
|
||||||
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
||||||
|
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
|
||||||
|
api_router.include_router(finance_specials.router, prefix="/studies/{study_id}/finance", tags=["finance-specials"])
|
||||||
|
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
||||||
|
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||||
|
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
|
||||||
|
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(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
|
||||||
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
||||||
api_router.include_router(constants.router, prefix="/constants", tags=["faq"])
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import startup as startup_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.startup import (
|
||||||
|
KickoffMeetingCreate,
|
||||||
|
KickoffMeetingRead,
|
||||||
|
KickoffMeetingUpdate,
|
||||||
|
StartupEthicsCreate,
|
||||||
|
StartupEthicsRead,
|
||||||
|
StartupEthicsUpdate,
|
||||||
|
StartupFeasibilityCreate,
|
||||||
|
StartupFeasibilityRead,
|
||||||
|
StartupFeasibilityUpdate,
|
||||||
|
TrainingAuthorizationCreate,
|
||||||
|
TrainingAuthorizationRead,
|
||||||
|
TrainingAuthorizationUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/feasibility",
|
||||||
|
response_model=StartupFeasibilityRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_feasibility(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_in: StartupFeasibilityCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> StartupFeasibilityRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_feasibility",
|
||||||
|
entity_id=record.id,
|
||||||
|
action="CREATE_STARTUP_FEASIBILITY",
|
||||||
|
detail="Startup feasibility record created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return StartupFeasibilityRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/feasibility",
|
||||||
|
response_model=list[StartupFeasibilityRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_feasibilities(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[StartupFeasibilityRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit)
|
||||||
|
return [StartupFeasibilityRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/feasibility/{record_id}",
|
||||||
|
response_model=StartupFeasibilityRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_feasibility(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> StartupFeasibilityRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_feasibility(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found")
|
||||||
|
return StartupFeasibilityRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/feasibility/{record_id}",
|
||||||
|
response_model=StartupFeasibilityRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_feasibility(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
record_in: StartupFeasibilityUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> StartupFeasibilityRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_feasibility(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found")
|
||||||
|
record = await startup_crud.update_feasibility(db, record, record_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_feasibility",
|
||||||
|
entity_id=record_id,
|
||||||
|
action="UPDATE_STARTUP_FEASIBILITY",
|
||||||
|
detail="Startup feasibility record updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return StartupFeasibilityRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/feasibility/{record_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_feasibility(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_feasibility(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found")
|
||||||
|
await startup_crud.delete_feasibility(db, record)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_feasibility",
|
||||||
|
entity_id=record_id,
|
||||||
|
action="DELETE_STARTUP_FEASIBILITY",
|
||||||
|
detail="Startup feasibility record deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/ethics",
|
||||||
|
response_model=StartupEthicsRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_ethics(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_in: StartupEthicsCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> StartupEthicsRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_ethics",
|
||||||
|
entity_id=record.id,
|
||||||
|
action="CREATE_STARTUP_ETHICS",
|
||||||
|
detail="Startup ethics record created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return StartupEthicsRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/ethics",
|
||||||
|
response_model=list[StartupEthicsRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_ethics(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[StartupEthicsRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit)
|
||||||
|
return [StartupEthicsRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/ethics/{record_id}",
|
||||||
|
response_model=StartupEthicsRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_ethics(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> StartupEthicsRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_ethics(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found")
|
||||||
|
return StartupEthicsRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/ethics/{record_id}",
|
||||||
|
response_model=StartupEthicsRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_ethics(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
record_in: StartupEthicsUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> StartupEthicsRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_ethics(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found")
|
||||||
|
record = await startup_crud.update_ethics(db, record, record_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_ethics",
|
||||||
|
entity_id=record_id,
|
||||||
|
action="UPDATE_STARTUP_ETHICS",
|
||||||
|
detail="Startup ethics record updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return StartupEthicsRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/ethics/{record_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_ethics(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_ethics(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found")
|
||||||
|
await startup_crud.delete_ethics(db, record)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_ethics",
|
||||||
|
entity_id=record_id,
|
||||||
|
action="DELETE_STARTUP_ETHICS",
|
||||||
|
detail="Startup ethics record deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/kickoff",
|
||||||
|
response_model=KickoffMeetingRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_kickoff(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
meeting_in: KickoffMeetingCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> KickoffMeetingRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_kickoff",
|
||||||
|
entity_id=meeting.id,
|
||||||
|
action="CREATE_KICKOFF_MEETING",
|
||||||
|
detail="Kickoff meeting created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return KickoffMeetingRead.model_validate(meeting)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/kickoff",
|
||||||
|
response_model=list[KickoffMeetingRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_kickoffs(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[KickoffMeetingRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit)
|
||||||
|
return [KickoffMeetingRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/kickoff/{meeting_id}",
|
||||||
|
response_model=KickoffMeetingRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_kickoff(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
meeting_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> KickoffMeetingRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||||
|
if not meeting or meeting.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found")
|
||||||
|
return KickoffMeetingRead.model_validate(meeting)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/kickoff/{meeting_id}",
|
||||||
|
response_model=KickoffMeetingRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_kickoff(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
meeting_id: uuid.UUID,
|
||||||
|
meeting_in: KickoffMeetingUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> KickoffMeetingRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||||
|
if not meeting or meeting.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found")
|
||||||
|
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_kickoff",
|
||||||
|
entity_id=meeting_id,
|
||||||
|
action="UPDATE_KICKOFF_MEETING",
|
||||||
|
detail="Kickoff meeting updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return KickoffMeetingRead.model_validate(meeting)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/kickoff/{meeting_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_kickoff(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
meeting_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||||
|
if not meeting or meeting.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found")
|
||||||
|
await startup_crud.delete_kickoff(db, meeting)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="startup_kickoff",
|
||||||
|
entity_id=meeting_id,
|
||||||
|
action="DELETE_KICKOFF_MEETING",
|
||||||
|
detail="Kickoff meeting deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/training-authorizations",
|
||||||
|
response_model=TrainingAuthorizationRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_training_authorization(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_in: TrainingAuthorizationCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> TrainingAuthorizationRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="training_authorization",
|
||||||
|
entity_id=record.id,
|
||||||
|
action="CREATE_TRAINING_AUTH",
|
||||||
|
detail="Training authorization created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return TrainingAuthorizationRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/training-authorizations",
|
||||||
|
response_model=list[TrainingAuthorizationRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_training_authorizations(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 200,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[TrainingAuthorizationRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit)
|
||||||
|
return [TrainingAuthorizationRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/training-authorizations/{record_id}",
|
||||||
|
response_model=TrainingAuthorizationRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_training_authorization(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> TrainingAuthorizationRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_training_authorization(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found")
|
||||||
|
return TrainingAuthorizationRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/training-authorizations/{record_id}",
|
||||||
|
response_model=TrainingAuthorizationRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_training_authorization(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
record_in: TrainingAuthorizationUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> TrainingAuthorizationRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_training_authorization(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found")
|
||||||
|
record = await startup_crud.update_training_authorization(db, record, record_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="training_authorization",
|
||||||
|
entity_id=record_id,
|
||||||
|
action="UPDATE_TRAINING_AUTH",
|
||||||
|
detail="Training authorization updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return TrainingAuthorizationRead.model_validate(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/training-authorizations/{record_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_training_authorization(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
record = await startup_crud.get_training_authorization(db, record_id)
|
||||||
|
if not record or record.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found")
|
||||||
|
await startup_crud.delete_training_authorization(db, record)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="training_authorization",
|
||||||
|
entity_id=record_id,
|
||||||
|
action="DELETE_TRAINING_AUTH",
|
||||||
|
detail="Training authorization deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import subject_history as history_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||||
|
study = await study_crud.get(db, study_id)
|
||||||
|
if not study:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||||
|
return study
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/histories",
|
||||||
|
response_model=SubjectHistoryRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_history(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
history_in: SubjectHistoryCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> SubjectHistoryRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
if history_in.subject_id != subject_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Subject mismatch")
|
||||||
|
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="subject_history",
|
||||||
|
entity_id=history.id,
|
||||||
|
action="CREATE_SUBJECT_HISTORY",
|
||||||
|
detail="Subject history created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return SubjectHistoryRead.model_validate(history)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/histories",
|
||||||
|
response_model=list[SubjectHistoryRead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_histories(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 200,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[SubjectHistoryRead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit)
|
||||||
|
return [SubjectHistoryRead.model_validate(item) for item in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/histories/{history_id}",
|
||||||
|
response_model=SubjectHistoryRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_history(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
history_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SubjectHistoryRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
history = await history_crud.get_history(db, history_id)
|
||||||
|
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found")
|
||||||
|
return SubjectHistoryRead.model_validate(history)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/histories/{history_id}",
|
||||||
|
response_model=SubjectHistoryRead,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def update_history(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
history_id: uuid.UUID,
|
||||||
|
history_in: SubjectHistoryUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> SubjectHistoryRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
history = await history_crud.get_history(db, history_id)
|
||||||
|
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found")
|
||||||
|
history = await history_crud.update_history(db, history, history_in)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="subject_history",
|
||||||
|
entity_id=history_id,
|
||||||
|
action="UPDATE_SUBJECT_HISTORY",
|
||||||
|
detail="Subject history updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return SubjectHistoryRead.model_validate(history)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/histories/{history_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_history(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
history_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
history = await history_crud.get_history(db, history_id)
|
||||||
|
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found")
|
||||||
|
await history_crud.delete_history(db, history)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="subject_history",
|
||||||
|
entity_id=history_id,
|
||||||
|
action="DELETE_SUBJECT_HISTORY",
|
||||||
|
detail="Subject history deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
@@ -58,13 +58,33 @@ async def list_subjects(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
site_id: uuid.UUID | None = None,
|
site_id: uuid.UUID | None = None,
|
||||||
status_filter: str | None = None,
|
status_filter: str | None = None,
|
||||||
|
subject_no: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> list[SubjectRead]:
|
) -> list[SubjectRead]:
|
||||||
await _ensure_study_exists(db, study_id)
|
await _ensure_study_exists(db, study_id)
|
||||||
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id, status=status_filter)
|
subjects = await subject_crud.list_subjects(
|
||||||
|
db, study_id, site_id=site_id, status=status_filter, subject_no=subject_no
|
||||||
|
)
|
||||||
return list(subjects)
|
return list(subjects)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{subject_id}",
|
||||||
|
response_model=SubjectRead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_subject(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SubjectRead:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
|
||||||
|
return subject
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{subject_id}",
|
"/{subject_id}",
|
||||||
response_model=SubjectRead,
|
response_model=SubjectRead,
|
||||||
@@ -100,3 +120,31 @@ async def update_subject(
|
|||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{subject_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_subject(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
subject = await subject_crud.get_subject(db, subject_id)
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
|
||||||
|
await subject_crud.delete_subject(db, subject)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="subject",
|
||||||
|
entity_id=subject_id,
|
||||||
|
action="DELETE_SUBJECT",
|
||||||
|
detail=f"Subject {subject_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.crud import verification as verification_crud
|
|
||||||
from app.crud import study as study_crud
|
|
||||||
from app.schemas.verification import VerificationCreate, VerificationRead, VerificationUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
|
||||||
study = await study_crud.get(db, study_id)
|
|
||||||
if not study:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
|
||||||
return study
|
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
|
||||||
"/",
|
|
||||||
response_model=VerificationRead,
|
|
||||||
status_code=status.HTTP_200_OK,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
|
||||||
)
|
|
||||||
async def upsert_verification(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
payload: VerificationCreate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> VerificationRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
try:
|
|
||||||
vp = await verification_crud.upsert_progress(db, study_id, payload)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="verification",
|
|
||||||
entity_id=vp.id,
|
|
||||||
action="UPSERT_VERIFICATION",
|
|
||||||
detail=f"{payload.level} progress subject {payload.subject_id} upserted",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return VerificationRead.model_validate(vp)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/",
|
|
||||||
response_model=list[VerificationRead],
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def list_verifications(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
level: str | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> list[VerificationRead]:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
items = await verification_crud.list_progress(db, study_id, site_id=site_id, subject_id=subject_id, level=level)
|
|
||||||
return [VerificationRead.model_validate(i) for i in items]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/subjects/{subject_id}/{level}",
|
|
||||||
response_model=VerificationRead,
|
|
||||||
dependencies=[Depends(require_study_member())],
|
|
||||||
)
|
|
||||||
async def get_verification(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
subject_id: uuid.UUID,
|
|
||||||
level: str,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
) -> VerificationRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
vp = await verification_crud.get_progress(db, study_id, subject_id, level)
|
|
||||||
if not vp:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Verification not found")
|
|
||||||
return VerificationRead.model_validate(vp)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{verification_id}",
|
|
||||||
response_model=VerificationRead,
|
|
||||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
|
||||||
)
|
|
||||||
async def update_verification(
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
verification_id: uuid.UUID,
|
|
||||||
payload: VerificationUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db_session),
|
|
||||||
current_user=Depends(get_current_user),
|
|
||||||
) -> VerificationRead:
|
|
||||||
await _ensure_study_exists(db, study_id)
|
|
||||||
vp = await verification_crud.get_by_id(db, verification_id)
|
|
||||||
if not vp or vp.study_id != study_id:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Verification not found")
|
|
||||||
|
|
||||||
updated = await verification_crud.update_progress(db, vp, payload)
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="verification",
|
|
||||||
entity_id=verification_id,
|
|
||||||
action="UPSERT_VERIFICATION",
|
|
||||||
detail=f"{updated.level} progress subject {updated.subject_id} updated",
|
|
||||||
operator_id=current_user.id,
|
|
||||||
operator_role=current_user.role,
|
|
||||||
)
|
|
||||||
return VerificationRead.model_validate(updated)
|
|
||||||
@@ -7,7 +7,7 @@ from app.core.deps import get_current_user, get_db_session, require_study_member
|
|||||||
from app.crud import audit as audit_crud
|
from app.crud import audit as audit_crud
|
||||||
from app.crud import subject as subject_crud
|
from app.crud import subject as subject_crud
|
||||||
from app.crud import visit as visit_crud
|
from app.crud import visit as visit_crud
|
||||||
from app.schemas.visit import VisitRead, VisitUpdate
|
from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -34,6 +34,44 @@ async def list_visits(
|
|||||||
return list(visits)
|
return list(visits)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
response_model=VisitRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def create_visit(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
visit_in: VisitCreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> VisitRead:
|
||||||
|
subject = await _ensure_subject(db, study_id, subject_id)
|
||||||
|
if visit_in.subject_id != subject_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Subject mismatch")
|
||||||
|
visit = await visit_crud.create_visit(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
visit_in=visit_in,
|
||||||
|
subject=subject,
|
||||||
|
visit_code=visit_in.visit_code,
|
||||||
|
visit_name=visit_in.visit_name,
|
||||||
|
planned_date=visit_in.planned_date,
|
||||||
|
)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="visit",
|
||||||
|
entity_id=visit.id,
|
||||||
|
action="CREATE_VISIT",
|
||||||
|
detail=f"Visit {visit.visit_code} created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
return visit
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
@router.patch(
|
||||||
"/{visit_id}",
|
"/{visit_id}",
|
||||||
response_model=VisitRead,
|
response_model=VisitRead,
|
||||||
@@ -68,3 +106,32 @@ async def update_visit(
|
|||||||
operator_role=current_user.role,
|
operator_role=current_user.role,
|
||||||
)
|
)
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{visit_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||||
|
)
|
||||||
|
async def delete_visit(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
visit_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> None:
|
||||||
|
await _ensure_subject(db, study_id, subject_id)
|
||||||
|
visit = await visit_crud.get_visit(db, visit_id)
|
||||||
|
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Visit not found")
|
||||||
|
await visit_crud.delete_visit(db, visit)
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="visit",
|
||||||
|
entity_id=visit_id,
|
||||||
|
action="DELETE_VISIT",
|
||||||
|
detail=f"Visit {visit_id} deleted",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"]
|
|
||||||
AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"]
|
|
||||||
AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"]
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"]
|
|
||||||
FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
IMP_TX_TYPES = [
|
|
||||||
"RECEIPT",
|
|
||||||
"DISPENSE",
|
|
||||||
"RETURN",
|
|
||||||
"RECONCILE",
|
|
||||||
"DESTROY",
|
|
||||||
"TRANSFER_IN",
|
|
||||||
"TRANSFER_OUT",
|
|
||||||
]
|
|
||||||
IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"]
|
|
||||||
ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
|
||||||
ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"]
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"]
|
|
||||||
VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"]
|
|
||||||
@@ -113,3 +113,8 @@ async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(ae)
|
await db.refresh(ae)
|
||||||
return ae
|
return ae
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_ae(db: AsyncSession, ae: AdverseEvent) -> None:
|
||||||
|
await db.delete(ae)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.comment import Comment
|
|
||||||
from app.schemas.comment import CommentCreate
|
|
||||||
|
|
||||||
|
|
||||||
async def create_comment(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
entity_type: str,
|
|
||||||
entity_id: uuid.UUID,
|
|
||||||
comment_in: CommentCreate,
|
|
||||||
created_by: uuid.UUID,
|
|
||||||
) -> Comment:
|
|
||||||
comment = Comment(
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=entity_id,
|
|
||||||
content=comment_in.content,
|
|
||||||
quote_comment_id=comment_in.quote_comment_id,
|
|
||||||
created_by=created_by,
|
|
||||||
)
|
|
||||||
db.add(comment)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(comment)
|
|
||||||
return comment
|
|
||||||
|
|
||||||
|
|
||||||
async def list_comments(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
entity_type: str,
|
|
||||||
entity_id: uuid.UUID,
|
|
||||||
) -> Sequence[Comment]:
|
|
||||||
result = await db.execute(
|
|
||||||
select(Comment)
|
|
||||||
.where(
|
|
||||||
Comment.study_id == study_id,
|
|
||||||
Comment.entity_type == entity_type,
|
|
||||||
Comment.entity_id == entity_id,
|
|
||||||
Comment.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
.order_by(Comment.created_at.asc())
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_comment(db: AsyncSession, comment_id: uuid.UUID, *, include_deleted: bool = False) -> Comment | None:
|
|
||||||
stmt = select(Comment).where(Comment.id == comment_id)
|
|
||||||
if not include_deleted:
|
|
||||||
stmt = stmt.where(Comment.is_deleted.is_(False))
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_comments_by_ids(
|
|
||||||
db: AsyncSession,
|
|
||||||
ids: set[uuid.UUID],
|
|
||||||
*,
|
|
||||||
include_deleted: bool = False,
|
|
||||||
) -> dict[uuid.UUID, Comment]:
|
|
||||||
if not ids:
|
|
||||||
return {}
|
|
||||||
stmt = select(Comment).where(Comment.id.in_(ids))
|
|
||||||
if not include_deleted:
|
|
||||||
stmt = stmt.where(Comment.is_deleted.is_(False))
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
comments = result.scalars().all()
|
|
||||||
return {c.id: c for c in comments}
|
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete_comment(db: AsyncSession, comment: Comment) -> Comment:
|
|
||||||
comment.is_deleted = True
|
|
||||||
db.add(comment)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(comment)
|
|
||||||
return comment
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime, timezone
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.data_query import DataQuery
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.subject import Subject
|
|
||||||
from app.schemas.data_query import DataQueryCreate, DataQueryUpdate
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
|
|
||||||
if site_id:
|
|
||||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
||||||
site = result.scalar_one_or_none()
|
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise ValueError("Site not found in study")
|
|
||||||
subject_site = None
|
|
||||||
if subject_id:
|
|
||||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
|
||||||
subj = result.scalar_one_or_none()
|
|
||||||
if not subj or subj.study_id != study_id:
|
|
||||||
raise ValueError("Subject not found in study")
|
|
||||||
subject_site = subj.site_id
|
|
||||||
if site_id and subject_site and site_id != subject_site:
|
|
||||||
raise ValueError("Site and subject mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_query(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
query_in: DataQueryCreate,
|
|
||||||
*,
|
|
||||||
created_by: uuid.UUID,
|
|
||||||
) -> DataQuery:
|
|
||||||
await _validate_site_subject(db, study_id, query_in.site_id, query_in.subject_id)
|
|
||||||
dq = DataQuery(
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=query_in.site_id,
|
|
||||||
subject_id=query_in.subject_id,
|
|
||||||
visit_id=query_in.visit_id,
|
|
||||||
title=query_in.title,
|
|
||||||
description=query_in.description,
|
|
||||||
category=query_in.category,
|
|
||||||
priority=query_in.priority,
|
|
||||||
assigned_to=query_in.assigned_to,
|
|
||||||
due_date=query_in.due_date,
|
|
||||||
status="OPEN",
|
|
||||||
resolution=None,
|
|
||||||
closed_at=None,
|
|
||||||
created_by=created_by,
|
|
||||||
)
|
|
||||||
db.add(dq)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(dq)
|
|
||||||
return dq
|
|
||||||
|
|
||||||
|
|
||||||
async def get_query(db: AsyncSession, query_id: uuid.UUID) -> DataQuery | None:
|
|
||||||
result = await db.execute(select(DataQuery).where(DataQuery.id == query_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_queries(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
*,
|
|
||||||
status: str | None = None,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
assigned_to: uuid.UUID | None = None,
|
|
||||||
overdue: bool | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
priority: str | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> Sequence[DataQuery]:
|
|
||||||
stmt = select(DataQuery).where(DataQuery.study_id == study_id)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(DataQuery.status == status)
|
|
||||||
if site_id:
|
|
||||||
stmt = stmt.where(DataQuery.site_id == site_id)
|
|
||||||
if subject_id:
|
|
||||||
stmt = stmt.where(DataQuery.subject_id == subject_id)
|
|
||||||
if assigned_to:
|
|
||||||
stmt = stmt.where(DataQuery.assigned_to == assigned_to)
|
|
||||||
if category:
|
|
||||||
stmt = stmt.where(DataQuery.category == category)
|
|
||||||
if priority:
|
|
||||||
stmt = stmt.where(DataQuery.priority == priority)
|
|
||||||
if overdue is True:
|
|
||||||
stmt = stmt.where(DataQuery.due_date < date.today(), DataQuery.status != "CLOSED")
|
|
||||||
if overdue is False:
|
|
||||||
stmt = stmt.where((DataQuery.due_date >= date.today()) | (DataQuery.due_date.is_(None)) | (DataQuery.status == "CLOSED"))
|
|
||||||
stmt = stmt.offset(skip).limit(limit)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_query(db: AsyncSession, dq: DataQuery, dq_in: DataQueryUpdate) -> DataQuery:
|
|
||||||
update_data = dq_in.model_dump(exclude_unset=True)
|
|
||||||
if "status" in update_data:
|
|
||||||
if update_data["status"] == "CLOSED":
|
|
||||||
update_data["closed_at"] = datetime.now(timezone.utc)
|
|
||||||
else:
|
|
||||||
update_data["closed_at"] = None
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(DataQuery)
|
|
||||||
.where(DataQuery.id == dq.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(dq)
|
|
||||||
return dq
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.drug_shipment import DrugShipment
|
||||||
|
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_shipment(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
shipment_in: DrugShipmentCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> DrugShipment:
|
||||||
|
shipment = DrugShipment(
|
||||||
|
study_id=study_id,
|
||||||
|
direction=shipment_in.direction,
|
||||||
|
site_name=shipment_in.site_name,
|
||||||
|
drug_desc=shipment_in.drug_desc,
|
||||||
|
ship_date=shipment_in.ship_date,
|
||||||
|
carrier=shipment_in.carrier,
|
||||||
|
tracking_no=shipment_in.tracking_no,
|
||||||
|
from_party=shipment_in.from_party,
|
||||||
|
to_party=shipment_in.to_party,
|
||||||
|
status=shipment_in.status,
|
||||||
|
remark=shipment_in.remark,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(shipment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(shipment)
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shipment(db: AsyncSession, shipment_id: uuid.UUID) -> DrugShipment | None:
|
||||||
|
result = await db.execute(select(DrugShipment).where(DrugShipment.id == shipment_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_shipments(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
tracking_no: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DrugShipment]:
|
||||||
|
stmt = select(DrugShipment).where(DrugShipment.study_id == study_id)
|
||||||
|
if site_name:
|
||||||
|
stmt = stmt.where(DrugShipment.site_name.ilike(f"%{site_name}%"))
|
||||||
|
if tracking_no:
|
||||||
|
stmt = stmt.where(DrugShipment.tracking_no.ilike(f"%{tracking_no}%"))
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(DrugShipment.status == status)
|
||||||
|
stmt = stmt.order_by(DrugShipment.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_shipment(
|
||||||
|
db: AsyncSession, shipment: DrugShipment, shipment_in: DrugShipmentUpdate
|
||||||
|
) -> DrugShipment:
|
||||||
|
update_data = shipment_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(shipment, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(shipment)
|
||||||
|
return shipment
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_shipment(db: AsyncSession, shipment: DrugShipment) -> None:
|
||||||
|
await db.delete(shipment)
|
||||||
|
await db.commit()
|
||||||
+2
-142
@@ -1,150 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import Numeric, func, select, update as sa_update, case
|
from sqlalchemy import case, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.finance import FinanceItem
|
from app.models.finance import FinanceItem
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.subject import Subject
|
|
||||||
from app.schemas.finance import FinanceCreate, FinanceStatusUpdate, FinanceUpdate
|
|
||||||
|
|
||||||
VALID_TRANSITIONS = {
|
|
||||||
"DRAFT": {"SUBMITTED"},
|
|
||||||
"SUBMITTED": {"APPROVED", "REJECTED"},
|
|
||||||
"APPROVED": {"PAID"},
|
|
||||||
"REJECTED": set(),
|
|
||||||
"PAID": set(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
|
|
||||||
subj_site = None
|
|
||||||
if site_id:
|
|
||||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
||||||
site = result.scalar_one_or_none()
|
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise ValueError("Site not found in study")
|
|
||||||
if subject_id:
|
|
||||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
|
||||||
subj = result.scalar_one_or_none()
|
|
||||||
if not subj or subj.study_id != study_id:
|
|
||||||
raise ValueError("Subject not found in study")
|
|
||||||
subj_site = subj.site_id
|
|
||||||
if site_id and subj_site and site_id != subj_site:
|
|
||||||
raise ValueError("Site and subject mismatch")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_item(db: AsyncSession, study_id: uuid.UUID, item_in: FinanceCreate, *, created_by: uuid.UUID) -> FinanceItem:
|
|
||||||
await _validate_site_subject(db, study_id, item_in.site_id, item_in.subject_id)
|
|
||||||
item = FinanceItem(
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=item_in.site_id,
|
|
||||||
subject_id=item_in.subject_id,
|
|
||||||
visit_id=item_in.visit_id,
|
|
||||||
category=item_in.category,
|
|
||||||
title=item_in.title,
|
|
||||||
description=item_in.description,
|
|
||||||
currency=item_in.currency,
|
|
||||||
amount=item_in.amount,
|
|
||||||
occur_date=item_in.occur_date,
|
|
||||||
status="DRAFT",
|
|
||||||
created_by=created_by,
|
|
||||||
)
|
|
||||||
db.add(item)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
async def get_item(db: AsyncSession, item_id: uuid.UUID) -> FinanceItem | None:
|
|
||||||
result = await db.execute(select(FinanceItem).where(FinanceItem.id == item_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_items(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
*,
|
|
||||||
status: str | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
date_from: date | None = None,
|
|
||||||
date_to: date | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> Sequence[FinanceItem]:
|
|
||||||
stmt = select(FinanceItem).where(FinanceItem.study_id == study_id)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(FinanceItem.status == status)
|
|
||||||
if category:
|
|
||||||
stmt = stmt.where(FinanceItem.category == category)
|
|
||||||
if site_id:
|
|
||||||
stmt = stmt.where(FinanceItem.site_id == site_id)
|
|
||||||
if subject_id:
|
|
||||||
stmt = stmt.where(FinanceItem.subject_id == subject_id)
|
|
||||||
if date_from:
|
|
||||||
stmt = stmt.where(FinanceItem.occur_date >= date_from)
|
|
||||||
if date_to:
|
|
||||||
stmt = stmt.where(FinanceItem.occur_date <= date_to)
|
|
||||||
stmt = stmt.offset(skip).limit(limit)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_item_draft(db: AsyncSession, item: FinanceItem, item_in: FinanceUpdate) -> FinanceItem:
|
|
||||||
if item.status != "DRAFT":
|
|
||||||
raise ValueError("Only DRAFT items can be edited")
|
|
||||||
update_data = item_in.model_dump(exclude_unset=True)
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(FinanceItem)
|
|
||||||
.where(FinanceItem.id == item.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
async def change_status(db: AsyncSession, item: FinanceItem, status_in: FinanceStatusUpdate, *, operator_id: uuid.UUID) -> FinanceItem:
|
|
||||||
target = status_in.status
|
|
||||||
allowed = VALID_TRANSITIONS.get(item.status, set())
|
|
||||||
if target not in allowed:
|
|
||||||
raise ValueError(f"Invalid status transition {item.status} -> {target}")
|
|
||||||
update_data = {"status": target}
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
if target == "SUBMITTED":
|
|
||||||
update_data["submitted_at"] = now
|
|
||||||
if target == "APPROVED":
|
|
||||||
update_data["approved_at"] = now
|
|
||||||
update_data["approver_id"] = operator_id
|
|
||||||
update_data["reject_reason"] = None
|
|
||||||
if target == "REJECTED":
|
|
||||||
if not status_in.reject_reason:
|
|
||||||
raise ValueError("reject_reason required")
|
|
||||||
update_data["rejected_at"] = now
|
|
||||||
update_data["approver_id"] = operator_id
|
|
||||||
update_data["reject_reason"] = status_in.reject_reason
|
|
||||||
if target == "PAID":
|
|
||||||
if item.status != "APPROVED":
|
|
||||||
raise ValueError("Only APPROVED can transition to PAID")
|
|
||||||
update_data["paid_at"] = now
|
|
||||||
update_data["payer_id"] = operator_id
|
|
||||||
|
|
||||||
await db.execute(
|
|
||||||
sa_update(FinanceItem)
|
|
||||||
.where(FinanceItem.id == item.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(item)
|
|
||||||
return item
|
|
||||||
|
|
||||||
|
|
||||||
async def summary(
|
async def summary(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.finance_contract import FinanceContract
|
||||||
|
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_contract(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
contract_in: FinanceContractCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> FinanceContract:
|
||||||
|
contract = FinanceContract(
|
||||||
|
study_id=study_id,
|
||||||
|
site_name=contract_in.site_name,
|
||||||
|
contract_no=contract_in.contract_no,
|
||||||
|
signed_date=contract_in.signed_date,
|
||||||
|
amount=contract_in.amount,
|
||||||
|
currency=contract_in.currency,
|
||||||
|
remark=contract_in.remark,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(contract)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
async def get_contract(db: AsyncSession, contract_id: uuid.UUID) -> FinanceContract | None:
|
||||||
|
result = await db.execute(select(FinanceContract).where(FinanceContract.id == contract_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_contracts(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
contract_no: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[FinanceContract]:
|
||||||
|
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
|
||||||
|
if site_name:
|
||||||
|
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
|
||||||
|
if contract_no:
|
||||||
|
stmt = stmt.where(FinanceContract.contract_no.ilike(f"%{contract_no}%"))
|
||||||
|
stmt = stmt.order_by(FinanceContract.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_contract(
|
||||||
|
db: AsyncSession, contract: FinanceContract, contract_in: FinanceContractUpdate
|
||||||
|
) -> FinanceContract:
|
||||||
|
update_data = contract_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(contract, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(contract)
|
||||||
|
return contract
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_contract(db: AsyncSession, contract: FinanceContract) -> None:
|
||||||
|
await db.delete(contract)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.finance_special import FinanceSpecial
|
||||||
|
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_special(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
special_in: FinanceSpecialCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> FinanceSpecial:
|
||||||
|
special = FinanceSpecial(
|
||||||
|
study_id=study_id,
|
||||||
|
site_name=special_in.site_name,
|
||||||
|
fee_type=special_in.fee_type,
|
||||||
|
amount=special_in.amount,
|
||||||
|
occur_date=special_in.occur_date,
|
||||||
|
staff_name=special_in.staff_name,
|
||||||
|
remark=special_in.remark,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(special)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(special)
|
||||||
|
return special
|
||||||
|
|
||||||
|
|
||||||
|
async def get_special(db: AsyncSession, special_id: uuid.UUID) -> FinanceSpecial | None:
|
||||||
|
result = await db.execute(select(FinanceSpecial).where(FinanceSpecial.id == special_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_specials(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
fee_type: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[FinanceSpecial]:
|
||||||
|
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
|
||||||
|
if site_name:
|
||||||
|
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
|
||||||
|
if fee_type:
|
||||||
|
stmt = stmt.where(FinanceSpecial.fee_type == fee_type)
|
||||||
|
stmt = stmt.order_by(FinanceSpecial.created_at.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_special(
|
||||||
|
db: AsyncSession, special: FinanceSpecial, special_in: FinanceSpecialUpdate
|
||||||
|
) -> FinanceSpecial:
|
||||||
|
update_data = special_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(special, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(special)
|
||||||
|
return special
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_special(db: AsyncSession, special: FinanceSpecial) -> None:
|
||||||
|
await db.delete(special)
|
||||||
|
await db.commit()
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.imp_batch import ImpBatch
|
|
||||||
from app.models.imp_product import ImpProduct
|
|
||||||
from app.schemas.imp import BatchCreate, BatchUpdate
|
|
||||||
|
|
||||||
|
|
||||||
async def create_batch(db: AsyncSession, study_id: uuid.UUID, batch_in: BatchCreate) -> ImpBatch:
|
|
||||||
result = await db.execute(select(ImpProduct).where(ImpProduct.id == batch_in.product_id))
|
|
||||||
product = result.scalar_one_or_none()
|
|
||||||
if not product or product.study_id != study_id:
|
|
||||||
raise ValueError("Product not found in study")
|
|
||||||
|
|
||||||
batch = ImpBatch(
|
|
||||||
study_id=study_id,
|
|
||||||
product_id=batch_in.product_id,
|
|
||||||
batch_no=batch_in.batch_no,
|
|
||||||
expiry_date=batch_in.expiry_date,
|
|
||||||
manufacture_date=batch_in.manufacture_date,
|
|
||||||
status="ACTIVE",
|
|
||||||
)
|
|
||||||
db.add(batch)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(batch)
|
|
||||||
return batch
|
|
||||||
|
|
||||||
|
|
||||||
async def get_batch(db: AsyncSession, batch_id: uuid.UUID) -> ImpBatch | None:
|
|
||||||
result = await db.execute(select(ImpBatch).where(ImpBatch.id == batch_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_batches(db: AsyncSession, study_id: uuid.UUID, product_id: uuid.UUID | None = None, status: str | None = None) -> Sequence[ImpBatch]:
|
|
||||||
stmt = select(ImpBatch).where(ImpBatch.study_id == study_id)
|
|
||||||
if product_id:
|
|
||||||
stmt = stmt.where(ImpBatch.product_id == product_id)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(ImpBatch.status == status)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_batch(db: AsyncSession, batch: ImpBatch, batch_in: BatchUpdate) -> ImpBatch:
|
|
||||||
update_data = batch_in.model_dump(exclude_unset=True)
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(ImpBatch)
|
|
||||||
.where(ImpBatch.id == batch.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(batch)
|
|
||||||
return batch
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.imp_inventory import ImpInventory
|
|
||||||
from app.models.imp_batch import ImpBatch
|
|
||||||
|
|
||||||
|
|
||||||
async def get_inventory_for_update(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory | None:
|
|
||||||
result = await db.execute(
|
|
||||||
select(ImpInventory)
|
|
||||||
.where(
|
|
||||||
ImpInventory.study_id == study_id,
|
|
||||||
ImpInventory.site_id == site_id,
|
|
||||||
ImpInventory.batch_id == batch_id,
|
|
||||||
)
|
|
||||||
.with_for_update()
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_inventory(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory:
|
|
||||||
inv = await get_inventory_for_update(db, study_id, site_id, batch_id)
|
|
||||||
if inv:
|
|
||||||
return inv
|
|
||||||
inv = ImpInventory(study_id=study_id, site_id=site_id, batch_id=batch_id, quantity_on_hand=0)
|
|
||||||
db.add(inv)
|
|
||||||
await db.flush()
|
|
||||||
return inv
|
|
||||||
|
|
||||||
|
|
||||||
async def list_inventory(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
product_id: uuid.UUID | None = None,
|
|
||||||
) -> Sequence[ImpInventory]:
|
|
||||||
stmt = select(ImpInventory).where(ImpInventory.study_id == study_id)
|
|
||||||
if site_id:
|
|
||||||
stmt = stmt.where(ImpInventory.site_id == site_id)
|
|
||||||
if product_id:
|
|
||||||
stmt = stmt.join(ImpBatch, ImpBatch.id == ImpInventory.batch_id).where(ImpBatch.product_id == product_id)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.imp_product import ImpProduct
|
|
||||||
from app.schemas.imp import ProductCreate, ProductUpdate
|
|
||||||
|
|
||||||
|
|
||||||
async def create_product(db: AsyncSession, study_id: uuid.UUID, product_in: ProductCreate) -> ImpProduct:
|
|
||||||
product = ImpProduct(
|
|
||||||
study_id=study_id,
|
|
||||||
name=product_in.name,
|
|
||||||
form=product_in.form,
|
|
||||||
strength=product_in.strength,
|
|
||||||
unit=product_in.unit,
|
|
||||||
description=product_in.description,
|
|
||||||
is_active=product_in.is_active,
|
|
||||||
)
|
|
||||||
db.add(product)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(product)
|
|
||||||
return product
|
|
||||||
|
|
||||||
|
|
||||||
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> ImpProduct | None:
|
|
||||||
result = await db.execute(select(ImpProduct).where(ImpProduct.id == product_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_products(db: AsyncSession, study_id: uuid.UUID, is_active: bool | None = None) -> Sequence[ImpProduct]:
|
|
||||||
stmt = select(ImpProduct).where(ImpProduct.study_id == study_id)
|
|
||||||
if is_active is not None:
|
|
||||||
stmt = stmt.where(ImpProduct.is_active == is_active)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_product(db: AsyncSession, product: ImpProduct, product_in: ProductUpdate) -> ImpProduct:
|
|
||||||
update_data = product_in.model_dump(exclude_unset=True)
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(ImpProduct)
|
|
||||||
.where(ImpProduct.id == product.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(product)
|
|
||||||
return product
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.imp_transaction import ImpTransaction
|
|
||||||
|
|
||||||
|
|
||||||
async def create_tx(
|
|
||||||
db: AsyncSession,
|
|
||||||
*,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID,
|
|
||||||
batch_id: uuid.UUID,
|
|
||||||
subject_id: uuid.UUID | None,
|
|
||||||
tx_type: str,
|
|
||||||
quantity: int,
|
|
||||||
tx_date: date,
|
|
||||||
reference: str | None,
|
|
||||||
notes: str | None,
|
|
||||||
created_by: uuid.UUID,
|
|
||||||
) -> ImpTransaction:
|
|
||||||
tx = ImpTransaction(
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=site_id,
|
|
||||||
batch_id=batch_id,
|
|
||||||
subject_id=subject_id,
|
|
||||||
tx_type=tx_type,
|
|
||||||
quantity=quantity,
|
|
||||||
tx_date=tx_date,
|
|
||||||
reference=reference,
|
|
||||||
notes=notes,
|
|
||||||
created_by=created_by,
|
|
||||||
)
|
|
||||||
db.add(tx)
|
|
||||||
await db.flush()
|
|
||||||
return tx
|
|
||||||
|
|
||||||
|
|
||||||
async def list_txs(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
*,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
batch_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
tx_type: str | None = None,
|
|
||||||
date_from: date | None = None,
|
|
||||||
date_to: date | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
) -> Sequence[ImpTransaction]:
|
|
||||||
stmt = select(ImpTransaction).where(ImpTransaction.study_id == study_id)
|
|
||||||
if site_id:
|
|
||||||
stmt = stmt.where(ImpTransaction.site_id == site_id)
|
|
||||||
if batch_id:
|
|
||||||
stmt = stmt.where(ImpTransaction.batch_id == batch_id)
|
|
||||||
if subject_id:
|
|
||||||
stmt = stmt.where(ImpTransaction.subject_id == subject_id)
|
|
||||||
if tx_type:
|
|
||||||
stmt = stmt.where(ImpTransaction.tx_type == tx_type)
|
|
||||||
if date_from:
|
|
||||||
stmt = stmt.where(ImpTransaction.tx_date >= date_from)
|
|
||||||
if date_to:
|
|
||||||
stmt = stmt.where(ImpTransaction.tx_date <= date_to)
|
|
||||||
stmt = stmt.offset(skip).limit(limit)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime, timezone
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.issue import Issue
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.subject import Subject
|
|
||||||
from app.schemas.issue import IssueCreate, IssueUpdate
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
|
|
||||||
if site_id:
|
|
||||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
|
||||||
site = result.scalar_one_or_none()
|
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise ValueError("Site not found in study")
|
|
||||||
if subject_id:
|
|
||||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
|
||||||
subj = result.scalar_one_or_none()
|
|
||||||
if not subj or subj.study_id != study_id:
|
|
||||||
raise ValueError("Subject not found in study")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_issue(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
issue_in: IssueCreate,
|
|
||||||
*,
|
|
||||||
created_by: uuid.UUID,
|
|
||||||
) -> Issue:
|
|
||||||
await _validate_site_subject(db, study_id, issue_in.site_id, issue_in.subject_id)
|
|
||||||
issue = Issue(
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=issue_in.site_id,
|
|
||||||
subject_id=issue_in.subject_id,
|
|
||||||
title=issue_in.title,
|
|
||||||
description=issue_in.description,
|
|
||||||
category=issue_in.category,
|
|
||||||
level=issue_in.level,
|
|
||||||
owner_id=issue_in.owner_id,
|
|
||||||
due_date=issue_in.due_date,
|
|
||||||
status="OPEN",
|
|
||||||
capa=None,
|
|
||||||
closed_at=None,
|
|
||||||
created_by=created_by,
|
|
||||||
)
|
|
||||||
db.add(issue)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(issue)
|
|
||||||
return issue
|
|
||||||
|
|
||||||
|
|
||||||
async def get_issue(db: AsyncSession, issue_id: uuid.UUID) -> Issue | None:
|
|
||||||
result = await db.execute(select(Issue).where(Issue.id == issue_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_issues(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
status: str | None = None,
|
|
||||||
level: str | None = None,
|
|
||||||
category: str | None = None,
|
|
||||||
overdue: bool | None = None,
|
|
||||||
) -> Sequence[Issue]:
|
|
||||||
stmt = select(Issue).where(Issue.study_id == study_id)
|
|
||||||
if status:
|
|
||||||
stmt = stmt.where(Issue.status == status)
|
|
||||||
if level:
|
|
||||||
stmt = stmt.where(Issue.level == level)
|
|
||||||
if category:
|
|
||||||
stmt = stmt.where(Issue.category == category)
|
|
||||||
if overdue is True:
|
|
||||||
stmt = stmt.where(Issue.due_date < date.today(), Issue.status != "CLOSED")
|
|
||||||
if overdue is False:
|
|
||||||
stmt = stmt.where((Issue.due_date >= date.today()) | (Issue.due_date.is_(None)) | (Issue.status == "CLOSED"))
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_issue(db: AsyncSession, issue: Issue, issue_in: IssueUpdate) -> Issue:
|
|
||||||
update_data = issue_in.model_dump(exclude_unset=True)
|
|
||||||
if "status" in update_data:
|
|
||||||
if update_data["status"] == "CLOSED":
|
|
||||||
update_data["closed_at"] = datetime.now(timezone.utc)
|
|
||||||
else:
|
|
||||||
update_data["closed_at"] = None
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(Issue)
|
|
||||||
.where(Issue.id == issue.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(issue)
|
|
||||||
return issue
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.knowledge_note import KnowledgeNote
|
||||||
|
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def create_note(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
note_in: KnowledgeNoteCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> KnowledgeNote:
|
||||||
|
note = KnowledgeNote(
|
||||||
|
study_id=study_id,
|
||||||
|
site_name=note_in.site_name,
|
||||||
|
title=note_in.title,
|
||||||
|
content=note_in.content,
|
||||||
|
level=note_in.level,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(note)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(note)
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
async def get_note(db: AsyncSession, note_id: uuid.UUID) -> KnowledgeNote | None:
|
||||||
|
result = await db.execute(select(KnowledgeNote).where(KnowledgeNote.id == note_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_notes(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
site_name: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[KnowledgeNote]:
|
||||||
|
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
|
||||||
|
if site_name:
|
||||||
|
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.where(KnowledgeNote.title.ilike(f"%{keyword}%"))
|
||||||
|
stmt = stmt.order_by(KnowledgeNote.updated_at.desc()).offset(skip).limit(limit)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_note(
|
||||||
|
db: AsyncSession, note: KnowledgeNote, note_in: KnowledgeNoteUpdate
|
||||||
|
) -> KnowledgeNote:
|
||||||
|
update_data = note_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(note, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(note)
|
||||||
|
return note
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_note(db: AsyncSession, note: KnowledgeNote) -> None:
|
||||||
|
await db.delete(note)
|
||||||
|
await db.commit()
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update as sa_update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.milestone import Milestone
|
|
||||||
from app.schemas.milestone import MilestoneCreate, MilestoneUpdate
|
|
||||||
|
|
||||||
|
|
||||||
async def create(db: AsyncSession, study_id: uuid.UUID, milestone_in: MilestoneCreate) -> Milestone:
|
|
||||||
milestone = Milestone(
|
|
||||||
study_id=study_id,
|
|
||||||
type=milestone_in.type,
|
|
||||||
name=milestone_in.name or milestone_in.type,
|
|
||||||
planned_date=milestone_in.planned_date,
|
|
||||||
actual_date=None,
|
|
||||||
status=milestone_in.status or "NOT_STARTED",
|
|
||||||
owner_id=milestone_in.owner_id,
|
|
||||||
site_id=milestone_in.site_id,
|
|
||||||
notes=milestone_in.notes,
|
|
||||||
)
|
|
||||||
db.add(milestone)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(milestone)
|
|
||||||
return milestone
|
|
||||||
|
|
||||||
|
|
||||||
async def get(db: AsyncSession, milestone_id: uuid.UUID) -> Milestone | None:
|
|
||||||
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]:
|
|
||||||
result = await db.execute(select(Milestone).where(Milestone.study_id == study_id).order_by(Milestone.planned_date))
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update(db: AsyncSession, milestone: Milestone, milestone_in: MilestoneUpdate) -> Milestone:
|
|
||||||
update_data = milestone_in.model_dump(exclude_unset=True)
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(Milestone)
|
|
||||||
.where(Milestone.id == milestone.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(milestone)
|
|
||||||
return milestone
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_milestone(db: AsyncSession, milestone: Milestone) -> None:
|
|
||||||
await db.execute(delete(Milestone).where(Milestone.id == milestone.id))
|
|
||||||
await db.commit()
|
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.startup_feasibility import StartupFeasibility
|
||||||
|
from app.models.startup_ethics import StartupEthics
|
||||||
|
from app.models.kickoff_meeting import KickoffMeeting
|
||||||
|
from app.models.training_authorization import TrainingAuthorization
|
||||||
|
from app.schemas.startup import (
|
||||||
|
KickoffMeetingCreate,
|
||||||
|
KickoffMeetingUpdate,
|
||||||
|
StartupEthicsCreate,
|
||||||
|
StartupEthicsUpdate,
|
||||||
|
StartupFeasibilityCreate,
|
||||||
|
StartupFeasibilityUpdate,
|
||||||
|
TrainingAuthorizationCreate,
|
||||||
|
TrainingAuthorizationUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_feasibility(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_in: StartupFeasibilityCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> StartupFeasibility:
|
||||||
|
record = StartupFeasibility(
|
||||||
|
study_id=study_id,
|
||||||
|
submit_date=record_in.submit_date,
|
||||||
|
accept_date=record_in.accept_date,
|
||||||
|
approved_date=record_in.approved_date,
|
||||||
|
project_no=record_in.project_no,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def get_feasibility(db: AsyncSession, record_id: uuid.UUID) -> StartupFeasibility | None:
|
||||||
|
result = await db.execute(select(StartupFeasibility).where(StartupFeasibility.id == record_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_feasibilities(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[StartupFeasibility]:
|
||||||
|
stmt = (
|
||||||
|
select(StartupFeasibility)
|
||||||
|
.where(StartupFeasibility.study_id == study_id)
|
||||||
|
.order_by(StartupFeasibility.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_feasibility(
|
||||||
|
db: AsyncSession, record: StartupFeasibility, record_in: StartupFeasibilityUpdate
|
||||||
|
) -> StartupFeasibility:
|
||||||
|
update_data = record_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(record, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_feasibility(db: AsyncSession, record: StartupFeasibility) -> None:
|
||||||
|
await db.delete(record)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_ethics(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_in: StartupEthicsCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> StartupEthics:
|
||||||
|
record = StartupEthics(
|
||||||
|
study_id=study_id,
|
||||||
|
submit_date=record_in.submit_date,
|
||||||
|
accept_date=record_in.accept_date,
|
||||||
|
meeting_date=record_in.meeting_date,
|
||||||
|
approved_date=record_in.approved_date,
|
||||||
|
approval_no=record_in.approval_no,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ethics(db: AsyncSession, record_id: uuid.UUID) -> StartupEthics | None:
|
||||||
|
result = await db.execute(select(StartupEthics).where(StartupEthics.id == record_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_ethics(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[StartupEthics]:
|
||||||
|
stmt = (
|
||||||
|
select(StartupEthics)
|
||||||
|
.where(StartupEthics.study_id == study_id)
|
||||||
|
.order_by(StartupEthics.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_ethics(
|
||||||
|
db: AsyncSession, record: StartupEthics, record_in: StartupEthicsUpdate
|
||||||
|
) -> StartupEthics:
|
||||||
|
update_data = record_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(record, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_ethics(db: AsyncSession, record: StartupEthics) -> None:
|
||||||
|
await db.delete(record)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_kickoff(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
meeting_in: KickoffMeetingCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> KickoffMeeting:
|
||||||
|
meeting = KickoffMeeting(
|
||||||
|
study_id=study_id,
|
||||||
|
kickoff_date=meeting_in.kickoff_date,
|
||||||
|
attendees=meeting_in.attendees,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(meeting)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(meeting)
|
||||||
|
return meeting
|
||||||
|
|
||||||
|
|
||||||
|
async def get_kickoff(db: AsyncSession, meeting_id: uuid.UUID) -> KickoffMeeting | None:
|
||||||
|
result = await db.execute(select(KickoffMeeting).where(KickoffMeeting.id == meeting_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_kickoffs(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[KickoffMeeting]:
|
||||||
|
stmt = (
|
||||||
|
select(KickoffMeeting)
|
||||||
|
.where(KickoffMeeting.study_id == study_id)
|
||||||
|
.order_by(KickoffMeeting.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_kickoff(
|
||||||
|
db: AsyncSession, meeting: KickoffMeeting, meeting_in: KickoffMeetingUpdate
|
||||||
|
) -> KickoffMeeting:
|
||||||
|
update_data = meeting_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(meeting, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(meeting)
|
||||||
|
return meeting
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_kickoff(db: AsyncSession, meeting: KickoffMeeting) -> None:
|
||||||
|
await db.delete(meeting)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_training_authorization(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
record_in: TrainingAuthorizationCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> TrainingAuthorization:
|
||||||
|
record = TrainingAuthorization(
|
||||||
|
study_id=study_id,
|
||||||
|
name=record_in.name,
|
||||||
|
role=record_in.role,
|
||||||
|
site_name=record_in.site_name,
|
||||||
|
trained=record_in.trained,
|
||||||
|
authorized=record_in.authorized,
|
||||||
|
trained_date=record_in.trained_date,
|
||||||
|
authorized_date=record_in.authorized_date,
|
||||||
|
remark=record_in.remark,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def get_training_authorization(
|
||||||
|
db: AsyncSession, record_id: uuid.UUID
|
||||||
|
) -> TrainingAuthorization | None:
|
||||||
|
result = await db.execute(select(TrainingAuthorization).where(TrainingAuthorization.id == record_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_training_authorizations(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> Sequence[TrainingAuthorization]:
|
||||||
|
stmt = (
|
||||||
|
select(TrainingAuthorization)
|
||||||
|
.where(TrainingAuthorization.study_id == study_id)
|
||||||
|
.order_by(TrainingAuthorization.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_training_authorization(
|
||||||
|
db: AsyncSession, record: TrainingAuthorization, record_in: TrainingAuthorizationUpdate
|
||||||
|
) -> TrainingAuthorization:
|
||||||
|
update_data = record_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(record, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_training_authorization(db: AsyncSession, record: TrainingAuthorization) -> None:
|
||||||
|
await db.delete(record)
|
||||||
|
await db.commit()
|
||||||
@@ -57,12 +57,15 @@ async def list_subjects(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
site_id: uuid.UUID | None = None,
|
site_id: uuid.UUID | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
|
subject_no: str | None = None,
|
||||||
) -> Sequence[Subject]:
|
) -> Sequence[Subject]:
|
||||||
stmt = select(Subject).where(Subject.study_id == study_id)
|
stmt = select(Subject).where(Subject.study_id == study_id)
|
||||||
if site_id:
|
if site_id:
|
||||||
stmt = stmt.where(Subject.site_id == site_id)
|
stmt = stmt.where(Subject.site_id == site_id)
|
||||||
if status:
|
if status:
|
||||||
stmt = stmt.where(Subject.status == status)
|
stmt = stmt.where(Subject.status == status)
|
||||||
|
if subject_no:
|
||||||
|
stmt = stmt.where(Subject.subject_no.ilike(f"%{subject_no}%"))
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
@@ -102,3 +105,8 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(subject)
|
await db.refresh(subject)
|
||||||
return subject
|
return subject
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_subject(db: AsyncSession, subject: Subject) -> None:
|
||||||
|
await db.delete(subject)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.models.subject_history import SubjectHistory
|
||||||
|
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryUpdate
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID) -> None:
|
||||||
|
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||||
|
subject = result.scalar_one_or_none()
|
||||||
|
if not subject or subject.study_id != study_id:
|
||||||
|
raise ValueError("Subject not found in study")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_history(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
history_in: SubjectHistoryCreate,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
) -> SubjectHistory:
|
||||||
|
await _ensure_subject(db, study_id, history_in.subject_id)
|
||||||
|
history = SubjectHistory(
|
||||||
|
study_id=study_id,
|
||||||
|
subject_id=history_in.subject_id,
|
||||||
|
record_date=history_in.record_date,
|
||||||
|
content=history_in.content,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(history)
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
async def get_history(db: AsyncSession, history_id: uuid.UUID) -> SubjectHistory | None:
|
||||||
|
result = await db.execute(select(SubjectHistory).where(SubjectHistory.id == history_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_histories(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
subject_id: uuid.UUID,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 200,
|
||||||
|
) -> Sequence[SubjectHistory]:
|
||||||
|
stmt = (
|
||||||
|
select(SubjectHistory)
|
||||||
|
.where(SubjectHistory.study_id == study_id, SubjectHistory.subject_id == subject_id)
|
||||||
|
.order_by(SubjectHistory.record_date.desc().nullslast(), SubjectHistory.created_at.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_history(
|
||||||
|
db: AsyncSession, history: SubjectHistory, history_in: SubjectHistoryUpdate
|
||||||
|
) -> SubjectHistory:
|
||||||
|
update_data = history_in.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(history, key, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(history)
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_history(db: AsyncSession, history: SubjectHistory) -> None:
|
||||||
|
await db.delete(history)
|
||||||
|
await db.commit()
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date
|
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from sqlalchemy import select, update as sa_update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.models.subject import Subject
|
|
||||||
from app.models.verification import VerificationProgress
|
|
||||||
from app.schemas.verification import VerificationCreate, VerificationUpdate
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, subject_id: uuid.UUID):
|
|
||||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
|
||||||
subj = result.scalar_one_or_none()
|
|
||||||
if not subj or subj.study_id != study_id:
|
|
||||||
raise ValueError("Subject not found in study")
|
|
||||||
if subj.site_id != site_id:
|
|
||||||
raise ValueError("Site does not match subject")
|
|
||||||
|
|
||||||
|
|
||||||
async def upsert_progress(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
data: VerificationCreate,
|
|
||||||
) -> VerificationProgress:
|
|
||||||
await _validate_site_subject(db, study_id, data.site_id, data.subject_id)
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(VerificationProgress).where(
|
|
||||||
VerificationProgress.study_id == study_id,
|
|
||||||
VerificationProgress.subject_id == data.subject_id,
|
|
||||||
VerificationProgress.level == data.level,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing = result.scalar_one_or_none()
|
|
||||||
if existing:
|
|
||||||
update_data = data.model_dump()
|
|
||||||
await db.execute(
|
|
||||||
sa_update(VerificationProgress)
|
|
||||||
.where(VerificationProgress.id == existing.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(existing)
|
|
||||||
return existing
|
|
||||||
|
|
||||||
vp = VerificationProgress(
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=data.site_id,
|
|
||||||
subject_id=data.subject_id,
|
|
||||||
level=data.level,
|
|
||||||
percent=data.percent,
|
|
||||||
last_verified_at=data.last_verified_at,
|
|
||||||
verifier_id=data.verifier_id,
|
|
||||||
notes=data.notes,
|
|
||||||
)
|
|
||||||
db.add(vp)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(vp)
|
|
||||||
return vp
|
|
||||||
|
|
||||||
|
|
||||||
async def get_progress(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID, level: str) -> VerificationProgress | None:
|
|
||||||
result = await db.execute(
|
|
||||||
select(VerificationProgress).where(
|
|
||||||
VerificationProgress.study_id == study_id,
|
|
||||||
VerificationProgress.subject_id == subject_id,
|
|
||||||
VerificationProgress.level == level,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_by_id(db: AsyncSession, verification_id: uuid.UUID) -> VerificationProgress | None:
|
|
||||||
result = await db.execute(select(VerificationProgress).where(VerificationProgress.id == verification_id))
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
async def list_progress(
|
|
||||||
db: AsyncSession,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID | None = None,
|
|
||||||
subject_id: uuid.UUID | None = None,
|
|
||||||
level: str | None = None,
|
|
||||||
) -> Sequence[VerificationProgress]:
|
|
||||||
stmt = select(VerificationProgress).where(VerificationProgress.study_id == study_id)
|
|
||||||
if site_id:
|
|
||||||
stmt = stmt.where(VerificationProgress.site_id == site_id)
|
|
||||||
if subject_id:
|
|
||||||
stmt = stmt.where(VerificationProgress.subject_id == subject_id)
|
|
||||||
if level:
|
|
||||||
stmt = stmt.where(VerificationProgress.level == level)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_progress(db: AsyncSession, vp: VerificationProgress, data: VerificationUpdate) -> VerificationProgress:
|
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
|
||||||
if update_data:
|
|
||||||
await db.execute(
|
|
||||||
sa_update(VerificationProgress)
|
|
||||||
.where(VerificationProgress.id == vp.id)
|
|
||||||
.values(**update_data)
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(vp)
|
|
||||||
return vp
|
|
||||||
@@ -58,3 +58,8 @@ async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) ->
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(visit)
|
await db.refresh(visit)
|
||||||
return visit
|
return visit
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_visit(db: AsyncSession, visit: Visit) -> None:
|
||||||
|
await db.delete(visit)
|
||||||
|
await db.commit()
|
||||||
|
|||||||
@@ -5,21 +5,22 @@ from app.models.user import User # noqa: F401
|
|||||||
from app.models.study import Study # noqa: F401
|
from app.models.study import Study # noqa: F401
|
||||||
from app.models.site import Site # noqa: F401
|
from app.models.site import Site # noqa: F401
|
||||||
from app.models.study_member import StudyMember # noqa: F401
|
from app.models.study_member import StudyMember # noqa: F401
|
||||||
from app.models.comment import Comment # noqa: F401
|
|
||||||
from app.models.attachment import Attachment # noqa: F401
|
from app.models.attachment import Attachment # noqa: F401
|
||||||
from app.models.audit_log import AuditLog # noqa: F401
|
from app.models.audit_log import AuditLog # noqa: F401
|
||||||
from app.models.milestone import Milestone # noqa: F401
|
from app.models.milestone import Milestone # noqa: F401
|
||||||
from app.models.subject import Subject # noqa: F401
|
from app.models.subject import Subject # noqa: F401
|
||||||
from app.models.visit import Visit # noqa: F401
|
from app.models.visit import Visit # noqa: F401
|
||||||
from app.models.ae import AdverseEvent # noqa: F401
|
from app.models.ae import AdverseEvent # noqa: F401
|
||||||
from app.models.issue import Issue # noqa: F401
|
|
||||||
from app.models.data_query import DataQuery # noqa: F401
|
|
||||||
from app.models.verification import VerificationProgress # noqa: F401
|
|
||||||
from app.models.imp_product import ImpProduct # noqa: F401
|
|
||||||
from app.models.imp_batch import ImpBatch # noqa: F401
|
|
||||||
from app.models.imp_inventory import ImpInventory # noqa: F401
|
|
||||||
from app.models.imp_transaction import ImpTransaction # noqa: F401
|
|
||||||
from app.models.finance import FinanceItem # noqa: F401
|
from app.models.finance import FinanceItem # noqa: F401
|
||||||
|
from app.models.finance_contract import FinanceContract # noqa: F401
|
||||||
|
from app.models.finance_special import FinanceSpecial # noqa: F401
|
||||||
|
from app.models.drug_shipment import DrugShipment # noqa: F401
|
||||||
|
from app.models.startup_feasibility import StartupFeasibility # noqa: F401
|
||||||
|
from app.models.startup_ethics import StartupEthics # noqa: F401
|
||||||
|
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
|
||||||
|
from app.models.training_authorization import TrainingAuthorization # noqa: F401
|
||||||
|
from app.models.knowledge_note import KnowledgeNote # noqa: F401
|
||||||
|
from app.models.subject_history import SubjectHistory # noqa: F401
|
||||||
from app.models.faq_category import FaqCategory # noqa: F401
|
from app.models.faq_category import FaqCategory # noqa: F401
|
||||||
from app.models.faq_item import FaqItem # noqa: F401
|
from app.models.faq_item import FaqItem # noqa: F401
|
||||||
from app.models.faq_reply import FaqReply # noqa: F401
|
from app.models.faq_reply import FaqReply # noqa: F401
|
||||||
|
|||||||
@@ -72,26 +72,16 @@ def create_app() -> FastAPI:
|
|||||||
{"name": "studies", "description": "项目管理"},
|
{"name": "studies", "description": "项目管理"},
|
||||||
{"name": "sites", "description": "中心管理"},
|
{"name": "sites", "description": "中心管理"},
|
||||||
{"name": "study-members", "description": "项目成员"},
|
{"name": "study-members", "description": "项目成员"},
|
||||||
{"name": "comments", "description": "通用评论"},
|
|
||||||
{"name": "attachments", "description": "通用附件"},
|
{"name": "attachments", "description": "通用附件"},
|
||||||
{"name": "audit-logs", "description": "审计日志"},
|
{"name": "audit-logs", "description": "审计日志"},
|
||||||
{"name": "milestones", "description": "里程碑"},
|
|
||||||
{"name": "dashboard", "description": "项目总览与统计"},
|
{"name": "dashboard", "description": "项目总览与统计"},
|
||||||
{"name": "subjects", "description": "受试者"},
|
{"name": "subjects", "description": "受试者"},
|
||||||
{"name": "visits", "description": "访视"},
|
{"name": "visits", "description": "访视"},
|
||||||
{"name": "aes", "description": "不良事件"},
|
{"name": "aes", "description": "不良事件"},
|
||||||
{"name": "issues", "description": "风险 / 问题"},
|
|
||||||
{"name": "data-queries", "description": "数据问题单"},
|
|
||||||
{"name": "verifications", "description": "SDV/SDR 核查进度"},
|
|
||||||
{"name": "imp-products", "description": "药品产品"},
|
|
||||||
{"name": "imp-batches", "description": "药品批次"},
|
|
||||||
{"name": "imp-inventory", "description": "药品库存"},
|
|
||||||
{"name": "imp-transactions", "description": "药品台账流水"},
|
|
||||||
{"name": "finance", "description": "费用管理"},
|
{"name": "finance", "description": "费用管理"},
|
||||||
{"name": "faq-categories", "description": "FAQ 分类"},
|
{"name": "faq-categories", "description": "FAQ 分类"},
|
||||||
{"name": "faqs", "description": "FAQ 条目"},
|
{"name": "faqs", "description": "FAQ 条目"},
|
||||||
{"name": "health", "description": "健康检查"},
|
{"name": "health", "description": "健康检查"},
|
||||||
{"name": "faq", "description": "常量字典"},
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
|
||||||
__tablename__ = "comments"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
||||||
quote_comment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("comments.id"), nullable=True)
|
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, date
|
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class DataQuery(Base):
|
|
||||||
__tablename__ = "data_queries"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
|
||||||
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
|
|
||||||
visit_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
priority: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
||||||
assigned_to: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
|
|
||||||
resolution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DrugShipment(Base):
|
||||||
|
__tablename__ = "drug_shipments"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
direction: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
drug_desc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
ship_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
tracking_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
from_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
to_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
remark: 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)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceContract(Base):
|
||||||
|
__tablename__ = "finance_contracts"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
contract_no: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
signed_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="CNY")
|
||||||
|
remark: 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)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceSpecial(Base):
|
||||||
|
__tablename__ = "finance_specials"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
fee_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||||
|
occur_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
staff_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
remark: 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)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, date
|
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ImpBatch(Base):
|
|
||||||
__tablename__ = "imp_batches"
|
|
||||||
__table_args__ = (UniqueConstraint("study_id", "batch_no", "product_id", name="uq_imp_batch_no"),)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_products.id"), index=True, nullable=False)
|
|
||||||
batch_no: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
||||||
expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
manufacture_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE")
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ImpInventory(Base):
|
|
||||||
__tablename__ = "imp_inventory"
|
|
||||||
__table_args__ = (UniqueConstraint("study_id", "site_id", "batch_id", name="uq_imp_inventory_site_batch"),)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
|
||||||
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
|
|
||||||
quantity_on_hand: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, date
|
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class ImpTransaction(Base):
|
|
||||||
__tablename__ = "imp_transactions"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
|
||||||
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
|
|
||||||
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), nullable=True)
|
|
||||||
tx_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
||||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
||||||
tx_date: Mapped[date] = mapped_column(Date, nullable=False)
|
|
||||||
reference: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, date
|
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Issue(Base):
|
|
||||||
__tablename__ = "issues"
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
|
||||||
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
|
|
||||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
level: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
||||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
|
|
||||||
capa: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
||||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, JSON, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class KickoffMeeting(Base):
|
||||||
|
__tablename__ = "kickoff_meetings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
kickoff_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
attendees: Mapped[list[str] | None] = mapped_column(JSON, 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())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.db.base_class import Base
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
class ImpProduct(Base):
|
class KnowledgeNote(Base):
|
||||||
__tablename__ = "imp_products"
|
__tablename__ = "knowledge_notes"
|
||||||
__table_args__ = (UniqueConstraint("study_id", "name", name="uq_imp_product_name"),)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
form: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
strength: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
unit: Mapped[str] = mapped_column(String(50), nullable=False)
|
level: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
description: 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)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StartupEthics(Base):
|
||||||
|
__tablename__ = "startup_ethics"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
meeting_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
approval_no: Mapped[str | None] = mapped_column(String(100), 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())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StartupFeasibility(Base):
|
||||||
|
__tablename__ = "startup_feasibility"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
project_no: Mapped[str | None] = mapped_column(String(100), 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())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectHistory(Base):
|
||||||
|
__tablename__ = "subject_histories"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
|
||||||
|
record_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingAuthorization(Base):
|
||||||
|
__tablename__ = "training_authorizations"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
role: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
site_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
trained: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
authorized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
trained_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
authorized_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
remark: 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)
|
||||||
|
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()
|
||||||
|
)
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, date
|
|
||||||
|
|
||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from app.db.base_class import Base
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationProgress(Base):
|
|
||||||
__tablename__ = "verification_progress"
|
|
||||||
__table_args__ = (UniqueConstraint("study_id", "subject_id", "level", name="uq_verification_subject_level"),)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
|
||||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
|
||||||
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
|
|
||||||
level: Mapped[str] = mapped_column(String(10), nullable=False) # SDR / SDV
|
|
||||||
percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
|
||||||
last_verified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
|
||||||
verifier_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
||||||
notes: 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()
|
|
||||||
)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreate(BaseModel):
|
|
||||||
content: str = Field(min_length=1)
|
|
||||||
quote_comment_id: uuid.UUID | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CommentQuote(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
content: str
|
|
||||||
created_by: uuid.UUID
|
|
||||||
created_at: datetime
|
|
||||||
is_deleted: bool = False
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class CommentRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
content: str
|
|
||||||
created_by: uuid.UUID
|
|
||||||
created_at: datetime
|
|
||||||
quote_comment_id: uuid.UUID | None = None
|
|
||||||
quote: CommentQuote | None = None
|
|
||||||
is_deleted: bool = False
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class DataQueryCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: str
|
|
||||||
priority: str
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
subject_id: Optional[uuid.UUID] = None
|
|
||||||
visit_id: Optional[uuid.UUID] = None
|
|
||||||
assigned_to: Optional[uuid.UUID] = None
|
|
||||||
due_date: Optional[date] = None
|
|
||||||
|
|
||||||
|
|
||||||
class DataQueryUpdate(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
priority: Optional[str] = None
|
|
||||||
assigned_to: Optional[uuid.UUID] = None
|
|
||||||
due_date: Optional[date] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
resolution: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class DataQueryRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
site_id: Optional[uuid.UUID]
|
|
||||||
subject_id: Optional[uuid.UUID]
|
|
||||||
visit_id: Optional[uuid.UUID]
|
|
||||||
title: str
|
|
||||||
description: Optional[str]
|
|
||||||
category: str
|
|
||||||
priority: str
|
|
||||||
assigned_to: Optional[uuid.UUID]
|
|
||||||
due_date: Optional[date]
|
|
||||||
status: str
|
|
||||||
resolution: Optional[str]
|
|
||||||
closed_at: Optional[datetime]
|
|
||||||
created_by: uuid.UUID
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
is_overdue: bool | None = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DrugShipmentCreate(BaseModel):
|
||||||
|
direction: str
|
||||||
|
site_name: str
|
||||||
|
drug_desc: str
|
||||||
|
ship_date: Optional[date] = None
|
||||||
|
carrier: Optional[str] = None
|
||||||
|
tracking_no: Optional[str] = None
|
||||||
|
from_party: Optional[str] = None
|
||||||
|
to_party: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DrugShipmentUpdate(BaseModel):
|
||||||
|
direction: Optional[str] = None
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
drug_desc: Optional[str] = None
|
||||||
|
ship_date: Optional[date] = None
|
||||||
|
carrier: Optional[str] = None
|
||||||
|
tracking_no: Optional[str] = None
|
||||||
|
from_party: Optional[str] = None
|
||||||
|
to_party: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DrugShipmentRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
direction: str
|
||||||
|
site_name: str
|
||||||
|
drug_desc: str
|
||||||
|
ship_date: Optional[date]
|
||||||
|
carrier: Optional[str]
|
||||||
|
tracking_no: Optional[str]
|
||||||
|
from_party: Optional[str]
|
||||||
|
to_party: Optional[str]
|
||||||
|
status: str
|
||||||
|
remark: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -1,67 +1,6 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class FinanceCreate(BaseModel):
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
subject_id: Optional[uuid.UUID] = None
|
|
||||||
visit_id: Optional[uuid.UUID] = None
|
|
||||||
category: str
|
|
||||||
title: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
currency: str = "CNY"
|
|
||||||
amount: Decimal = Field(gt=0)
|
|
||||||
occur_date: date
|
|
||||||
|
|
||||||
|
|
||||||
class FinanceUpdate(BaseModel):
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
subject_id: Optional[uuid.UUID] = None
|
|
||||||
visit_id: Optional[uuid.UUID] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
currency: Optional[str] = None
|
|
||||||
amount: Optional[Decimal] = Field(default=None, gt=0)
|
|
||||||
occur_date: Optional[date] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FinanceStatusUpdate(BaseModel):
|
|
||||||
status: str
|
|
||||||
reject_reason: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class FinanceRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
site_id: Optional[uuid.UUID]
|
|
||||||
subject_id: Optional[uuid.UUID]
|
|
||||||
visit_id: Optional[uuid.UUID]
|
|
||||||
category: str
|
|
||||||
title: str
|
|
||||||
description: Optional[str]
|
|
||||||
currency: str
|
|
||||||
amount: Decimal
|
|
||||||
occur_date: date
|
|
||||||
status: str
|
|
||||||
submitted_at: Optional[datetime]
|
|
||||||
approved_at: Optional[datetime]
|
|
||||||
rejected_at: Optional[datetime]
|
|
||||||
paid_at: Optional[datetime]
|
|
||||||
approver_id: Optional[uuid.UUID]
|
|
||||||
payer_id: Optional[uuid.UUID]
|
|
||||||
reject_reason: Optional[str]
|
|
||||||
created_by: uuid.UUID
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
month: Optional[str] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
class FinanceSummaryRead(BaseModel):
|
class FinanceSummaryRead(BaseModel):
|
||||||
total_amount: Decimal
|
total_amount: Decimal
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceContractCreate(BaseModel):
|
||||||
|
site_name: str
|
||||||
|
contract_no: str
|
||||||
|
signed_date: Optional[date] = None
|
||||||
|
amount: Decimal = Field(gt=0)
|
||||||
|
currency: str = "CNY"
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceContractUpdate(BaseModel):
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
contract_no: Optional[str] = None
|
||||||
|
signed_date: Optional[date] = None
|
||||||
|
amount: Optional[Decimal] = Field(default=None, gt=0)
|
||||||
|
currency: Optional[str] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceContractRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
site_name: str
|
||||||
|
contract_no: str
|
||||||
|
signed_date: Optional[date]
|
||||||
|
amount: Decimal
|
||||||
|
currency: str
|
||||||
|
remark: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceSpecialCreate(BaseModel):
|
||||||
|
site_name: str
|
||||||
|
fee_type: str
|
||||||
|
amount: Decimal = Field(gt=0)
|
||||||
|
occur_date: Optional[date] = None
|
||||||
|
staff_name: Optional[str] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceSpecialUpdate(BaseModel):
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
fee_type: Optional[str] = None
|
||||||
|
amount: Optional[Decimal] = Field(default=None, gt=0)
|
||||||
|
occur_date: Optional[date] = None
|
||||||
|
staff_name: Optional[str] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinanceSpecialRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
site_name: str
|
||||||
|
fee_type: str
|
||||||
|
amount: Decimal
|
||||||
|
occur_date: Optional[date]
|
||||||
|
staff_name: Optional[str]
|
||||||
|
remark: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
form: Optional[str] = None
|
|
||||||
strength: Optional[str] = None
|
|
||||||
unit: str
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_active: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
form: Optional[str] = None
|
|
||||||
strength: Optional[str] = None
|
|
||||||
unit: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProductRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
name: str
|
|
||||||
form: Optional[str]
|
|
||||||
strength: Optional[str]
|
|
||||||
unit: str
|
|
||||||
description: Optional[str]
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class BatchCreate(BaseModel):
|
|
||||||
product_id: uuid.UUID
|
|
||||||
batch_no: str
|
|
||||||
expiry_date: Optional[date] = None
|
|
||||||
manufacture_date: Optional[date] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BatchUpdate(BaseModel):
|
|
||||||
status: Optional[str] = None
|
|
||||||
expiry_date: Optional[date] = None
|
|
||||||
manufacture_date: Optional[date] = None
|
|
||||||
|
|
||||||
|
|
||||||
class BatchRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
product_id: uuid.UUID
|
|
||||||
batch_no: str
|
|
||||||
expiry_date: Optional[date]
|
|
||||||
manufacture_date: Optional[date]
|
|
||||||
status: str
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryRead(BaseModel):
|
|
||||||
site_id: uuid.UUID
|
|
||||||
batch_id: uuid.UUID
|
|
||||||
quantity_on_hand: int
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TxCreate(BaseModel):
|
|
||||||
site_id: uuid.UUID
|
|
||||||
batch_id: uuid.UUID
|
|
||||||
subject_id: Optional[uuid.UUID] = None
|
|
||||||
tx_type: str
|
|
||||||
quantity: int = Field(gt=0)
|
|
||||||
tx_date: date
|
|
||||||
reference: Optional[str] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class TxRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
site_id: uuid.UUID
|
|
||||||
batch_id: uuid.UUID
|
|
||||||
subject_id: Optional[uuid.UUID]
|
|
||||||
tx_type: str
|
|
||||||
quantity: int
|
|
||||||
tx_date: date
|
|
||||||
reference: Optional[str]
|
|
||||||
notes: Optional[str]
|
|
||||||
created_by: uuid.UUID
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class IssueCreate(BaseModel):
|
|
||||||
title: str
|
|
||||||
category: str
|
|
||||||
level: str
|
|
||||||
due_date: Optional[date] = None
|
|
||||||
owner_id: Optional[uuid.UUID] = None
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
subject_id: Optional[uuid.UUID] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class IssueUpdate(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
category: Optional[str] = None
|
|
||||||
level: Optional[str] = None
|
|
||||||
due_date: Optional[date] = None
|
|
||||||
owner_id: Optional[uuid.UUID] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
capa: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class IssueRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
site_id: Optional[uuid.UUID]
|
|
||||||
subject_id: Optional[uuid.UUID]
|
|
||||||
title: str
|
|
||||||
description: Optional[str]
|
|
||||||
category: str
|
|
||||||
level: str
|
|
||||||
owner_id: Optional[uuid.UUID]
|
|
||||||
due_date: Optional[date]
|
|
||||||
status: str
|
|
||||||
capa: Optional[str]
|
|
||||||
closed_at: Optional[datetime]
|
|
||||||
created_by: uuid.UUID
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
is_overdue: bool | None = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeNoteCreate(BaseModel):
|
||||||
|
site_name: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
level: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeNoteUpdate(BaseModel):
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
level: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeNoteRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
site_name: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
level: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.schemas.user import UserDisplay
|
|
||||||
|
|
||||||
|
|
||||||
class SiteDisplay(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
name: Optional[str] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
class MilestoneCreate(BaseModel):
|
|
||||||
type: str
|
|
||||||
name: Optional[str] = None
|
|
||||||
planned_date: Optional[date] = None
|
|
||||||
owner_id: Optional[uuid.UUID] = None
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
status: str = Field(default="NOT_STARTED")
|
|
||||||
|
|
||||||
|
|
||||||
class MilestoneUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
planned_date: Optional[date] = None
|
|
||||||
actual_date: Optional[date] = None
|
|
||||||
status: Optional[str] = None
|
|
||||||
owner_id: Optional[uuid.UUID] = None
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class MilestoneRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
type: str
|
|
||||||
name: str
|
|
||||||
planned_date: Optional[date]
|
|
||||||
actual_date: Optional[date]
|
|
||||||
status: str
|
|
||||||
owner_id: Optional[uuid.UUID]
|
|
||||||
site_id: Optional[uuid.UUID] = None
|
|
||||||
owner: Optional[UserDisplay] = None
|
|
||||||
site: Optional[SiteDisplay] = None
|
|
||||||
notes: Optional[str]
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class StartupFeasibilityCreate(BaseModel):
|
||||||
|
submit_date: Optional[date] = None
|
||||||
|
accept_date: Optional[date] = None
|
||||||
|
approved_date: Optional[date] = None
|
||||||
|
project_no: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StartupFeasibilityUpdate(BaseModel):
|
||||||
|
submit_date: Optional[date] = None
|
||||||
|
accept_date: Optional[date] = None
|
||||||
|
approved_date: Optional[date] = None
|
||||||
|
project_no: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StartupFeasibilityRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
submit_date: Optional[date]
|
||||||
|
accept_date: Optional[date]
|
||||||
|
approved_date: Optional[date]
|
||||||
|
project_no: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class StartupEthicsCreate(BaseModel):
|
||||||
|
submit_date: Optional[date] = None
|
||||||
|
accept_date: Optional[date] = None
|
||||||
|
meeting_date: Optional[date] = None
|
||||||
|
approved_date: Optional[date] = None
|
||||||
|
approval_no: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StartupEthicsUpdate(BaseModel):
|
||||||
|
submit_date: Optional[date] = None
|
||||||
|
accept_date: Optional[date] = None
|
||||||
|
meeting_date: Optional[date] = None
|
||||||
|
approved_date: Optional[date] = None
|
||||||
|
approval_no: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StartupEthicsRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
submit_date: Optional[date]
|
||||||
|
accept_date: Optional[date]
|
||||||
|
meeting_date: Optional[date]
|
||||||
|
approved_date: Optional[date]
|
||||||
|
approval_no: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class KickoffMeetingCreate(BaseModel):
|
||||||
|
kickoff_date: Optional[date] = None
|
||||||
|
attendees: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KickoffMeetingUpdate(BaseModel):
|
||||||
|
kickoff_date: Optional[date] = None
|
||||||
|
attendees: Optional[list[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class KickoffMeetingRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
kickoff_date: Optional[date]
|
||||||
|
attendees: Optional[list[str]]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingAuthorizationCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
role: Optional[str] = None
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
trained: bool = False
|
||||||
|
authorized: bool = False
|
||||||
|
trained_date: Optional[date] = None
|
||||||
|
authorized_date: Optional[date] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingAuthorizationUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
role: Optional[str] = None
|
||||||
|
site_name: Optional[str] = None
|
||||||
|
trained: Optional[bool] = None
|
||||||
|
authorized: Optional[bool] = None
|
||||||
|
trained_date: Optional[date] = None
|
||||||
|
authorized_date: Optional[date] = None
|
||||||
|
remark: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingAuthorizationRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
role: Optional[str]
|
||||||
|
site_name: Optional[str]
|
||||||
|
trained: bool
|
||||||
|
authorized: bool
|
||||||
|
trained_date: Optional[date]
|
||||||
|
authorized_date: Optional[date]
|
||||||
|
remark: Optional[str]
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectHistoryCreate(BaseModel):
|
||||||
|
subject_id: uuid.UUID
|
||||||
|
record_date: Optional[date] = None
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectHistoryUpdate(BaseModel):
|
||||||
|
record_date: Optional[date] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectHistoryRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
subject_id: uuid.UUID
|
||||||
|
record_date: Optional[date]
|
||||||
|
content: str
|
||||||
|
created_by: Optional[uuid.UUID]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from datetime import date, datetime
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationCreate(BaseModel):
|
|
||||||
site_id: uuid.UUID
|
|
||||||
subject_id: uuid.UUID
|
|
||||||
level: str
|
|
||||||
percent: int = Field(ge=0, le=100)
|
|
||||||
last_verified_at: Optional[date] = None
|
|
||||||
verifier_id: Optional[uuid.UUID] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationUpdate(BaseModel):
|
|
||||||
percent: Optional[int] = Field(default=None, ge=0, le=100)
|
|
||||||
last_verified_at: Optional[date] = None
|
|
||||||
verifier_id: Optional[uuid.UUID] = None
|
|
||||||
notes: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class VerificationRead(BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
study_id: uuid.UUID
|
|
||||||
site_id: uuid.UUID
|
|
||||||
subject_id: uuid.UUID
|
|
||||||
level: str
|
|
||||||
percent: int
|
|
||||||
last_verified_at: Optional[date]
|
|
||||||
verifier_id: Optional[uuid.UUID]
|
|
||||||
notes: Optional[str]
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.crud import imp_inventory as inventory_crud
|
|
||||||
from app.crud import imp_tx as tx_crud
|
|
||||||
from app.crud import audit as audit_crud
|
|
||||||
from app.models.imp_batch import ImpBatch
|
|
||||||
from app.models.site import Site
|
|
||||||
from app.models.subject import Subject
|
|
||||||
|
|
||||||
TxType = Literal["RECEIPT", "DISPENSE", "RETURN", "RECONCILE", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"]
|
|
||||||
|
|
||||||
POSITIVE_TYPES = {"RECEIPT", "RETURN", "TRANSFER_IN"}
|
|
||||||
NEGATIVE_TYPES = {"DISPENSE", "DESTROY", "TRANSFER_OUT"}
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
|
|
||||||
site = await db.get(Site, site_id)
|
|
||||||
if not site or site.study_id != study_id:
|
|
||||||
raise ValueError("Site not found in study")
|
|
||||||
batch = await db.get(ImpBatch, batch_id)
|
|
||||||
if not batch or batch.study_id != study_id:
|
|
||||||
raise ValueError("Batch not found in study")
|
|
||||||
# batch status check could be extended
|
|
||||||
if subject_id:
|
|
||||||
subj = await db.get(Subject, subject_id)
|
|
||||||
if not subj or subj.study_id != study_id:
|
|
||||||
raise ValueError("Subject not found in study")
|
|
||||||
|
|
||||||
|
|
||||||
async def create_transaction_and_apply_inventory(
|
|
||||||
db: AsyncSession,
|
|
||||||
*,
|
|
||||||
study_id: uuid.UUID,
|
|
||||||
site_id: uuid.UUID,
|
|
||||||
batch_id: uuid.UUID,
|
|
||||||
subject_id: uuid.UUID | None,
|
|
||||||
tx_type: TxType,
|
|
||||||
quantity: int,
|
|
||||||
tx_date,
|
|
||||||
reference: str | None,
|
|
||||||
notes: str | None,
|
|
||||||
operator_id: uuid.UUID,
|
|
||||||
operator_role: str,
|
|
||||||
) -> tuple:
|
|
||||||
if quantity <= 0:
|
|
||||||
raise ValueError("Quantity must be positive")
|
|
||||||
await _validate_entities(db, study_id, site_id, batch_id, subject_id)
|
|
||||||
|
|
||||||
async with db.begin_nested():
|
|
||||||
inv = await inventory_crud.get_or_create_inventory(db, study_id, site_id, batch_id)
|
|
||||||
delta = quantity if tx_type in POSITIVE_TYPES else -quantity
|
|
||||||
new_qoh = inv.quantity_on_hand + delta
|
|
||||||
if new_qoh < 0:
|
|
||||||
raise ValueError("Insufficient inventory")
|
|
||||||
|
|
||||||
inv.quantity_on_hand = new_qoh
|
|
||||||
tx = await tx_crud.create_tx(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
site_id=site_id,
|
|
||||||
batch_id=batch_id,
|
|
||||||
subject_id=subject_id,
|
|
||||||
tx_type=tx_type,
|
|
||||||
quantity=quantity,
|
|
||||||
tx_date=tx_date,
|
|
||||||
reference=reference,
|
|
||||||
notes=notes,
|
|
||||||
created_by=operator_id,
|
|
||||||
)
|
|
||||||
await db.flush()
|
|
||||||
detail = f"{tx_type} {quantity} units batch {batch_id} at site {site_id}, QOH -> {new_qoh}"
|
|
||||||
await audit_crud.log_action(
|
|
||||||
db,
|
|
||||||
study_id=study_id,
|
|
||||||
entity_type="imp_tx",
|
|
||||||
entity_id=tx.id,
|
|
||||||
action="CREATE_IMP_TX",
|
|
||||||
detail=detail,
|
|
||||||
operator_id=operator_id,
|
|
||||||
operator_role=operator_role,
|
|
||||||
)
|
|
||||||
return tx
|
|
||||||
+593
-1679
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost, apiPatch } from "./axios";
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
import type { ApiListResponse } from "../types/api";
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
export const fetchAes = (studyId: string, params?: Record<string, any>) =>
|
export const fetchAes = (studyId: string, params?: Record<string, any>) =>
|
||||||
@@ -9,3 +9,6 @@ export const createAe = (studyId: string, payload: Record<string, any>) =>
|
|||||||
|
|
||||||
export const updateAe = (studyId: string, aeId: string, payload: Record<string, any>) =>
|
export const updateAe = (studyId: string, aeId: string, payload: Record<string, any>) =>
|
||||||
apiPatch(`/api/v1/studies/${studyId}/aes/${aeId}`, payload);
|
apiPatch(`/api/v1/studies/${studyId}/aes/${aeId}`, payload);
|
||||||
|
|
||||||
|
export const deleteAe = (studyId: string, aeId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/aes/${aeId}`);
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { apiGet, apiPost, apiDelete } from "./axios";
|
|
||||||
|
|
||||||
export const fetchComments = (studyId: string, entityType: string, entityId: string) =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`);
|
|
||||||
|
|
||||||
export const createComment = (
|
|
||||||
studyId: string,
|
|
||||||
entityType: string,
|
|
||||||
entityId: string,
|
|
||||||
payload: Record<string, any>
|
|
||||||
) => apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`, payload);
|
|
||||||
|
|
||||||
export const deleteComment = (studyId: string, entityType: string, entityId: string, commentId: string) =>
|
|
||||||
apiDelete(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments/${commentId}`);
|
|
||||||
@@ -9,6 +9,3 @@ export const fetchFinanceSummary = (studyId: string) =>
|
|||||||
|
|
||||||
export const fetchOverdueAesCount = (studyId: string) =>
|
export const fetchOverdueAesCount = (studyId: string) =>
|
||||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
|
||||||
|
|
||||||
export const fetchOverdueQueriesCount = (studyId: string) =>
|
|
||||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/data-queries/`, { params: { overdue: true, limit: 1 } });
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { apiGet, apiPost, apiPatch } from "./axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export const fetchDataQueries = (studyId: string, params?: Record<string, any>) =>
|
|
||||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/data-queries/`, { params });
|
|
||||||
|
|
||||||
export const createDataQuery = (studyId: string, payload: Record<string, any>) =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/data-queries/`, payload);
|
|
||||||
|
|
||||||
export const updateDataQuery = (studyId: string, queryId: string, payload: Record<string, any>) =>
|
|
||||||
apiPatch(`/api/v1/studies/${studyId}/data-queries/${queryId}`, payload);
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listDrugShipments = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/drug/shipments`, { params });
|
||||||
|
|
||||||
|
export const getDrugShipment = (studyId: string, shipmentId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`);
|
||||||
|
|
||||||
|
export const createDrugShipment = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/drug/shipments`, payload);
|
||||||
|
|
||||||
|
export const updateDrugShipment = (studyId: string, shipmentId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`, payload);
|
||||||
|
|
||||||
|
export const deleteDrugShipment = (studyId: string, shipmentId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`);
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export interface FinanceItem {
|
|
||||||
id: string;
|
|
||||||
study_id: string;
|
|
||||||
site_id?: string | null;
|
|
||||||
subject_id?: string | null;
|
|
||||||
visit_id?: string | null;
|
|
||||||
category: string;
|
|
||||||
title: string;
|
|
||||||
description?: string | null;
|
|
||||||
currency: string;
|
|
||||||
amount: number | string;
|
|
||||||
occur_date: string;
|
|
||||||
status: string;
|
|
||||||
submitted_at?: string | null;
|
|
||||||
approved_at?: string | null;
|
|
||||||
rejected_at?: string | null;
|
|
||||||
paid_at?: string | null;
|
|
||||||
approver_id?: string | null;
|
|
||||||
payer_id?: string | null;
|
|
||||||
reject_reason?: string | null;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FinanceSummary {
|
|
||||||
total_amount: number | string;
|
|
||||||
approved_amount: number | string;
|
|
||||||
paid_amount: number | string;
|
|
||||||
submitted_amount?: number | string;
|
|
||||||
count_total: number;
|
|
||||||
count_paid: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchFinanceItems = (
|
|
||||||
studyId: string,
|
|
||||||
params?: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ApiListResponse<FinanceItem>>> =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/finance/items/`, { params });
|
|
||||||
|
|
||||||
export const fetchFinanceItem = (studyId: string, itemId: string) =>
|
|
||||||
apiGet<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}`);
|
|
||||||
|
|
||||||
export const createFinanceItem = (studyId: string, payload: Record<string, any>) =>
|
|
||||||
apiPost<FinanceItem>(`/api/v1/studies/${studyId}/finance/items`, payload);
|
|
||||||
|
|
||||||
export const updateFinanceItem = (studyId: string, itemId: string, payload: Record<string, any>) =>
|
|
||||||
apiPatch<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}`, payload);
|
|
||||||
|
|
||||||
export const changeFinanceStatus = (
|
|
||||||
studyId: string,
|
|
||||||
itemId: string,
|
|
||||||
payload: { status: string; reject_reason?: string }
|
|
||||||
) => apiPost<FinanceItem>(`/api/v1/studies/${studyId}/finance/items/${itemId}/status`, payload);
|
|
||||||
|
|
||||||
export const fetchFinanceSummary = (studyId: string, params?: Record<string, any>) =>
|
|
||||||
apiGet<FinanceSummary | ApiListResponse<FinanceSummary>>(
|
|
||||||
`/api/v1/studies/${studyId}/finance/summary`,
|
|
||||||
{ params }
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listFinanceContracts = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/finance/contracts`, { params });
|
||||||
|
|
||||||
|
export const getFinanceContract = (studyId: string, contractId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`);
|
||||||
|
|
||||||
|
export const createFinanceContract = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/finance/contracts`, payload);
|
||||||
|
|
||||||
|
export const updateFinanceContract = (studyId: string, contractId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`, payload);
|
||||||
|
|
||||||
|
export const deleteFinanceContract = (studyId: string, contractId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listFinanceSpecials = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/finance/specials`, { params });
|
||||||
|
|
||||||
|
export const getFinanceSpecial = (studyId: string, specialId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/finance/specials/${specialId}`);
|
||||||
|
|
||||||
|
export const createFinanceSpecial = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/finance/specials`, payload);
|
||||||
|
|
||||||
|
export const updateFinanceSpecial = (studyId: string, specialId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/finance/specials/${specialId}`, payload);
|
||||||
|
|
||||||
|
export const deleteFinanceSpecial = (studyId: string, specialId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/finance/specials/${specialId}`);
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export interface ImpBatch {
|
|
||||||
id: string;
|
|
||||||
study_id: string;
|
|
||||||
product_id: string;
|
|
||||||
batch_no: string;
|
|
||||||
expiry_date?: string | null;
|
|
||||||
manufacture_date?: string | null;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchImpBatches = (
|
|
||||||
studyId: string,
|
|
||||||
params?: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ApiListResponse<ImpBatch>>> =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/imp/batches/`, { params });
|
|
||||||
|
|
||||||
export const createImpBatch = (
|
|
||||||
studyId: string,
|
|
||||||
payload: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ImpBatch>> =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/imp/batches/`, payload);
|
|
||||||
|
|
||||||
export const updateImpBatch = (
|
|
||||||
studyId: string,
|
|
||||||
batchId: string,
|
|
||||||
payload: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ImpBatch>> =>
|
|
||||||
apiPatch(`/api/v1/studies/${studyId}/imp/batches/${batchId}`, payload);
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { apiGet } from "./axios";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export interface ImpInventoryRow {
|
|
||||||
site_id: string;
|
|
||||||
batch_id: string;
|
|
||||||
quantity_on_hand: number;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchImpInventory = (
|
|
||||||
studyId: string,
|
|
||||||
params?: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ApiListResponse<ImpInventoryRow>>> =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/imp/inventory/`, { params });
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { apiGet, apiPatch, apiPost } from "./axios";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export interface ImpProduct {
|
|
||||||
id: string;
|
|
||||||
study_id: string;
|
|
||||||
name: string;
|
|
||||||
form?: string | null;
|
|
||||||
strength?: string | null;
|
|
||||||
unit: string;
|
|
||||||
description?: string | null;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchImpProducts = (
|
|
||||||
studyId: string
|
|
||||||
): Promise<AxiosResponse<ApiListResponse<ImpProduct>>> =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/imp/products/`);
|
|
||||||
|
|
||||||
export const createImpProduct = (
|
|
||||||
studyId: string,
|
|
||||||
payload: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ImpProduct>> =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/imp/products/`, payload);
|
|
||||||
|
|
||||||
export const updateImpProduct = (
|
|
||||||
studyId: string,
|
|
||||||
productId: string,
|
|
||||||
payload: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ImpProduct>> =>
|
|
||||||
apiPatch(`/api/v1/studies/${studyId}/imp/products/${productId}`, payload);
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { apiGet, apiPost } from "./axios";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export interface ImpTransaction {
|
|
||||||
id: string;
|
|
||||||
study_id: string;
|
|
||||||
site_id: string;
|
|
||||||
batch_id: string;
|
|
||||||
subject_id?: string | null;
|
|
||||||
tx_type: string;
|
|
||||||
quantity: number;
|
|
||||||
tx_date: string;
|
|
||||||
reference?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
created_by: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchImpTransactions = (
|
|
||||||
studyId: string,
|
|
||||||
params?: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ApiListResponse<ImpTransaction>>> =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/imp/transactions/`, { params });
|
|
||||||
|
|
||||||
export const createImpTransaction = (
|
|
||||||
studyId: string,
|
|
||||||
payload: Record<string, any>
|
|
||||||
): Promise<AxiosResponse<ImpTransaction>> =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/imp/transactions/`, payload);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { apiGet, apiPost, apiPatch } from "./axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export const fetchIssues = (studyId: string, params?: Record<string, any>) =>
|
|
||||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/issues/`, { params });
|
|
||||||
|
|
||||||
export const createIssue = (studyId: string, payload: Record<string, any>) =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/issues/`, payload);
|
|
||||||
|
|
||||||
export const updateIssue = (studyId: string, issueId: string, payload: Record<string, any>) =>
|
|
||||||
apiPatch(`/api/v1/studies/${studyId}/issues/${issueId}`, payload);
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listKnowledgeNotes = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/knowledge/notes`, { params });
|
||||||
|
|
||||||
|
export const getKnowledgeNote = (studyId: string, noteId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`);
|
||||||
|
|
||||||
|
export const createKnowledgeNote = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/knowledge/notes`, payload);
|
||||||
|
|
||||||
|
export const updateKnowledgeNote = (studyId: string, noteId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`, payload);
|
||||||
|
|
||||||
|
export const deleteKnowledgeNote = (studyId: string, noteId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`);
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "./axios";
|
|
||||||
import type { ApiListResponse } from "../types/api";
|
|
||||||
|
|
||||||
export const fetchMilestones = (studyId: string) =>
|
|
||||||
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/milestones/`);
|
|
||||||
|
|
||||||
export const createMilestone = (studyId: string, payload: Record<string, any>) =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/milestones/`, payload);
|
|
||||||
|
|
||||||
export const updateMilestone = (studyId: string, milestoneId: string, payload: Record<string, any>) =>
|
|
||||||
apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload);
|
|
||||||
|
|
||||||
export const deleteMilestone = (studyId: string, milestoneId: string) =>
|
|
||||||
apiDelete(`/api/v1/studies/${studyId}/milestones/${milestoneId}`);
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listFeasibility = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/feasibility`, { params });
|
||||||
|
|
||||||
|
export const getFeasibility = (studyId: string, id: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/feasibility/${id}`);
|
||||||
|
|
||||||
|
export const createFeasibility = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/startup/feasibility`, payload);
|
||||||
|
|
||||||
|
export const updateFeasibility = (studyId: string, id: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/startup/feasibility/${id}`, payload);
|
||||||
|
|
||||||
|
export const deleteFeasibility = (studyId: string, id: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/startup/feasibility/${id}`);
|
||||||
|
|
||||||
|
export const listEthics = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/ethics`, { params });
|
||||||
|
|
||||||
|
export const getEthics = (studyId: string, id: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/ethics/${id}`);
|
||||||
|
|
||||||
|
export const createEthics = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/startup/ethics`, payload);
|
||||||
|
|
||||||
|
export const updateEthics = (studyId: string, id: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/startup/ethics/${id}`, payload);
|
||||||
|
|
||||||
|
export const deleteEthics = (studyId: string, id: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/startup/ethics/${id}`);
|
||||||
|
|
||||||
|
export const listKickoffs = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/kickoff`, { params });
|
||||||
|
|
||||||
|
export const getKickoff = (studyId: string, id: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/kickoff/${id}`);
|
||||||
|
|
||||||
|
export const createKickoff = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/startup/kickoff`, payload);
|
||||||
|
|
||||||
|
export const updateKickoff = (studyId: string, id: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/startup/kickoff/${id}`, payload);
|
||||||
|
|
||||||
|
export const deleteKickoff = (studyId: string, id: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/startup/kickoff/${id}`);
|
||||||
|
|
||||||
|
export const listTrainingAuthorizations = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations`, { params });
|
||||||
|
|
||||||
|
export const getTrainingAuthorization = (studyId: string, id: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`);
|
||||||
|
|
||||||
|
export const createTrainingAuthorization = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/startup/training-authorizations`, payload);
|
||||||
|
|
||||||
|
export const updateTrainingAuthorization = (studyId: string, id: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`, payload);
|
||||||
|
|
||||||
|
export const deleteTrainingAuthorization = (studyId: string, id: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
|
export const listSubjectHistories = (studyId: string, subjectId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, { params });
|
||||||
|
|
||||||
|
export const getSubjectHistory = (studyId: string, subjectId: string, historyId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`);
|
||||||
|
|
||||||
|
export const createSubjectHistory = (studyId: string, subjectId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, payload);
|
||||||
|
|
||||||
|
export const updateSubjectHistory = (
|
||||||
|
studyId: string,
|
||||||
|
subjectId: string,
|
||||||
|
historyId: string,
|
||||||
|
payload: Record<string, any>
|
||||||
|
) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`, payload);
|
||||||
|
|
||||||
|
export const deleteSubjectHistory = (studyId: string, subjectId: string, historyId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { apiGet, apiPost, apiPatch } from "./axios";
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
import type { ApiListResponse } from "../types/api";
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
export const fetchSubjects = (studyId: string, params?: Record<string, any>) =>
|
export const fetchSubjects = (studyId: string, params?: Record<string, any>) =>
|
||||||
@@ -7,5 +7,11 @@ export const fetchSubjects = (studyId: string, params?: Record<string, any>) =>
|
|||||||
export const createSubject = (studyId: string, payload: Record<string, any>) =>
|
export const createSubject = (studyId: string, payload: Record<string, any>) =>
|
||||||
apiPost(`/api/v1/studies/${studyId}/subjects/`, payload);
|
apiPost(`/api/v1/studies/${studyId}/subjects/`, payload);
|
||||||
|
|
||||||
|
export const getSubject = (studyId: string, subjectId: string) =>
|
||||||
|
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}`);
|
||||||
|
|
||||||
export const updateSubject = (studyId: string, subjectId: string, payload: Record<string, any>) =>
|
export const updateSubject = (studyId: string, subjectId: string, payload: Record<string, any>) =>
|
||||||
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}`, payload);
|
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}`, payload);
|
||||||
|
|
||||||
|
export const deleteSubject = (studyId: string, subjectId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}`);
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { apiGet, apiPost } from "./axios";
|
|
||||||
|
|
||||||
export const fetchVerifications = (studyId: string, params?: Record<string, any>) =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/verifications/`, { params });
|
|
||||||
|
|
||||||
export const upsertVerification = (studyId: string, payload: Record<string, any>) =>
|
|
||||||
apiPost(`/api/v1/studies/${studyId}/verifications/`, payload);
|
|
||||||
|
|
||||||
export const fetchVerification = (studyId: string, subjectId: string, level: string) =>
|
|
||||||
apiGet(`/api/v1/studies/${studyId}/verifications/subjects/${subjectId}/${level}`);
|
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { apiGet, apiPatch } from "./axios";
|
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
|
||||||
|
|
||||||
export const fetchVisits = (studyId: string, subjectId: string) =>
|
export const fetchVisits = (studyId: string, subjectId: string) =>
|
||||||
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`);
|
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`);
|
||||||
|
|
||||||
|
export const createVisit = (studyId: string, subjectId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload);
|
||||||
|
|
||||||
export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) =>
|
export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record<string, any>) =>
|
||||||
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
|
apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload);
|
||||||
|
|
||||||
|
export const deleteVisit = (studyId: string, subjectId: string, visitId: string) =>
|
||||||
|
apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`);
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog :title="isEdit ? '编辑 AE' : '新增 AE'" v-model="visible" width="600px" @close="onClose" class="ctms-dialog">
|
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="ctms-form--dense form-body">
|
|
||||||
<el-form-item label="受试者" prop="subject_id">
|
|
||||||
<SubjectSelect v-model="form.subject_id" :study-id="study.currentStudy?.id || null" placeholder="可留空" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="事件描述" prop="term">
|
|
||||||
<el-input v-model="form.term" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="发生日期" prop="onset_date">
|
|
||||||
<el-date-picker
|
|
||||||
v-model="form.onset_date"
|
|
||||||
type="date"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
:disabled-date="disableFutureDate"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="SAE" prop="seriousness">
|
|
||||||
<el-select v-model="form.seriousness" placeholder="请选择">
|
|
||||||
<el-option label="是" value="SERIOUS" />
|
|
||||||
<el-option label="否" value="NON_SERIOUS" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="严重程度" prop="severity">
|
|
||||||
<el-select v-model="form.severity" placeholder="请选择">
|
|
||||||
<el-option v-for="s in severities" :key="s" :label="severityLabel(s)" :value="s" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="描述" prop="description">
|
|
||||||
<el-input v-model="form.description" type="textarea" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="转归结局" prop="outcome">
|
|
||||||
<el-select v-model="form.outcome" placeholder="可留空" clearable>
|
|
||||||
<el-option v-for="o in outcomes" :key="o" :label="o" :value="o" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="footer-right">
|
|
||||||
<el-button @click="onClose">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, reactive, ref, watch } from "vue";
|
|
||||||
import type { FormInstance, FormRules } from "element-plus";
|
|
||||||
import { ElMessage } from "element-plus";
|
|
||||||
import { createAe, updateAe } from "../api/aes";
|
|
||||||
import { useStudyStore } from "../store/study";
|
|
||||||
import SubjectSelect from "./selectors/SubjectSelect.vue";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
ae?: Record<string, any>;
|
|
||||||
subjects?: any[];
|
|
||||||
}>();
|
|
||||||
const emit = defineEmits(["update:modelValue", "success"]);
|
|
||||||
|
|
||||||
const visible = computed({
|
|
||||||
get: () => props.modelValue,
|
|
||||||
set: (v: boolean) => emit("update:modelValue", v),
|
|
||||||
});
|
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
|
||||||
const submitting = ref(false);
|
|
||||||
const study = useStudyStore();
|
|
||||||
|
|
||||||
const severities = ["G1", "G2", "G3", "G4", "G5"];
|
|
||||||
const outcomes = ["痊愈", "好转", "持续", "恶化", "死亡", "其他"];
|
|
||||||
const severityLabel = (v: string) =>
|
|
||||||
({
|
|
||||||
G1: "I级",
|
|
||||||
G2: "II级",
|
|
||||||
G3: "III级",
|
|
||||||
G4: "IV级",
|
|
||||||
G5: "V级",
|
|
||||||
}[v] || v);
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
subject_id: "",
|
|
||||||
term: "",
|
|
||||||
onset_date: "",
|
|
||||||
seriousness: "NON_SERIOUS",
|
|
||||||
severity: "G1",
|
|
||||||
description: "",
|
|
||||||
outcome: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules: FormRules = {
|
|
||||||
term: [{ required: true, message: "请输入事件描述", trigger: "blur" }],
|
|
||||||
onset_date: [{ required: true, message: "请选择发生日期", trigger: "change" }],
|
|
||||||
seriousness: [{ required: true, message: "请选择严重性", trigger: "change" }],
|
|
||||||
severity: [{ required: true, message: "请选择严重程度", trigger: "change" }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.ae);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.ae,
|
|
||||||
(val) => {
|
|
||||||
if (val) {
|
|
||||||
Object.assign(form, {
|
|
||||||
subject_id: val.subject_id || "",
|
|
||||||
term: val.term || "",
|
|
||||||
onset_date: val.onset_date || "",
|
|
||||||
seriousness: val.seriousness || "NON_SERIOUS",
|
|
||||||
severity: val.severity || "G1",
|
|
||||||
description: val.description || "",
|
|
||||||
outcome: val.outcome || "",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Object.assign(form, {
|
|
||||||
subject_id: "",
|
|
||||||
term: "",
|
|
||||||
onset_date: "",
|
|
||||||
seriousness: "NON_SERIOUS",
|
|
||||||
severity: "G1",
|
|
||||||
description: "",
|
|
||||||
outcome: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const onClose = () => {
|
|
||||||
visible.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const disableFutureDate = (time: Date) => time.getTime() > Date.now();
|
|
||||||
|
|
||||||
const onSubmit = async () => {
|
|
||||||
if (!formRef.value) return;
|
|
||||||
await formRef.value.validate(async (valid) => {
|
|
||||||
if (!valid) return;
|
|
||||||
if (form.onset_date && new Date(form.onset_date) > new Date()) {
|
|
||||||
ElMessage.warning("发生日期不得晚于当前日期");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!study.currentStudy) {
|
|
||||||
ElMessage.error("未选择项目");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
submitting.value = true;
|
|
||||||
try {
|
|
||||||
const payload: Record<string, any> = {};
|
|
||||||
Object.entries(form).forEach(([k, v]) => {
|
|
||||||
if (v !== "" && v !== null && v !== undefined) {
|
|
||||||
payload[k] = v;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (form.outcome === "" || form.outcome === null || form.outcome === undefined) {
|
|
||||||
payload.outcome = null;
|
|
||||||
}
|
|
||||||
if (isEdit.value && props.ae) {
|
|
||||||
await updateAe(study.currentStudy.id, props.ae.id, payload);
|
|
||||||
} else {
|
|
||||||
await createAe(study.currentStudy.id, payload);
|
|
||||||
}
|
|
||||||
ElMessage.success("提交成功");
|
|
||||||
emit("success");
|
|
||||||
onClose();
|
|
||||||
} catch (e: any) {
|
|
||||||
ElMessage.error(e?.response?.data?.message || "提交失败");
|
|
||||||
} finally {
|
|
||||||
submitting.value = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.form-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-dialog__body) {
|
|
||||||
padding-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.el-dialog__footer) {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user