信息架构/菜单框架大重构-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,
)
-3
View File
@@ -1,3 +0,0 @@
AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"]
AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"]
AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"]
-2
View File
@@ -1,2 +0,0 @@
FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"]
FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"]
-10
View File
@@ -1,10 +0,0 @@
IMP_TX_TYPES = [
"RECEIPT",
"DISPENSE",
"RETURN",
"RECONCILE",
"DESTROY",
"TRANSFER_IN",
"TRANSFER_OUT",
]
IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"]
-3
View File
@@ -1,3 +0,0 @@
ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"]
ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"]
-2
View File
@@ -1,2 +0,0 @@
SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"]
VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"]
+5
View File
@@ -113,3 +113,8 @@ async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_
await db.commit()
await db.refresh(ae)
return ae
async def delete_ae(db: AsyncSession, ae: AdverseEvent) -> None:
await db.delete(ae)
await db.commit()
-81
View File
@@ -1,81 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.comment import Comment
from app.schemas.comment import CommentCreate
async def create_comment(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
comment_in: CommentCreate,
created_by: uuid.UUID,
) -> Comment:
comment = Comment(
study_id=study_id,
entity_type=entity_type,
entity_id=entity_id,
content=comment_in.content,
quote_comment_id=comment_in.quote_comment_id,
created_by=created_by,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
return comment
async def list_comments(
db: AsyncSession,
study_id: uuid.UUID,
entity_type: str,
entity_id: uuid.UUID,
) -> Sequence[Comment]:
result = await db.execute(
select(Comment)
.where(
Comment.study_id == study_id,
Comment.entity_type == entity_type,
Comment.entity_id == entity_id,
Comment.is_deleted.is_(False),
)
.order_by(Comment.created_at.asc())
)
return result.scalars().all()
async def get_comment(db: AsyncSession, comment_id: uuid.UUID, *, include_deleted: bool = False) -> Comment | None:
stmt = select(Comment).where(Comment.id == comment_id)
if not include_deleted:
stmt = stmt.where(Comment.is_deleted.is_(False))
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def get_comments_by_ids(
db: AsyncSession,
ids: set[uuid.UUID],
*,
include_deleted: bool = False,
) -> dict[uuid.UUID, Comment]:
if not ids:
return {}
stmt = select(Comment).where(Comment.id.in_(ids))
if not include_deleted:
stmt = stmt.where(Comment.is_deleted.is_(False))
result = await db.execute(stmt)
comments = result.scalars().all()
return {c.id: c for c in comments}
async def soft_delete_comment(db: AsyncSession, comment: Comment) -> Comment:
comment.is_deleted = True
db.add(comment)
await db.commit()
await db.refresh(comment)
return comment
-117
View File
@@ -1,117 +0,0 @@
import uuid
from datetime import date, datetime, timezone
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.data_query import DataQuery
from app.models.site import Site
from app.models.subject import Subject
from app.schemas.data_query import DataQueryCreate, DataQueryUpdate
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
if site_id:
result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none()
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
subject_site = None
if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
subject_site = subj.site_id
if site_id and subject_site and site_id != subject_site:
raise ValueError("Site and subject mismatch")
async def create_query(
db: AsyncSession,
study_id: uuid.UUID,
query_in: DataQueryCreate,
*,
created_by: uuid.UUID,
) -> DataQuery:
await _validate_site_subject(db, study_id, query_in.site_id, query_in.subject_id)
dq = DataQuery(
study_id=study_id,
site_id=query_in.site_id,
subject_id=query_in.subject_id,
visit_id=query_in.visit_id,
title=query_in.title,
description=query_in.description,
category=query_in.category,
priority=query_in.priority,
assigned_to=query_in.assigned_to,
due_date=query_in.due_date,
status="OPEN",
resolution=None,
closed_at=None,
created_by=created_by,
)
db.add(dq)
await db.commit()
await db.refresh(dq)
return dq
async def get_query(db: AsyncSession, query_id: uuid.UUID) -> DataQuery | None:
result = await db.execute(select(DataQuery).where(DataQuery.id == query_id))
return result.scalar_one_or_none()
async def list_queries(
db: AsyncSession,
study_id: uuid.UUID,
*,
status: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
assigned_to: uuid.UUID | None = None,
overdue: bool | None = None,
category: str | None = None,
priority: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[DataQuery]:
stmt = select(DataQuery).where(DataQuery.study_id == study_id)
if status:
stmt = stmt.where(DataQuery.status == status)
if site_id:
stmt = stmt.where(DataQuery.site_id == site_id)
if subject_id:
stmt = stmt.where(DataQuery.subject_id == subject_id)
if assigned_to:
stmt = stmt.where(DataQuery.assigned_to == assigned_to)
if category:
stmt = stmt.where(DataQuery.category == category)
if priority:
stmt = stmt.where(DataQuery.priority == priority)
if overdue is True:
stmt = stmt.where(DataQuery.due_date < date.today(), DataQuery.status != "CLOSED")
if overdue is False:
stmt = stmt.where((DataQuery.due_date >= date.today()) | (DataQuery.due_date.is_(None)) | (DataQuery.status == "CLOSED"))
stmt = stmt.offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_query(db: AsyncSession, dq: DataQuery, dq_in: DataQueryUpdate) -> DataQuery:
update_data = dq_in.model_dump(exclude_unset=True)
if "status" in update_data:
if update_data["status"] == "CLOSED":
update_data["closed_at"] = datetime.now(timezone.utc)
else:
update_data["closed_at"] = None
if update_data:
await db.execute(
sa_update(DataQuery)
.where(DataQuery.id == dq.id)
.values(**update_data)
)
await db.commit()
await db.refresh(dq)
return dq
+76
View File
@@ -0,0 +1,76 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.drug_shipment import DrugShipment
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentUpdate
async def create_shipment(
db: AsyncSession,
study_id: uuid.UUID,
shipment_in: DrugShipmentCreate,
created_by: uuid.UUID | None,
) -> DrugShipment:
shipment = DrugShipment(
study_id=study_id,
direction=shipment_in.direction,
site_name=shipment_in.site_name,
drug_desc=shipment_in.drug_desc,
ship_date=shipment_in.ship_date,
carrier=shipment_in.carrier,
tracking_no=shipment_in.tracking_no,
from_party=shipment_in.from_party,
to_party=shipment_in.to_party,
status=shipment_in.status,
remark=shipment_in.remark,
created_by=created_by,
)
db.add(shipment)
await db.commit()
await db.refresh(shipment)
return shipment
async def get_shipment(db: AsyncSession, shipment_id: uuid.UUID) -> DrugShipment | None:
result = await db.execute(select(DrugShipment).where(DrugShipment.id == shipment_id))
return result.scalar_one_or_none()
async def list_shipments(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
tracking_no: str | None = None,
status: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[DrugShipment]:
stmt = select(DrugShipment).where(DrugShipment.study_id == study_id)
if site_name:
stmt = stmt.where(DrugShipment.site_name.ilike(f"%{site_name}%"))
if tracking_no:
stmt = stmt.where(DrugShipment.tracking_no.ilike(f"%{tracking_no}%"))
if status:
stmt = stmt.where(DrugShipment.status == status)
stmt = stmt.order_by(DrugShipment.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_shipment(
db: AsyncSession, shipment: DrugShipment, shipment_in: DrugShipmentUpdate
) -> DrugShipment:
update_data = shipment_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(shipment, key, value)
await db.commit()
await db.refresh(shipment)
return shipment
async def delete_shipment(db: AsyncSession, shipment: DrugShipment) -> None:
await db.delete(shipment)
await db.commit()
+2 -142
View File
@@ -1,150 +1,10 @@
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Sequence
from datetime import date
from sqlalchemy import Numeric, func, select, update as sa_update, case
from sqlalchemy import case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.finance import FinanceItem
from app.models.site import Site
from app.models.subject import Subject
from app.schemas.finance import FinanceCreate, FinanceStatusUpdate, FinanceUpdate
VALID_TRANSITIONS = {
"DRAFT": {"SUBMITTED"},
"SUBMITTED": {"APPROVED", "REJECTED"},
"APPROVED": {"PAID"},
"REJECTED": set(),
"PAID": set(),
}
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
subj_site = None
if site_id:
result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none()
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
subj_site = subj.site_id
if site_id and subj_site and site_id != subj_site:
raise ValueError("Site and subject mismatch")
async def create_item(db: AsyncSession, study_id: uuid.UUID, item_in: FinanceCreate, *, created_by: uuid.UUID) -> FinanceItem:
await _validate_site_subject(db, study_id, item_in.site_id, item_in.subject_id)
item = FinanceItem(
study_id=study_id,
site_id=item_in.site_id,
subject_id=item_in.subject_id,
visit_id=item_in.visit_id,
category=item_in.category,
title=item_in.title,
description=item_in.description,
currency=item_in.currency,
amount=item_in.amount,
occur_date=item_in.occur_date,
status="DRAFT",
created_by=created_by,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
async def get_item(db: AsyncSession, item_id: uuid.UUID) -> FinanceItem | None:
result = await db.execute(select(FinanceItem).where(FinanceItem.id == item_id))
return result.scalar_one_or_none()
async def list_items(
db: AsyncSession,
study_id: uuid.UUID,
*,
status: str | None = None,
category: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
date_from: date | None = None,
date_to: date | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceItem]:
stmt = select(FinanceItem).where(FinanceItem.study_id == study_id)
if status:
stmt = stmt.where(FinanceItem.status == status)
if category:
stmt = stmt.where(FinanceItem.category == category)
if site_id:
stmt = stmt.where(FinanceItem.site_id == site_id)
if subject_id:
stmt = stmt.where(FinanceItem.subject_id == subject_id)
if date_from:
stmt = stmt.where(FinanceItem.occur_date >= date_from)
if date_to:
stmt = stmt.where(FinanceItem.occur_date <= date_to)
stmt = stmt.offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_item_draft(db: AsyncSession, item: FinanceItem, item_in: FinanceUpdate) -> FinanceItem:
if item.status != "DRAFT":
raise ValueError("Only DRAFT items can be edited")
update_data = item_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(FinanceItem)
.where(FinanceItem.id == item.id)
.values(**update_data)
)
await db.commit()
await db.refresh(item)
return item
async def change_status(db: AsyncSession, item: FinanceItem, status_in: FinanceStatusUpdate, *, operator_id: uuid.UUID) -> FinanceItem:
target = status_in.status
allowed = VALID_TRANSITIONS.get(item.status, set())
if target not in allowed:
raise ValueError(f"Invalid status transition {item.status} -> {target}")
update_data = {"status": target}
now = datetime.now(timezone.utc)
if target == "SUBMITTED":
update_data["submitted_at"] = now
if target == "APPROVED":
update_data["approved_at"] = now
update_data["approver_id"] = operator_id
update_data["reject_reason"] = None
if target == "REJECTED":
if not status_in.reject_reason:
raise ValueError("reject_reason required")
update_data["rejected_at"] = now
update_data["approver_id"] = operator_id
update_data["reject_reason"] = status_in.reject_reason
if target == "PAID":
if item.status != "APPROVED":
raise ValueError("Only APPROVED can transition to PAID")
update_data["paid_at"] = now
update_data["payer_id"] = operator_id
await db.execute(
sa_update(FinanceItem)
.where(FinanceItem.id == item.id)
.values(**update_data)
)
await db.commit()
await db.refresh(item)
return item
async def summary(
db: AsyncSession,
study_id: uuid.UUID,
+69
View File
@@ -0,0 +1,69 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.finance_contract import FinanceContract
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractUpdate
async def create_contract(
db: AsyncSession,
study_id: uuid.UUID,
contract_in: FinanceContractCreate,
created_by: uuid.UUID | None,
) -> FinanceContract:
contract = FinanceContract(
study_id=study_id,
site_name=contract_in.site_name,
contract_no=contract_in.contract_no,
signed_date=contract_in.signed_date,
amount=contract_in.amount,
currency=contract_in.currency,
remark=contract_in.remark,
created_by=created_by,
)
db.add(contract)
await db.commit()
await db.refresh(contract)
return contract
async def get_contract(db: AsyncSession, contract_id: uuid.UUID) -> FinanceContract | None:
result = await db.execute(select(FinanceContract).where(FinanceContract.id == contract_id))
return result.scalar_one_or_none()
async def list_contracts(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
contract_no: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceContract]:
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
if site_name:
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
if contract_no:
stmt = stmt.where(FinanceContract.contract_no.ilike(f"%{contract_no}%"))
stmt = stmt.order_by(FinanceContract.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_contract(
db: AsyncSession, contract: FinanceContract, contract_in: FinanceContractUpdate
) -> FinanceContract:
update_data = contract_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(contract, key, value)
await db.commit()
await db.refresh(contract)
return contract
async def delete_contract(db: AsyncSession, contract: FinanceContract) -> None:
await db.delete(contract)
await db.commit()
+69
View File
@@ -0,0 +1,69 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.finance_special import FinanceSpecial
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialUpdate
async def create_special(
db: AsyncSession,
study_id: uuid.UUID,
special_in: FinanceSpecialCreate,
created_by: uuid.UUID | None,
) -> FinanceSpecial:
special = FinanceSpecial(
study_id=study_id,
site_name=special_in.site_name,
fee_type=special_in.fee_type,
amount=special_in.amount,
occur_date=special_in.occur_date,
staff_name=special_in.staff_name,
remark=special_in.remark,
created_by=created_by,
)
db.add(special)
await db.commit()
await db.refresh(special)
return special
async def get_special(db: AsyncSession, special_id: uuid.UUID) -> FinanceSpecial | None:
result = await db.execute(select(FinanceSpecial).where(FinanceSpecial.id == special_id))
return result.scalar_one_or_none()
async def list_specials(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceSpecial]:
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type:
stmt = stmt.where(FinanceSpecial.fee_type == fee_type)
stmt = stmt.order_by(FinanceSpecial.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_special(
db: AsyncSession, special: FinanceSpecial, special_in: FinanceSpecialUpdate
) -> FinanceSpecial:
update_data = special_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(special, key, value)
await db.commit()
await db.refresh(special)
return special
async def delete_special(db: AsyncSession, special: FinanceSpecial) -> None:
await db.delete(special)
await db.commit()
-57
View File
@@ -1,57 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_batch import ImpBatch
from app.models.imp_product import ImpProduct
from app.schemas.imp import BatchCreate, BatchUpdate
async def create_batch(db: AsyncSession, study_id: uuid.UUID, batch_in: BatchCreate) -> ImpBatch:
result = await db.execute(select(ImpProduct).where(ImpProduct.id == batch_in.product_id))
product = result.scalar_one_or_none()
if not product or product.study_id != study_id:
raise ValueError("Product not found in study")
batch = ImpBatch(
study_id=study_id,
product_id=batch_in.product_id,
batch_no=batch_in.batch_no,
expiry_date=batch_in.expiry_date,
manufacture_date=batch_in.manufacture_date,
status="ACTIVE",
)
db.add(batch)
await db.commit()
await db.refresh(batch)
return batch
async def get_batch(db: AsyncSession, batch_id: uuid.UUID) -> ImpBatch | None:
result = await db.execute(select(ImpBatch).where(ImpBatch.id == batch_id))
return result.scalar_one_or_none()
async def list_batches(db: AsyncSession, study_id: uuid.UUID, product_id: uuid.UUID | None = None, status: str | None = None) -> Sequence[ImpBatch]:
stmt = select(ImpBatch).where(ImpBatch.study_id == study_id)
if product_id:
stmt = stmt.where(ImpBatch.product_id == product_id)
if status:
stmt = stmt.where(ImpBatch.status == status)
result = await db.execute(stmt)
return result.scalars().all()
async def update_batch(db: AsyncSession, batch: ImpBatch, batch_in: BatchUpdate) -> ImpBatch:
update_data = batch_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(ImpBatch)
.where(ImpBatch.id == batch.id)
.values(**update_data)
)
await db.commit()
await db.refresh(batch)
return batch
-46
View File
@@ -1,46 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_inventory import ImpInventory
from app.models.imp_batch import ImpBatch
async def get_inventory_for_update(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory | None:
result = await db.execute(
select(ImpInventory)
.where(
ImpInventory.study_id == study_id,
ImpInventory.site_id == site_id,
ImpInventory.batch_id == batch_id,
)
.with_for_update()
)
return result.scalar_one_or_none()
async def get_or_create_inventory(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory:
inv = await get_inventory_for_update(db, study_id, site_id, batch_id)
if inv:
return inv
inv = ImpInventory(study_id=study_id, site_id=site_id, batch_id=batch_id, quantity_on_hand=0)
db.add(inv)
await db.flush()
return inv
async def list_inventory(
db: AsyncSession,
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
product_id: uuid.UUID | None = None,
) -> Sequence[ImpInventory]:
stmt = select(ImpInventory).where(ImpInventory.study_id == study_id)
if site_id:
stmt = stmt.where(ImpInventory.site_id == site_id)
if product_id:
stmt = stmt.join(ImpBatch, ImpBatch.id == ImpInventory.batch_id).where(ImpBatch.product_id == product_id)
result = await db.execute(stmt)
return result.scalars().all()
-50
View File
@@ -1,50 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_product import ImpProduct
from app.schemas.imp import ProductCreate, ProductUpdate
async def create_product(db: AsyncSession, study_id: uuid.UUID, product_in: ProductCreate) -> ImpProduct:
product = ImpProduct(
study_id=study_id,
name=product_in.name,
form=product_in.form,
strength=product_in.strength,
unit=product_in.unit,
description=product_in.description,
is_active=product_in.is_active,
)
db.add(product)
await db.commit()
await db.refresh(product)
return product
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> ImpProduct | None:
result = await db.execute(select(ImpProduct).where(ImpProduct.id == product_id))
return result.scalar_one_or_none()
async def list_products(db: AsyncSession, study_id: uuid.UUID, is_active: bool | None = None) -> Sequence[ImpProduct]:
stmt = select(ImpProduct).where(ImpProduct.study_id == study_id)
if is_active is not None:
stmt = stmt.where(ImpProduct.is_active == is_active)
result = await db.execute(stmt)
return result.scalars().all()
async def update_product(db: AsyncSession, product: ImpProduct, product_in: ProductUpdate) -> ImpProduct:
update_data = product_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(ImpProduct)
.where(ImpProduct.id == product.id)
.values(**update_data)
)
await db.commit()
await db.refresh(product)
return product
-70
View File
@@ -1,70 +0,0 @@
import uuid
from datetime import date
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.imp_transaction import ImpTransaction
async def create_tx(
db: AsyncSession,
*,
study_id: uuid.UUID,
site_id: uuid.UUID,
batch_id: uuid.UUID,
subject_id: uuid.UUID | None,
tx_type: str,
quantity: int,
tx_date: date,
reference: str | None,
notes: str | None,
created_by: uuid.UUID,
) -> ImpTransaction:
tx = ImpTransaction(
study_id=study_id,
site_id=site_id,
batch_id=batch_id,
subject_id=subject_id,
tx_type=tx_type,
quantity=quantity,
tx_date=tx_date,
reference=reference,
notes=notes,
created_by=created_by,
)
db.add(tx)
await db.flush()
return tx
async def list_txs(
db: AsyncSession,
study_id: uuid.UUID,
*,
site_id: uuid.UUID | None = None,
batch_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
tx_type: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[ImpTransaction]:
stmt = select(ImpTransaction).where(ImpTransaction.study_id == study_id)
if site_id:
stmt = stmt.where(ImpTransaction.site_id == site_id)
if batch_id:
stmt = stmt.where(ImpTransaction.batch_id == batch_id)
if subject_id:
stmt = stmt.where(ImpTransaction.subject_id == subject_id)
if tx_type:
stmt = stmt.where(ImpTransaction.tx_type == tx_type)
if date_from:
stmt = stmt.where(ImpTransaction.tx_date >= date_from)
if date_to:
stmt = stmt.where(ImpTransaction.tx_date <= date_to)
stmt = stmt.offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
-99
View File
@@ -1,99 +0,0 @@
import uuid
from datetime import date, datetime, timezone
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.issue import Issue
from app.models.site import Site
from app.models.subject import Subject
from app.schemas.issue import IssueCreate, IssueUpdate
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
if site_id:
result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none()
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
async def create_issue(
db: AsyncSession,
study_id: uuid.UUID,
issue_in: IssueCreate,
*,
created_by: uuid.UUID,
) -> Issue:
await _validate_site_subject(db, study_id, issue_in.site_id, issue_in.subject_id)
issue = Issue(
study_id=study_id,
site_id=issue_in.site_id,
subject_id=issue_in.subject_id,
title=issue_in.title,
description=issue_in.description,
category=issue_in.category,
level=issue_in.level,
owner_id=issue_in.owner_id,
due_date=issue_in.due_date,
status="OPEN",
capa=None,
closed_at=None,
created_by=created_by,
)
db.add(issue)
await db.commit()
await db.refresh(issue)
return issue
async def get_issue(db: AsyncSession, issue_id: uuid.UUID) -> Issue | None:
result = await db.execute(select(Issue).where(Issue.id == issue_id))
return result.scalar_one_or_none()
async def list_issues(
db: AsyncSession,
study_id: uuid.UUID,
status: str | None = None,
level: str | None = None,
category: str | None = None,
overdue: bool | None = None,
) -> Sequence[Issue]:
stmt = select(Issue).where(Issue.study_id == study_id)
if status:
stmt = stmt.where(Issue.status == status)
if level:
stmt = stmt.where(Issue.level == level)
if category:
stmt = stmt.where(Issue.category == category)
if overdue is True:
stmt = stmt.where(Issue.due_date < date.today(), Issue.status != "CLOSED")
if overdue is False:
stmt = stmt.where((Issue.due_date >= date.today()) | (Issue.due_date.is_(None)) | (Issue.status == "CLOSED"))
result = await db.execute(stmt)
return result.scalars().all()
async def update_issue(db: AsyncSession, issue: Issue, issue_in: IssueUpdate) -> Issue:
update_data = issue_in.model_dump(exclude_unset=True)
if "status" in update_data:
if update_data["status"] == "CLOSED":
update_data["closed_at"] = datetime.now(timezone.utc)
else:
update_data["closed_at"] = None
if update_data:
await db.execute(
sa_update(Issue)
.where(Issue.id == issue.id)
.values(**update_data)
)
await db.commit()
await db.refresh(issue)
return issue
+67
View File
@@ -0,0 +1,67 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.knowledge_note import KnowledgeNote
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteUpdate
async def create_note(
db: AsyncSession,
study_id: uuid.UUID,
note_in: KnowledgeNoteCreate,
created_by: uuid.UUID | None,
) -> KnowledgeNote:
note = KnowledgeNote(
study_id=study_id,
site_name=note_in.site_name,
title=note_in.title,
content=note_in.content,
level=note_in.level,
created_by=created_by,
)
db.add(note)
await db.commit()
await db.refresh(note)
return note
async def get_note(db: AsyncSession, note_id: uuid.UUID) -> KnowledgeNote | None:
result = await db.execute(select(KnowledgeNote).where(KnowledgeNote.id == note_id))
return result.scalar_one_or_none()
async def list_notes(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
keyword: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[KnowledgeNote]:
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
if site_name:
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
if keyword:
stmt = stmt.where(KnowledgeNote.title.ilike(f"%{keyword}%"))
stmt = stmt.order_by(KnowledgeNote.updated_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_note(
db: AsyncSession, note: KnowledgeNote, note_in: KnowledgeNoteUpdate
) -> KnowledgeNote:
update_data = note_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(note, key, value)
await db.commit()
await db.refresh(note)
return note
async def delete_note(db: AsyncSession, note: KnowledgeNote) -> None:
await db.delete(note)
await db.commit()
-54
View File
@@ -1,54 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import delete, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.milestone import Milestone
from app.schemas.milestone import MilestoneCreate, MilestoneUpdate
async def create(db: AsyncSession, study_id: uuid.UUID, milestone_in: MilestoneCreate) -> Milestone:
milestone = Milestone(
study_id=study_id,
type=milestone_in.type,
name=milestone_in.name or milestone_in.type,
planned_date=milestone_in.planned_date,
actual_date=None,
status=milestone_in.status or "NOT_STARTED",
owner_id=milestone_in.owner_id,
site_id=milestone_in.site_id,
notes=milestone_in.notes,
)
db.add(milestone)
await db.commit()
await db.refresh(milestone)
return milestone
async def get(db: AsyncSession, milestone_id: uuid.UUID) -> Milestone | None:
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
return result.scalar_one_or_none()
async def list_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]:
result = await db.execute(select(Milestone).where(Milestone.study_id == study_id).order_by(Milestone.planned_date))
return result.scalars().all()
async def update(db: AsyncSession, milestone: Milestone, milestone_in: MilestoneUpdate) -> Milestone:
update_data = milestone_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(Milestone)
.where(Milestone.id == milestone.id)
.values(**update_data)
)
await db.commit()
await db.refresh(milestone)
return milestone
async def delete_milestone(db: AsyncSession, milestone: Milestone) -> None:
await db.execute(delete(Milestone).where(Milestone.id == milestone.id))
await db.commit()
+257
View File
@@ -0,0 +1,257 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.startup_feasibility import StartupFeasibility
from app.models.startup_ethics import StartupEthics
from app.models.kickoff_meeting import KickoffMeeting
from app.models.training_authorization import TrainingAuthorization
from app.schemas.startup import (
KickoffMeetingCreate,
KickoffMeetingUpdate,
StartupEthicsCreate,
StartupEthicsUpdate,
StartupFeasibilityCreate,
StartupFeasibilityUpdate,
TrainingAuthorizationCreate,
TrainingAuthorizationUpdate,
)
async def create_feasibility(
db: AsyncSession,
study_id: uuid.UUID,
record_in: StartupFeasibilityCreate,
created_by: uuid.UUID | None,
) -> StartupFeasibility:
record = StartupFeasibility(
study_id=study_id,
submit_date=record_in.submit_date,
accept_date=record_in.accept_date,
approved_date=record_in.approved_date,
project_no=record_in.project_no,
created_by=created_by,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record
async def get_feasibility(db: AsyncSession, record_id: uuid.UUID) -> StartupFeasibility | None:
result = await db.execute(select(StartupFeasibility).where(StartupFeasibility.id == record_id))
return result.scalar_one_or_none()
async def list_feasibilities(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
) -> Sequence[StartupFeasibility]:
stmt = (
select(StartupFeasibility)
.where(StartupFeasibility.study_id == study_id)
.order_by(StartupFeasibility.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
async def update_feasibility(
db: AsyncSession, record: StartupFeasibility, record_in: StartupFeasibilityUpdate
) -> StartupFeasibility:
update_data = record_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(record, key, value)
await db.commit()
await db.refresh(record)
return record
async def delete_feasibility(db: AsyncSession, record: StartupFeasibility) -> None:
await db.delete(record)
await db.commit()
async def create_ethics(
db: AsyncSession,
study_id: uuid.UUID,
record_in: StartupEthicsCreate,
created_by: uuid.UUID | None,
) -> StartupEthics:
record = StartupEthics(
study_id=study_id,
submit_date=record_in.submit_date,
accept_date=record_in.accept_date,
meeting_date=record_in.meeting_date,
approved_date=record_in.approved_date,
approval_no=record_in.approval_no,
created_by=created_by,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record
async def get_ethics(db: AsyncSession, record_id: uuid.UUID) -> StartupEthics | None:
result = await db.execute(select(StartupEthics).where(StartupEthics.id == record_id))
return result.scalar_one_or_none()
async def list_ethics(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
) -> Sequence[StartupEthics]:
stmt = (
select(StartupEthics)
.where(StartupEthics.study_id == study_id)
.order_by(StartupEthics.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
async def update_ethics(
db: AsyncSession, record: StartupEthics, record_in: StartupEthicsUpdate
) -> StartupEthics:
update_data = record_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(record, key, value)
await db.commit()
await db.refresh(record)
return record
async def delete_ethics(db: AsyncSession, record: StartupEthics) -> None:
await db.delete(record)
await db.commit()
async def create_kickoff(
db: AsyncSession,
study_id: uuid.UUID,
meeting_in: KickoffMeetingCreate,
created_by: uuid.UUID | None,
) -> KickoffMeeting:
meeting = KickoffMeeting(
study_id=study_id,
kickoff_date=meeting_in.kickoff_date,
attendees=meeting_in.attendees,
created_by=created_by,
)
db.add(meeting)
await db.commit()
await db.refresh(meeting)
return meeting
async def get_kickoff(db: AsyncSession, meeting_id: uuid.UUID) -> KickoffMeeting | None:
result = await db.execute(select(KickoffMeeting).where(KickoffMeeting.id == meeting_id))
return result.scalar_one_or_none()
async def list_kickoffs(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
) -> Sequence[KickoffMeeting]:
stmt = (
select(KickoffMeeting)
.where(KickoffMeeting.study_id == study_id)
.order_by(KickoffMeeting.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
async def update_kickoff(
db: AsyncSession, meeting: KickoffMeeting, meeting_in: KickoffMeetingUpdate
) -> KickoffMeeting:
update_data = meeting_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(meeting, key, value)
await db.commit()
await db.refresh(meeting)
return meeting
async def delete_kickoff(db: AsyncSession, meeting: KickoffMeeting) -> None:
await db.delete(meeting)
await db.commit()
async def create_training_authorization(
db: AsyncSession,
study_id: uuid.UUID,
record_in: TrainingAuthorizationCreate,
created_by: uuid.UUID | None,
) -> TrainingAuthorization:
record = TrainingAuthorization(
study_id=study_id,
name=record_in.name,
role=record_in.role,
site_name=record_in.site_name,
trained=record_in.trained,
authorized=record_in.authorized,
trained_date=record_in.trained_date,
authorized_date=record_in.authorized_date,
remark=record_in.remark,
created_by=created_by,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record
async def get_training_authorization(
db: AsyncSession, record_id: uuid.UUID
) -> TrainingAuthorization | None:
result = await db.execute(select(TrainingAuthorization).where(TrainingAuthorization.id == record_id))
return result.scalar_one_or_none()
async def list_training_authorizations(
db: AsyncSession,
study_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
) -> Sequence[TrainingAuthorization]:
stmt = (
select(TrainingAuthorization)
.where(TrainingAuthorization.study_id == study_id)
.order_by(TrainingAuthorization.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
async def update_training_authorization(
db: AsyncSession, record: TrainingAuthorization, record_in: TrainingAuthorizationUpdate
) -> TrainingAuthorization:
update_data = record_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(record, key, value)
await db.commit()
await db.refresh(record)
return record
async def delete_training_authorization(db: AsyncSession, record: TrainingAuthorization) -> None:
await db.delete(record)
await db.commit()
+8
View File
@@ -57,12 +57,15 @@ async def list_subjects(
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
status: str | None = None,
subject_no: str | None = None,
) -> Sequence[Subject]:
stmt = select(Subject).where(Subject.study_id == study_id)
if site_id:
stmt = stmt.where(Subject.site_id == site_id)
if status:
stmt = stmt.where(Subject.status == status)
if subject_no:
stmt = stmt.where(Subject.subject_no.ilike(f"%{subject_no}%"))
result = await db.execute(stmt)
return result.scalars().all()
@@ -102,3 +105,8 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject
await db.commit()
await db.refresh(subject)
return subject
async def delete_subject(db: AsyncSession, subject: Subject) -> None:
await db.delete(subject)
await db.commit()
+75
View File
@@ -0,0 +1,75 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subject import Subject
from app.models.subject_history import SubjectHistory
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryUpdate
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID) -> None:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subject = result.scalar_one_or_none()
if not subject or subject.study_id != study_id:
raise ValueError("Subject not found in study")
async def create_history(
db: AsyncSession,
study_id: uuid.UUID,
history_in: SubjectHistoryCreate,
created_by: uuid.UUID | None,
) -> SubjectHistory:
await _ensure_subject(db, study_id, history_in.subject_id)
history = SubjectHistory(
study_id=study_id,
subject_id=history_in.subject_id,
record_date=history_in.record_date,
content=history_in.content,
created_by=created_by,
)
db.add(history)
await db.commit()
await db.refresh(history)
return history
async def get_history(db: AsyncSession, history_id: uuid.UUID) -> SubjectHistory | None:
result = await db.execute(select(SubjectHistory).where(SubjectHistory.id == history_id))
return result.scalar_one_or_none()
async def list_histories(
db: AsyncSession,
study_id: uuid.UUID,
subject_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
) -> Sequence[SubjectHistory]:
stmt = (
select(SubjectHistory)
.where(SubjectHistory.study_id == study_id, SubjectHistory.subject_id == subject_id)
.order_by(SubjectHistory.record_date.desc().nullslast(), SubjectHistory.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
async def update_history(
db: AsyncSession, history: SubjectHistory, history_in: SubjectHistoryUpdate
) -> SubjectHistory:
update_data = history_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(history, key, value)
await db.commit()
await db.refresh(history)
return history
async def delete_history(db: AsyncSession, history: SubjectHistory) -> None:
await db.delete(history)
await db.commit()
-108
View File
@@ -1,108 +0,0 @@
import uuid
from datetime import date
from typing import Sequence
from sqlalchemy import select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.subject import Subject
from app.models.verification import VerificationProgress
from app.schemas.verification import VerificationCreate, VerificationUpdate
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, subject_id: uuid.UUID):
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
if subj.site_id != site_id:
raise ValueError("Site does not match subject")
async def upsert_progress(
db: AsyncSession,
study_id: uuid.UUID,
data: VerificationCreate,
) -> VerificationProgress:
await _validate_site_subject(db, study_id, data.site_id, data.subject_id)
result = await db.execute(
select(VerificationProgress).where(
VerificationProgress.study_id == study_id,
VerificationProgress.subject_id == data.subject_id,
VerificationProgress.level == data.level,
)
)
existing = result.scalar_one_or_none()
if existing:
update_data = data.model_dump()
await db.execute(
sa_update(VerificationProgress)
.where(VerificationProgress.id == existing.id)
.values(**update_data)
)
await db.commit()
await db.refresh(existing)
return existing
vp = VerificationProgress(
study_id=study_id,
site_id=data.site_id,
subject_id=data.subject_id,
level=data.level,
percent=data.percent,
last_verified_at=data.last_verified_at,
verifier_id=data.verifier_id,
notes=data.notes,
)
db.add(vp)
await db.commit()
await db.refresh(vp)
return vp
async def get_progress(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID, level: str) -> VerificationProgress | None:
result = await db.execute(
select(VerificationProgress).where(
VerificationProgress.study_id == study_id,
VerificationProgress.subject_id == subject_id,
VerificationProgress.level == level,
)
)
return result.scalar_one_or_none()
async def get_by_id(db: AsyncSession, verification_id: uuid.UUID) -> VerificationProgress | None:
result = await db.execute(select(VerificationProgress).where(VerificationProgress.id == verification_id))
return result.scalar_one_or_none()
async def list_progress(
db: AsyncSession,
study_id: uuid.UUID,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
level: str | None = None,
) -> Sequence[VerificationProgress]:
stmt = select(VerificationProgress).where(VerificationProgress.study_id == study_id)
if site_id:
stmt = stmt.where(VerificationProgress.site_id == site_id)
if subject_id:
stmt = stmt.where(VerificationProgress.subject_id == subject_id)
if level:
stmt = stmt.where(VerificationProgress.level == level)
result = await db.execute(stmt)
return result.scalars().all()
async def update_progress(db: AsyncSession, vp: VerificationProgress, data: VerificationUpdate) -> VerificationProgress:
update_data = data.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(VerificationProgress)
.where(VerificationProgress.id == vp.id)
.values(**update_data)
)
await db.commit()
await db.refresh(vp)
return vp
+5
View File
@@ -58,3 +58,8 @@ async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) ->
await db.commit()
await db.refresh(visit)
return visit
async def delete_visit(db: AsyncSession, visit: Visit) -> None:
await db.delete(visit)
await db.commit()
+9 -8
View File
@@ -5,21 +5,22 @@ from app.models.user import User # noqa: F401
from app.models.study import Study # noqa: F401
from app.models.site import Site # noqa: F401
from app.models.study_member import StudyMember # noqa: F401
from app.models.comment import Comment # noqa: F401
from app.models.attachment import Attachment # noqa: F401
from app.models.audit_log import AuditLog # noqa: F401
from app.models.milestone import Milestone # noqa: F401
from app.models.subject import Subject # noqa: F401
from app.models.visit import Visit # noqa: F401
from app.models.ae import AdverseEvent # noqa: F401
from app.models.issue import Issue # noqa: F401
from app.models.data_query import DataQuery # noqa: F401
from app.models.verification import VerificationProgress # noqa: F401
from app.models.imp_product import ImpProduct # noqa: F401
from app.models.imp_batch import ImpBatch # noqa: F401
from app.models.imp_inventory import ImpInventory # noqa: F401
from app.models.imp_transaction import ImpTransaction # noqa: F401
from app.models.finance import FinanceItem # noqa: F401
from app.models.finance_contract import FinanceContract # noqa: F401
from app.models.finance_special import FinanceSpecial # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.startup_feasibility import StartupFeasibility # noqa: F401
from app.models.startup_ethics import StartupEthics # noqa: F401
from app.models.kickoff_meeting import KickoffMeeting # noqa: F401
from app.models.training_authorization import TrainingAuthorization # noqa: F401
from app.models.knowledge_note import KnowledgeNote # noqa: F401
from app.models.subject_history import SubjectHistory # noqa: F401
from app.models.faq_category import FaqCategory # noqa: F401
from app.models.faq_item import FaqItem # noqa: F401
from app.models.faq_reply import FaqReply # noqa: F401
-10
View File
@@ -72,26 +72,16 @@ def create_app() -> FastAPI:
{"name": "studies", "description": "项目管理"},
{"name": "sites", "description": "中心管理"},
{"name": "study-members", "description": "项目成员"},
{"name": "comments", "description": "通用评论"},
{"name": "attachments", "description": "通用附件"},
{"name": "audit-logs", "description": "审计日志"},
{"name": "milestones", "description": "里程碑"},
{"name": "dashboard", "description": "项目总览与统计"},
{"name": "subjects", "description": "受试者"},
{"name": "visits", "description": "访视"},
{"name": "aes", "description": "不良事件"},
{"name": "issues", "description": "风险 / 问题"},
{"name": "data-queries", "description": "数据问题单"},
{"name": "verifications", "description": "SDV/SDR 核查进度"},
{"name": "imp-products", "description": "药品产品"},
{"name": "imp-batches", "description": "药品批次"},
{"name": "imp-inventory", "description": "药品库存"},
{"name": "imp-transactions", "description": "药品台账流水"},
{"name": "finance", "description": "费用管理"},
{"name": "faq-categories", "description": "FAQ 分类"},
{"name": "faqs", "description": "FAQ 条目"},
{"name": "health", "description": "健康检查"},
{"name": "faq", "description": "常量字典"},
],
)
-22
View File
@@ -1,22 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Comment(Base):
__tablename__ = "comments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
quote_comment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("comments.id"), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
-32
View File
@@ -1,32 +0,0 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class DataQuery(Base):
__tablename__ = "data_queries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
visit_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
priority: Mapped[str] = mapped_column(String(20), nullable=False)
assigned_to: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
resolution: Mapped[str | None] = mapped_column(Text, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+30
View File
@@ -0,0 +1,30 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class DrugShipment(Base):
__tablename__ = "drug_shipments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
direction: Mapped[str] = mapped_column(String(20), nullable=False)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
drug_desc: Mapped[str] = mapped_column(Text, nullable=False)
ship_date: Mapped[date | None] = mapped_column(Date, nullable=True)
carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
tracking_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
from_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
to_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FinanceContract(Base):
__tablename__ = "finance_contracts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
contract_no: Mapped[str] = mapped_column(String(100), nullable=False)
signed_date: Mapped[date | None] = mapped_column(Date, nullable=True)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="CNY")
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+26
View File
@@ -0,0 +1,26 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FinanceSpecial(Base):
__tablename__ = "finance_specials"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
fee_type: Mapped[str] = mapped_column(String(50), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
occur_date: Mapped[date | None] = mapped_column(Date, nullable=True)
staff_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-25
View File
@@ -1,25 +0,0 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpBatch(Base):
__tablename__ = "imp_batches"
__table_args__ = (UniqueConstraint("study_id", "batch_no", "product_id", name="uq_imp_batch_no"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_products.id"), index=True, nullable=False)
batch_no: Mapped[str] = mapped_column(String(100), nullable=False)
expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True)
manufacture_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-20
View File
@@ -1,20 +0,0 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpInventory(Base):
__tablename__ = "imp_inventory"
__table_args__ = (UniqueConstraint("study_id", "site_id", "batch_id", name="uq_imp_inventory_site_batch"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
quantity_on_hand: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
-25
View File
@@ -1,25 +0,0 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpTransaction(Base):
__tablename__ = "imp_transactions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), nullable=True)
tx_type: Mapped[str] = mapped_column(String(20), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
tx_date: Mapped[date] = mapped_column(Date, nullable=False)
reference: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
-31
View File
@@ -1,31 +0,0 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class Issue(Base):
__tablename__ = "issues"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
level: Mapped[str] = mapped_column(String(20), nullable=False)
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
capa: Mapped[str | None] = mapped_column(Text, nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, JSON, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class KickoffMeeting(Base):
__tablename__ = "kickoff_meetings"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
kickoff_date: Mapped[date | None] = mapped_column(Date, nullable=True)
attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
@@ -1,25 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class ImpProduct(Base):
__tablename__ = "imp_products"
__table_args__ = (UniqueConstraint("study_id", "name", name="uq_imp_product_name"),)
class KnowledgeNote(Base):
__tablename__ = "knowledge_notes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
form: Mapped[str | None] = mapped_column(String(100), nullable=True)
strength: Mapped[str | None] = mapped_column(String(100), nullable=True)
unit: Mapped[str] = mapped_column(String(50), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class StartupEthics(Base):
__tablename__ = "startup_ethics"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
meeting_date: Mapped[date | None] = mapped_column(Date, nullable=True)
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
approval_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class StartupFeasibility(Base):
__tablename__ = "startup_feasibility"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
project_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class SubjectHistory(Base):
__tablename__ = "subject_histories"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
record_date: Mapped[date | None] = mapped_column(Date, nullable=True)
content: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
@@ -0,0 +1,28 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class TrainingAuthorization(Base):
__tablename__ = "training_authorizations"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[str | None] = mapped_column(String(100), nullable=True)
site_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
trained: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
authorized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
trained_date: Mapped[date | None] = mapped_column(Date, nullable=True)
authorized_date: Mapped[date | None] = mapped_column(Date, nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-27
View File
@@ -1,27 +0,0 @@
import uuid
from datetime import datetime, date
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class VerificationProgress(Base):
__tablename__ = "verification_progress"
__table_args__ = (UniqueConstraint("study_id", "subject_id", "level", name="uq_verification_subject_level"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
level: Mapped[str] = mapped_column(String(10), nullable=False) # SDR / SDV
percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_verified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
verifier_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-31
View File
@@ -1,31 +0,0 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class CommentCreate(BaseModel):
content: str = Field(min_length=1)
quote_comment_id: uuid.UUID | None = None
class CommentQuote(BaseModel):
id: uuid.UUID
content: str
created_by: uuid.UUID
created_at: datetime
is_deleted: bool = False
model_config = ConfigDict(from_attributes=True)
class CommentRead(BaseModel):
id: uuid.UUID
content: str
created_by: uuid.UUID
created_at: datetime
quote_comment_id: uuid.UUID | None = None
quote: CommentQuote | None = None
is_deleted: bool = False
model_config = ConfigDict(from_attributes=True)
-51
View File
@@ -1,51 +0,0 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class DataQueryCreate(BaseModel):
title: str
description: Optional[str] = None
category: str
priority: str
site_id: Optional[uuid.UUID] = None
subject_id: Optional[uuid.UUID] = None
visit_id: Optional[uuid.UUID] = None
assigned_to: Optional[uuid.UUID] = None
due_date: Optional[date] = None
class DataQueryUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
priority: Optional[str] = None
assigned_to: Optional[uuid.UUID] = None
due_date: Optional[date] = None
status: Optional[str] = None
resolution: Optional[str] = None
class DataQueryRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: Optional[uuid.UUID]
subject_id: Optional[uuid.UUID]
visit_id: Optional[uuid.UUID]
title: str
description: Optional[str]
category: str
priority: str
assigned_to: Optional[uuid.UUID]
due_date: Optional[date]
status: str
resolution: Optional[str]
closed_at: Optional[datetime]
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
is_overdue: bool | None = None
model_config = ConfigDict(from_attributes=True)
+51
View File
@@ -0,0 +1,51 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class DrugShipmentCreate(BaseModel):
direction: str
site_name: str
drug_desc: str
ship_date: Optional[date] = None
carrier: Optional[str] = None
tracking_no: Optional[str] = None
from_party: Optional[str] = None
to_party: Optional[str] = None
status: str
remark: Optional[str] = None
class DrugShipmentUpdate(BaseModel):
direction: Optional[str] = None
site_name: Optional[str] = None
drug_desc: Optional[str] = None
ship_date: Optional[date] = None
carrier: Optional[str] = None
tracking_no: Optional[str] = None
from_party: Optional[str] = None
to_party: Optional[str] = None
status: Optional[str] = None
remark: Optional[str] = None
class DrugShipmentRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
direction: str
site_name: str
drug_desc: str
ship_date: Optional[date]
carrier: Optional[str]
tracking_no: Optional[str]
from_party: Optional[str]
to_party: Optional[str]
status: str
remark: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+1 -62
View File
@@ -1,67 +1,6 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FinanceCreate(BaseModel):
site_id: Optional[uuid.UUID] = None
subject_id: Optional[uuid.UUID] = None
visit_id: Optional[uuid.UUID] = None
category: str
title: str
description: Optional[str] = None
currency: str = "CNY"
amount: Decimal = Field(gt=0)
occur_date: date
class FinanceUpdate(BaseModel):
site_id: Optional[uuid.UUID] = None
subject_id: Optional[uuid.UUID] = None
visit_id: Optional[uuid.UUID] = None
category: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
currency: Optional[str] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
occur_date: Optional[date] = None
class FinanceStatusUpdate(BaseModel):
status: str
reject_reason: Optional[str] = None
class FinanceRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: Optional[uuid.UUID]
subject_id: Optional[uuid.UUID]
visit_id: Optional[uuid.UUID]
category: str
title: str
description: Optional[str]
currency: str
amount: Decimal
occur_date: date
status: str
submitted_at: Optional[datetime]
approved_at: Optional[datetime]
rejected_at: Optional[datetime]
paid_at: Optional[datetime]
approver_id: Optional[uuid.UUID]
payer_id: Optional[uuid.UUID]
reject_reason: Optional[str]
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
month: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
from pydantic import BaseModel
class FinanceSummaryRead(BaseModel):
total_amount: Decimal
+40
View File
@@ -0,0 +1,40 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FinanceContractCreate(BaseModel):
site_name: str
contract_no: str
signed_date: Optional[date] = None
amount: Decimal = Field(gt=0)
currency: str = "CNY"
remark: Optional[str] = None
class FinanceContractUpdate(BaseModel):
site_name: Optional[str] = None
contract_no: Optional[str] = None
signed_date: Optional[date] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
currency: Optional[str] = None
remark: Optional[str] = None
class FinanceContractRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_name: str
contract_no: str
signed_date: Optional[date]
amount: Decimal
currency: str
remark: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+40
View File
@@ -0,0 +1,40 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FinanceSpecialCreate(BaseModel):
site_name: str
fee_type: str
amount: Decimal = Field(gt=0)
occur_date: Optional[date] = None
staff_name: Optional[str] = None
remark: Optional[str] = None
class FinanceSpecialUpdate(BaseModel):
site_name: Optional[str] = None
fee_type: Optional[str] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
occur_date: Optional[date] = None
staff_name: Optional[str] = None
remark: Optional[str] = None
class FinanceSpecialRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_name: str
fee_type: str
amount: Decimal
occur_date: Optional[date]
staff_name: Optional[str]
remark: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-102
View File
@@ -1,102 +0,0 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class ProductCreate(BaseModel):
name: str
form: Optional[str] = None
strength: Optional[str] = None
unit: str
description: Optional[str] = None
is_active: bool = True
class ProductUpdate(BaseModel):
name: Optional[str] = None
form: Optional[str] = None
strength: Optional[str] = None
unit: Optional[str] = None
description: Optional[str] = None
is_active: Optional[bool] = None
class ProductRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
name: str
form: Optional[str]
strength: Optional[str]
unit: str
description: Optional[str]
is_active: bool
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class BatchCreate(BaseModel):
product_id: uuid.UUID
batch_no: str
expiry_date: Optional[date] = None
manufacture_date: Optional[date] = None
class BatchUpdate(BaseModel):
status: Optional[str] = None
expiry_date: Optional[date] = None
manufacture_date: Optional[date] = None
class BatchRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
product_id: uuid.UUID
batch_no: str
expiry_date: Optional[date]
manufacture_date: Optional[date]
status: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class InventoryRead(BaseModel):
site_id: uuid.UUID
batch_id: uuid.UUID
quantity_on_hand: int
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class TxCreate(BaseModel):
site_id: uuid.UUID
batch_id: uuid.UUID
subject_id: Optional[uuid.UUID] = None
tx_type: str
quantity: int = Field(gt=0)
tx_date: date
reference: Optional[str] = None
notes: Optional[str] = None
class TxRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: uuid.UUID
batch_id: uuid.UUID
subject_id: Optional[uuid.UUID]
tx_type: str
quantity: int
tx_date: date
reference: Optional[str]
notes: Optional[str]
created_by: uuid.UUID
created_at: datetime
model_config = ConfigDict(from_attributes=True)
-49
View File
@@ -1,49 +0,0 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class IssueCreate(BaseModel):
title: str
category: str
level: str
due_date: Optional[date] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
subject_id: Optional[uuid.UUID] = None
description: Optional[str] = None
class IssueUpdate(BaseModel):
title: Optional[str] = None
category: Optional[str] = None
level: Optional[str] = None
due_date: Optional[date] = None
owner_id: Optional[uuid.UUID] = None
status: Optional[str] = None
capa: Optional[str] = None
description: Optional[str] = None
class IssueRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: Optional[uuid.UUID]
subject_id: Optional[uuid.UUID]
title: str
description: Optional[str]
category: str
level: str
owner_id: Optional[uuid.UUID]
due_date: Optional[date]
status: str
capa: Optional[str]
closed_at: Optional[datetime]
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
is_overdue: bool | None = None
model_config = ConfigDict(from_attributes=True)
+33
View File
@@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class KnowledgeNoteCreate(BaseModel):
site_name: str
title: str
content: str
level: Optional[str] = None
class KnowledgeNoteUpdate(BaseModel):
site_name: Optional[str] = None
title: Optional[str] = None
content: Optional[str] = None
level: Optional[str] = None
class KnowledgeNoteRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_name: str
title: str
content: str
level: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-52
View File
@@ -1,52 +0,0 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
from app.schemas.user import UserDisplay
class SiteDisplay(BaseModel):
id: uuid.UUID
name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class MilestoneCreate(BaseModel):
type: str
name: Optional[str] = None
planned_date: Optional[date] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
status: str = Field(default="NOT_STARTED")
class MilestoneUpdate(BaseModel):
name: Optional[str] = None
planned_date: Optional[date] = None
actual_date: Optional[date] = None
status: Optional[str] = None
owner_id: Optional[uuid.UUID] = None
site_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
class MilestoneRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
type: str
name: str
planned_date: Optional[date]
actual_date: Optional[date]
status: str
owner_id: Optional[uuid.UUID]
site_id: Optional[uuid.UUID] = None
owner: Optional[UserDisplay] = None
site: Optional[SiteDisplay] = None
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+126
View File
@@ -0,0 +1,126 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class StartupFeasibilityCreate(BaseModel):
submit_date: Optional[date] = None
accept_date: Optional[date] = None
approved_date: Optional[date] = None
project_no: Optional[str] = None
class StartupFeasibilityUpdate(BaseModel):
submit_date: Optional[date] = None
accept_date: Optional[date] = None
approved_date: Optional[date] = None
project_no: Optional[str] = None
class StartupFeasibilityRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
submit_date: Optional[date]
accept_date: Optional[date]
approved_date: Optional[date]
project_no: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class StartupEthicsCreate(BaseModel):
submit_date: Optional[date] = None
accept_date: Optional[date] = None
meeting_date: Optional[date] = None
approved_date: Optional[date] = None
approval_no: Optional[str] = None
class StartupEthicsUpdate(BaseModel):
submit_date: Optional[date] = None
accept_date: Optional[date] = None
meeting_date: Optional[date] = None
approved_date: Optional[date] = None
approval_no: Optional[str] = None
class StartupEthicsRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
submit_date: Optional[date]
accept_date: Optional[date]
meeting_date: Optional[date]
approved_date: Optional[date]
approval_no: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class KickoffMeetingCreate(BaseModel):
kickoff_date: Optional[date] = None
attendees: Optional[list[str]] = None
class KickoffMeetingUpdate(BaseModel):
kickoff_date: Optional[date] = None
attendees: Optional[list[str]] = None
class KickoffMeetingRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
kickoff_date: Optional[date]
attendees: Optional[list[str]]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class TrainingAuthorizationCreate(BaseModel):
name: str
role: Optional[str] = None
site_name: Optional[str] = None
trained: bool = False
authorized: bool = False
trained_date: Optional[date] = None
authorized_date: Optional[date] = None
remark: Optional[str] = None
class TrainingAuthorizationUpdate(BaseModel):
name: Optional[str] = None
role: Optional[str] = None
site_name: Optional[str] = None
trained: Optional[bool] = None
authorized: Optional[bool] = None
trained_date: Optional[date] = None
authorized_date: Optional[date] = None
remark: Optional[str] = None
class TrainingAuthorizationRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
name: str
role: Optional[str]
site_name: Optional[str]
trained: bool
authorized: bool
trained_date: Optional[date]
authorized_date: Optional[date]
remark: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class SubjectHistoryCreate(BaseModel):
subject_id: uuid.UUID
record_date: Optional[date] = None
content: str
class SubjectHistoryUpdate(BaseModel):
record_date: Optional[date] = None
content: Optional[str] = None
class SubjectHistoryRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
subject_id: uuid.UUID
record_date: Optional[date]
content: str
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-38
View File
@@ -1,38 +0,0 @@
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class VerificationCreate(BaseModel):
site_id: uuid.UUID
subject_id: uuid.UUID
level: str
percent: int = Field(ge=0, le=100)
last_verified_at: Optional[date] = None
verifier_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
class VerificationUpdate(BaseModel):
percent: Optional[int] = Field(default=None, ge=0, le=100)
last_verified_at: Optional[date] = None
verifier_id: Optional[uuid.UUID] = None
notes: Optional[str] = None
class VerificationRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: uuid.UUID
subject_id: uuid.UUID
level: str
percent: int
last_verified_at: Optional[date]
verifier_id: Optional[uuid.UUID]
notes: Optional[str]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-85
View File
@@ -1,85 +0,0 @@
import uuid
from typing import Literal
from sqlalchemy.ext.asyncio import AsyncSession
from app.crud import imp_inventory as inventory_crud
from app.crud import imp_tx as tx_crud
from app.crud import audit as audit_crud
from app.models.imp_batch import ImpBatch
from app.models.site import Site
from app.models.subject import Subject
TxType = Literal["RECEIPT", "DISPENSE", "RETURN", "RECONCILE", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"]
POSITIVE_TYPES = {"RECEIPT", "RETURN", "TRANSFER_IN"}
NEGATIVE_TYPES = {"DISPENSE", "DESTROY", "TRANSFER_OUT"}
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
site = await db.get(Site, site_id)
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
batch = await db.get(ImpBatch, batch_id)
if not batch or batch.study_id != study_id:
raise ValueError("Batch not found in study")
# batch status check could be extended
if subject_id:
subj = await db.get(Subject, subject_id)
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
async def create_transaction_and_apply_inventory(
db: AsyncSession,
*,
study_id: uuid.UUID,
site_id: uuid.UUID,
batch_id: uuid.UUID,
subject_id: uuid.UUID | None,
tx_type: TxType,
quantity: int,
tx_date,
reference: str | None,
notes: str | None,
operator_id: uuid.UUID,
operator_role: str,
) -> tuple:
if quantity <= 0:
raise ValueError("Quantity must be positive")
await _validate_entities(db, study_id, site_id, batch_id, subject_id)
async with db.begin_nested():
inv = await inventory_crud.get_or_create_inventory(db, study_id, site_id, batch_id)
delta = quantity if tx_type in POSITIVE_TYPES else -quantity
new_qoh = inv.quantity_on_hand + delta
if new_qoh < 0:
raise ValueError("Insufficient inventory")
inv.quantity_on_hand = new_qoh
tx = await tx_crud.create_tx(
db,
study_id=study_id,
site_id=site_id,
batch_id=batch_id,
subject_id=subject_id,
tx_type=tx_type,
quantity=quantity,
tx_date=tx_date,
reference=reference,
notes=notes,
created_by=operator_id,
)
await db.flush()
detail = f"{tx_type} {quantity} units batch {batch_id} at site {site_id}, QOH -> {new_qoh}"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="imp_tx",
entity_id=tx.id,
action="CREATE_IMP_TX",
detail=detail,
operator_id=operator_id,
operator_role=operator_role,
)
return tx