信息架构/菜单框架大重构-20260109
This commit is contained in:
@@ -179,3 +179,34 @@ async def update_ae(
|
||||
data = AERead.model_validate(updated)
|
||||
data.is_overdue = _is_overdue(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 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.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(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(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.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(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(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(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_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(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,
|
||||
site_id: uuid.UUID | None = None,
|
||||
status_filter: str | None = None,
|
||||
subject_no: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[SubjectRead]:
|
||||
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)
|
||||
|
||||
|
||||
@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(
|
||||
"/{subject_id}",
|
||||
response_model=SubjectRead,
|
||||
@@ -100,3 +120,31 @@ async def update_subject(
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
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 subject as subject_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()
|
||||
|
||||
@@ -34,6 +34,44 @@ async def 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(
|
||||
"/{visit_id}",
|
||||
response_model=VisitRead,
|
||||
@@ -68,3 +106,32 @@ async def update_visit(
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user