信息架构/菜单框架大重构-20260109

This commit is contained in:
Cheng Zhou
2026-01-09 11:19:04 +08:00
parent ba3cf95b8a
commit 9021c7fe2b
188 changed files with 7632 additions and 11658 deletions
+31
View File
@@ -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,
)
-144
View File
@@ -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,
)
-21
View File
@@ -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},
}
-153
View File
@@ -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
+142
View File
@@ -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,
)
-190
View File
@@ -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))
+139
View File
@@ -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,
)
+139
View File
@@ -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,
)
-10
View File
@@ -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()
-95
View File
@@ -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)
-32
View File
@@ -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]
-91
View File
@@ -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)
-112
View File
@@ -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]
-156
View File
@@ -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
+139
View File
@@ -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,
)
-208
View File
@@ -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
+7 -12
View File
@@ -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"])
+504
View File
@@ -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,
)
+144
View File
@@ -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,
)
+49 -1
View File
@@ -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,
)
-115
View File
@@ -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)
+68 -1
View File
@@ -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,
)