信息架构/菜单框架大重构-20260109
This commit is contained in:
@@ -179,3 +179,34 @@ async def update_ae(
|
||||
data = AERead.model_validate(updated)
|
||||
data.is_overdue = _is_overdue(data)
|
||||
return data
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{ae_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def delete_ae(
|
||||
study_id: uuid.UUID,
|
||||
ae_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
ae = await ae_crud.get_ae(db, ae_id)
|
||||
if not ae or ae.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="AE not found")
|
||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||
if current_user.role != "ADMIN" and member_role not in {"PM", "PV"}:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
await ae_crud.delete_ae(db, ae)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="ae",
|
||||
entity_id=ae_id,
|
||||
action="DELETE_AE",
|
||||
detail=f"AE {ae_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member
|
||||
from app.crud import comment as comment_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.comment import CommentCreate, CommentRead, CommentQuote
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=CommentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def create_comment(
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
comment_in: CommentCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> CommentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
quote = None
|
||||
if comment_in.quote_comment_id:
|
||||
quote = await comment_crud.get_comment(db, comment_in.quote_comment_id, include_deleted=True)
|
||||
if (
|
||||
not quote
|
||||
or quote.study_id != study_id
|
||||
or quote.entity_type != entity_type
|
||||
or quote.entity_id != entity_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用评论不存在")
|
||||
if quote.is_deleted:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="引用评论已删除")
|
||||
comment = await comment_crud.create_comment(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
comment_in=comment_in,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="CREATE_COMMENT",
|
||||
detail=f"Comment created by {current_user.full_name}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return CommentRead(
|
||||
id=comment.id,
|
||||
content=comment.content,
|
||||
created_by=comment.created_by,
|
||||
created_at=comment.created_at,
|
||||
quote_comment_id=comment.quote_comment_id,
|
||||
quote=CommentQuote.model_validate(quote) if quote else None,
|
||||
is_deleted=comment.is_deleted,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[CommentRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_comments(
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[CommentRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
comments = await comment_crud.list_comments(db, study_id, entity_type, entity_id)
|
||||
quote_ids = {c.quote_comment_id for c in comments if c.quote_comment_id}
|
||||
quote_map = await comment_crud.get_comments_by_ids(db, quote_ids, include_deleted=True)
|
||||
return [
|
||||
CommentRead(
|
||||
id=c.id,
|
||||
content=c.content,
|
||||
created_by=c.created_by,
|
||||
created_at=c.created_at,
|
||||
quote_comment_id=c.quote_comment_id,
|
||||
quote=CommentQuote.model_validate(quote_map.get(c.quote_comment_id)) if c.quote_comment_id else None,
|
||||
is_deleted=c.is_deleted,
|
||||
)
|
||||
for c in comments
|
||||
]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{comment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def delete_comment(
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
comment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
await _ensure_study_exists(db, study_id)
|
||||
comment = await comment_crud.get_comment(db, comment_id, include_deleted=True)
|
||||
if (
|
||||
not comment
|
||||
or comment.study_id != study_id
|
||||
or comment.entity_type != entity_type
|
||||
or comment.entity_id != entity_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value != "ADMIN" and comment.created_by != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
if comment.is_deleted:
|
||||
return
|
||||
await comment_crud.soft_delete_comment(db, comment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="DELETE_COMMENT",
|
||||
detail=f"Comment deleted by {current_user.full_name}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.constants import ae, finance, imp, issue, subject
|
||||
from app.core.deps import get_current_user
|
||||
|
||||
router = APIRouter(tags=["FAQ"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
summary="获取前端常量字典",
|
||||
description="返回各模块状态/类型常量,便于前端一次性加载。",
|
||||
)
|
||||
async def get_constants():
|
||||
return {
|
||||
"subject": {"status": subject.SUBJECT_STATUS, "visit_status": subject.VISIT_STATUS},
|
||||
"ae": {"seriousness": ae.AE_SERIOUSNESS, "severity": ae.AE_SEVERITY, "status": ae.AE_STATUS},
|
||||
"issue": {"category": issue.ISSUE_CATEGORY, "level": issue.ISSUE_LEVEL, "status": issue.ISSUE_STATUS},
|
||||
"finance": {"status": finance.FINANCE_STATUS, "category": finance.FINANCE_CATEGORY},
|
||||
"imp": {"tx_types": imp.IMP_TX_TYPES, "batch_status": imp.IMP_BATCH_STATUS},
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import data_query as dq_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.data_query import DataQueryCreate, DataQueryRead, DataQueryUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _is_overdue(dq: DataQueryRead) -> bool:
|
||||
return bool(dq.due_date and date.today() > dq.due_date and dq.status != "CLOSED")
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=DataQueryRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_query(
|
||||
study_id: uuid.UUID,
|
||||
query_in: DataQueryCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DataQueryRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
try:
|
||||
dq = await dq_crud.create_query(db, study_id, query_in, created_by=current_user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="data_query",
|
||||
entity_id=dq.id,
|
||||
action="CREATE_DATA_QUERY",
|
||||
detail=f"DataQuery {dq.id} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
obj = DataQueryRead.model_validate(dq)
|
||||
obj.is_overdue = _is_overdue(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[DataQueryRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_queries(
|
||||
study_id: uuid.UUID,
|
||||
status_filter: str | None = None,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
assigned_to: uuid.UUID | None = None,
|
||||
overdue: bool | None = None,
|
||||
category: str | None = None,
|
||||
priority: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[DataQueryRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
queries = await dq_crud.list_queries(
|
||||
db,
|
||||
study_id,
|
||||
status=status_filter,
|
||||
site_id=site_id,
|
||||
subject_id=subject_id,
|
||||
assigned_to=assigned_to,
|
||||
overdue=overdue,
|
||||
category=category,
|
||||
priority=priority,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
result: list[DataQueryRead] = []
|
||||
for q in queries:
|
||||
obj = DataQueryRead.model_validate(q)
|
||||
obj.is_overdue = _is_overdue(obj)
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{query_id}",
|
||||
response_model=DataQueryRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_query(
|
||||
study_id: uuid.UUID,
|
||||
query_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DataQueryRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
dq = await dq_crud.get_query(db, query_id)
|
||||
if not dq or dq.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DataQuery not found")
|
||||
obj = DataQueryRead.model_validate(dq)
|
||||
obj.is_overdue = _is_overdue(obj)
|
||||
return obj
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{query_id}",
|
||||
response_model=DataQueryRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_query(
|
||||
study_id: uuid.UUID,
|
||||
query_id: uuid.UUID,
|
||||
query_in: DataQueryUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DataQueryRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
dq = await dq_crud.get_query(db, query_id)
|
||||
if not dq or dq.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="DataQuery not found")
|
||||
old_status = dq.status
|
||||
updated = await dq_crud.update_query(db, dq, query_in)
|
||||
detail = None
|
||||
action = "UPDATE_DATA_QUERY"
|
||||
if query_in.status and query_in.status != old_status:
|
||||
detail = f"DataQuery {query_id} status {old_status} -> {query_in.status}"
|
||||
action = "DATA_QUERY_STATUS_CHANGE"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="data_query",
|
||||
entity_id=query_id,
|
||||
action=action,
|
||||
detail=detail or "DataQuery updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
obj = DataQueryRead.model_validate(updated)
|
||||
obj.is_overdue = _is_overdue(obj)
|
||||
return obj
|
||||
@@ -0,0 +1,142 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import drug_shipment as shipment_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/shipments",
|
||||
response_model=DrugShipmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_shipment(
|
||||
study_id: uuid.UUID,
|
||||
shipment_in: DrugShipmentCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DrugShipmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment.id,
|
||||
action="CREATE_DRUG_SHIPMENT",
|
||||
detail=f"Drug shipment {shipment.id} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return DrugShipmentRead.model_validate(shipment)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shipments",
|
||||
response_model=list[DrugShipmentRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_shipments(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
tracking_no: str | None = None,
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[DrugShipmentRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await shipment_crud.list_shipments(
|
||||
db, study_id, site_name=site_name, tracking_no=tracking_no, status=status, skip=skip, limit=limit
|
||||
)
|
||||
return [DrugShipmentRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/shipments/{shipment_id}",
|
||||
response_model=DrugShipmentRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_shipment(
|
||||
study_id: uuid.UUID,
|
||||
shipment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> DrugShipmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||
if not shipment or shipment.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found")
|
||||
return DrugShipmentRead.model_validate(shipment)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/shipments/{shipment_id}",
|
||||
response_model=DrugShipmentRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_shipment(
|
||||
study_id: uuid.UUID,
|
||||
shipment_id: uuid.UUID,
|
||||
shipment_in: DrugShipmentUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> DrugShipmentRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||
if not shipment or shipment.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found")
|
||||
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment_id,
|
||||
action="UPDATE_DRUG_SHIPMENT",
|
||||
detail=f"Drug shipment {shipment_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return DrugShipmentRead.model_validate(shipment)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/shipments/{shipment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_shipment(
|
||||
study_id: uuid.UUID,
|
||||
shipment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
shipment = await shipment_crud.get_shipment(db, shipment_id)
|
||||
if not shipment or shipment.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shipment not found")
|
||||
await shipment_crud.delete_shipment(db, shipment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="drug_shipment",
|
||||
entity_id=shipment_id,
|
||||
action="DELETE_DRUG_SHIPMENT",
|
||||
detail=f"Drug shipment {shipment_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import finance as finance_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.finance import FinanceCreate, FinanceRead, FinanceStatusUpdate, FinanceUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
def _with_month(item: FinanceRead) -> FinanceRead:
|
||||
item.month = item.occur_date.strftime("%Y-%m")
|
||||
return item
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items",
|
||||
response_model=FinanceRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_finance_item(
|
||||
study_id: uuid.UUID,
|
||||
item_in: FinanceCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
try:
|
||||
item = await finance_crud.create_item(db, study_id, item_in, created_by=current_user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance",
|
||||
entity_id=item.id,
|
||||
action="CREATE_FINANCE_ITEM",
|
||||
detail=f"Finance {item.id} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return _with_month(FinanceRead.model_validate(item))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/items",
|
||||
response_model=list[FinanceRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_finance_items(
|
||||
study_id: uuid.UUID,
|
||||
status_filter: str | None = None,
|
||||
category: str | None = None,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[FinanceRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await finance_crud.list_items(
|
||||
db,
|
||||
study_id,
|
||||
status=status_filter,
|
||||
category=category,
|
||||
site_id=site_id,
|
||||
subject_id=subject_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return [_with_month(FinanceRead.model_validate(i)) for i in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/items/{item_id}",
|
||||
response_model=FinanceRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_finance_item(
|
||||
study_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FinanceRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await finance_crud.get_item(db, item_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
|
||||
return _with_month(FinanceRead.model_validate(item))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/items/{item_id}",
|
||||
response_model=FinanceRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_finance_item(
|
||||
study_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
item_in: FinanceUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await finance_crud.get_item(db, item_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
|
||||
try:
|
||||
updated = await finance_crud.update_item_draft(db, item, item_in)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance",
|
||||
entity_id=item_id,
|
||||
action="UPDATE_FINANCE_ITEM",
|
||||
detail=f"Finance {item_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return _with_month(FinanceRead.model_validate(updated))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/items/{item_id}/status",
|
||||
response_model=FinanceRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def change_finance_status(
|
||||
study_id: uuid.UUID,
|
||||
item_id: uuid.UUID,
|
||||
status_in: FinanceStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await finance_crud.get_item(db, item_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
|
||||
|
||||
# permission by status target
|
||||
from app.crud import member as member_crud
|
||||
member = await member_crud.get_member(db, study_id, current_user.id)
|
||||
member_role = member.role_in_study if member else None
|
||||
|
||||
target = status_in.status
|
||||
if target in {"SUBMITTED"}:
|
||||
if current_user.role != "ADMIN" and member_role not in {"PM", "CRA"}:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
elif target in {"APPROVED", "REJECTED", "PAID"}:
|
||||
if current_user.role != "ADMIN" and member_role not in {"PM"}:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported status")
|
||||
|
||||
old_status = item.status
|
||||
try:
|
||||
updated = await finance_crud.change_status(db, item, status_in, operator_id=current_user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
detail = f"Finance {item_id} {old_status} -> {status_in.status}"
|
||||
action = "FINANCE_STATUS_CHANGE"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance",
|
||||
entity_id=item_id,
|
||||
action=action,
|
||||
detail=detail,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return _with_month(FinanceRead.model_validate(updated))
|
||||
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import finance_contract as contract_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractRead, FinanceContractUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/contracts",
|
||||
response_model=FinanceContractRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_in: FinanceContractCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceContractRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.create_contract(db, study_id, contract_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_contract",
|
||||
entity_id=contract.id,
|
||||
action="CREATE_FINANCE_CONTRACT",
|
||||
detail=f"Finance contract {contract.contract_no} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceContractRead.model_validate(contract)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/contracts",
|
||||
response_model=list[FinanceContractRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_contracts(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
contract_no: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[FinanceContractRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await contract_crud.list_contracts(db, study_id, site_name=site_name, contract_no=contract_no, skip=skip, limit=limit)
|
||||
return [FinanceContractRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/contracts/{contract_id}",
|
||||
response_model=FinanceContractRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FinanceContractRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.get_contract(db, contract_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found")
|
||||
return FinanceContractRead.model_validate(contract)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/contracts/{contract_id}",
|
||||
response_model=FinanceContractRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_id: uuid.UUID,
|
||||
contract_in: FinanceContractUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceContractRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.get_contract(db, contract_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found")
|
||||
contract = await contract_crud.update_contract(db, contract, contract_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_contract",
|
||||
entity_id=contract_id,
|
||||
action="UPDATE_FINANCE_CONTRACT",
|
||||
detail=f"Finance contract {contract_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceContractRead.model_validate(contract)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/contracts/{contract_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_contract(
|
||||
study_id: uuid.UUID,
|
||||
contract_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
contract = await contract_crud.get_contract(db, contract_id)
|
||||
if not contract or contract.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contract not found")
|
||||
await contract_crud.delete_contract(db, contract)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_contract",
|
||||
entity_id=contract_id,
|
||||
action="DELETE_FINANCE_CONTRACT",
|
||||
detail=f"Finance contract {contract_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import finance_special as special_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/specials",
|
||||
response_model=FinanceSpecialRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_special(
|
||||
study_id: uuid.UUID,
|
||||
special_in: FinanceSpecialCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceSpecialRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_special",
|
||||
entity_id=item.id,
|
||||
action="CREATE_FINANCE_SPECIAL",
|
||||
detail=f"Finance special {item.id} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceSpecialRead.model_validate(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/specials",
|
||||
response_model=list[FinanceSpecialRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_specials(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
fee_type: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[FinanceSpecialRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await special_crud.list_specials(db, study_id, site_name=site_name, fee_type=fee_type, skip=skip, limit=limit)
|
||||
return [FinanceSpecialRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/specials/{special_id}",
|
||||
response_model=FinanceSpecialRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_special(
|
||||
study_id: uuid.UUID,
|
||||
special_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> FinanceSpecialRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.get_special(db, special_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found")
|
||||
return FinanceSpecialRead.model_validate(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/specials/{special_id}",
|
||||
response_model=FinanceSpecialRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_special(
|
||||
study_id: uuid.UUID,
|
||||
special_id: uuid.UUID,
|
||||
special_in: FinanceSpecialUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FinanceSpecialRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.get_special(db, special_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found")
|
||||
item = await special_crud.update_special(db, item, special_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_special",
|
||||
entity_id=special_id,
|
||||
action="UPDATE_FINANCE_SPECIAL",
|
||||
detail=f"Finance special {special_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FinanceSpecialRead.model_validate(item)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/specials/{special_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_special(
|
||||
study_id: uuid.UUID,
|
||||
special_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
item = await special_crud.get_special(db, special_id)
|
||||
if not item or item.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Special fee not found")
|
||||
await special_crud.delete_special(db, item)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="finance_special",
|
||||
entity_id=special_id,
|
||||
action="DELETE_FINANCE_SPECIAL",
|
||||
detail=f"Finance special {special_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session
|
||||
from app.schemas.imp import TxCreate
|
||||
|
||||
router = APIRouter()
|
||||
@@ -1,95 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import imp_batch as batch_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.imp import BatchCreate, BatchRead, BatchUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=BatchRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
||||
)
|
||||
async def create_batch(
|
||||
study_id: uuid.UUID,
|
||||
batch_in: BatchCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> BatchRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
try:
|
||||
batch = await batch_crud.create_batch(db, study_id, batch_in)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="imp_batch",
|
||||
entity_id=batch.id,
|
||||
action="CREATE_IMP_BATCH",
|
||||
detail=f"IMP batch {batch.batch_no} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return BatchRead.model_validate(batch)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[BatchRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_batches(
|
||||
study_id: uuid.UUID,
|
||||
product_id: uuid.UUID | None = None,
|
||||
status_filter: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[BatchRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
batches = await batch_crud.list_batches(db, study_id, product_id=product_id, status=status_filter)
|
||||
return [BatchRead.model_validate(b) for b in batches]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{batch_id}",
|
||||
response_model=BatchRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
||||
)
|
||||
async def update_batch(
|
||||
study_id: uuid.UUID,
|
||||
batch_id: uuid.UUID,
|
||||
batch_in: BatchUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> BatchRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
batch = await batch_crud.get_batch(db, batch_id)
|
||||
if not batch or batch.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Batch not found")
|
||||
updated = await batch_crud.update_batch(db, batch, batch_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="imp_batch",
|
||||
entity_id=batch_id,
|
||||
action="UPDATE_IMP_BATCH",
|
||||
detail=f"IMP batch {batch_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return BatchRead.model_validate(updated)
|
||||
@@ -1,32 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_db_session, require_study_member
|
||||
from app.crud import imp_inventory as inventory_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.imp import InventoryRead
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
return study
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[InventoryRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_inventory(
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
product_id: uuid.UUID | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[InventoryRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
inv = await inventory_crud.list_inventory(db, study_id, site_id=site_id, product_id=product_id)
|
||||
return [InventoryRead.model_validate(i) for i in inv]
|
||||
@@ -1,91 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import imp_product as product_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.imp import ProductCreate, ProductRead, ProductUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ProductRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
||||
)
|
||||
async def create_product(
|
||||
study_id: uuid.UUID,
|
||||
product_in: ProductCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> ProductRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
product = await product_crud.create_product(db, study_id, product_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="imp_product",
|
||||
entity_id=product.id,
|
||||
action="CREATE_IMP_PRODUCT",
|
||||
detail=f"IMP product {product.name} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return ProductRead.model_validate(product)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ProductRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_products(
|
||||
study_id: uuid.UUID,
|
||||
is_active: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[ProductRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
products = await product_crud.list_products(db, study_id, is_active=is_active)
|
||||
return [ProductRead.model_validate(p) for p in products]
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{product_id}",
|
||||
response_model=ProductRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
||||
)
|
||||
async def update_product(
|
||||
study_id: uuid.UUID,
|
||||
product_id: uuid.UUID,
|
||||
product_in: ProductUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> ProductRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
product = await product_crud.get_product(db, product_id)
|
||||
if not product or product.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
|
||||
updated = await product_crud.update_product(db, product, product_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="imp_product",
|
||||
entity_id=product_id,
|
||||
action="UPDATE_IMP_PRODUCT",
|
||||
detail=f"IMP product {product_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return ProductRead.model_validate(updated)
|
||||
@@ -1,112 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import imp_batch as batch_crud
|
||||
from app.crud import imp_tx as tx_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import subject as subject_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.schemas.imp import TxCreate, TxRead
|
||||
from app.services.imp import create_transaction_and_apply_inventory, NEGATIVE_TYPES
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
|
||||
batch = await batch_crud.get_batch(db, batch_id)
|
||||
if not batch or batch.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Batch not found")
|
||||
from app.models.site import Site
|
||||
site = await db.get(Site, site_id)
|
||||
if not site or site.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
|
||||
if subject_id:
|
||||
subj = await subject_crud.get_subject(db, subject_id)
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
|
||||
if batch.expiry_date and date.today() > batch.expiry_date and batch_id:
|
||||
# forbid outflow when expired
|
||||
pass
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=TxRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "IMP"]))],
|
||||
)
|
||||
async def create_transaction(
|
||||
study_id: uuid.UUID,
|
||||
tx_in: TxCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> TxRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
await _validate_entities(db, study_id, tx_in.site_id, tx_in.batch_id, tx_in.subject_id)
|
||||
if tx_in.tx_type in NEGATIVE_TYPES:
|
||||
batch = await batch_crud.get_batch(db, tx_in.batch_id)
|
||||
if batch and batch.status == "EXPIRED":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot dispense from expired batch")
|
||||
try:
|
||||
tx = await create_transaction_and_apply_inventory(
|
||||
db,
|
||||
study_id=study_id,
|
||||
site_id=tx_in.site_id,
|
||||
batch_id=tx_in.batch_id,
|
||||
subject_id=tx_in.subject_id,
|
||||
tx_type=tx_in.tx_type,
|
||||
quantity=tx_in.quantity,
|
||||
tx_date=tx_in.tx_date,
|
||||
reference=tx_in.reference,
|
||||
notes=tx_in.notes,
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
return TxRead.model_validate(tx)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[TxRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_transactions(
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
batch_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
tx_type: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[TxRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
txs = await tx_crud.list_txs(
|
||||
db,
|
||||
study_id,
|
||||
site_id=site_id,
|
||||
batch_id=batch_id,
|
||||
subject_id=subject_id,
|
||||
tx_type=tx_type,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
return [TxRead.model_validate(t) for t in txs]
|
||||
@@ -1,156 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import issue as issue_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.issue import IssueCreate, IssueRead, IssueUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
ALLOWED_EDIT_ROLES = {"PM", "PV"}
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
async def _get_member_role(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> str | None:
|
||||
member = await member_crud.get_member(db, study_id, user_id)
|
||||
return member.role_in_study if member else None
|
||||
|
||||
|
||||
def _is_overdue(issue: IssueRead) -> bool:
|
||||
return bool(issue.due_date and date.today() > issue.due_date and issue.status != "CLOSED")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=IssueRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def create_issue(
|
||||
study_id: uuid.UUID,
|
||||
issue_in: IssueCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> IssueRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||
if current_user.role != "ADMIN" and member_role not in ALLOWED_EDIT_ROLES:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
try:
|
||||
issue = await issue_crud.create_issue(db, study_id, issue_in, created_by=current_user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="issue",
|
||||
entity_id=issue.id,
|
||||
action="CREATE_ISSUE",
|
||||
detail=f"Issue {issue.id} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
data = IssueRead.model_validate(issue)
|
||||
data.is_overdue = _is_overdue(data)
|
||||
return data
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[IssueRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_issues(
|
||||
study_id: uuid.UUID,
|
||||
status_filter: str | None = None,
|
||||
level: str | None = None,
|
||||
category: str | None = None,
|
||||
overdue: bool | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[IssueRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
issues = await issue_crud.list_issues(db, study_id, status=status_filter, level=level, category=category, overdue=overdue)
|
||||
result: list[IssueRead] = []
|
||||
for item in issues:
|
||||
obj = IssueRead.model_validate(item)
|
||||
obj.is_overdue = _is_overdue(obj)
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{issue_id}",
|
||||
response_model=IssueRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_issue(
|
||||
study_id: uuid.UUID,
|
||||
issue_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> IssueRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
issue = await issue_crud.get_issue(db, issue_id)
|
||||
if not issue or issue.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
||||
data = IssueRead.model_validate(issue)
|
||||
data.is_overdue = _is_overdue(data)
|
||||
return data
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{issue_id}",
|
||||
response_model=IssueRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def update_issue(
|
||||
study_id: uuid.UUID,
|
||||
issue_id: uuid.UUID,
|
||||
issue_in: IssueUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> IssueRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
issue = await issue_crud.get_issue(db, issue_id)
|
||||
if not issue or issue.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
||||
|
||||
member_role = await _get_member_role(db, study_id, current_user.id)
|
||||
if current_user.role != "ADMIN" and member_role not in ALLOWED_EDIT_ROLES:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||
|
||||
old_status = issue.status
|
||||
old_level = issue.level
|
||||
updated = await issue_crud.update_issue(db, issue, issue_in)
|
||||
|
||||
detail = None
|
||||
action = "UPDATE_ISSUE"
|
||||
if issue_in.status and issue_in.status != old_status:
|
||||
detail = f"Issue {issue_id} status {old_status} -> {issue_in.status}"
|
||||
action = "ISSUE_STATUS_CHANGE"
|
||||
elif issue_in.level and issue_in.level != old_level:
|
||||
detail = f"Issue {issue_id} level {old_level} -> {issue_in.level}"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="issue",
|
||||
entity_id=issue_id,
|
||||
action=action,
|
||||
detail=detail or "Issue updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
data = IssueRead.model_validate(updated)
|
||||
data.is_overdue = _is_overdue(data)
|
||||
return data
|
||||
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import knowledge_note as note_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteRead, KnowledgeNoteUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notes",
|
||||
response_model=KnowledgeNoteRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_note(
|
||||
study_id: uuid.UUID,
|
||||
note_in: KnowledgeNoteCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> KnowledgeNoteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.create_note(db, study_id, note_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="knowledge_note",
|
||||
entity_id=note.id,
|
||||
action="CREATE_KNOWLEDGE_NOTE",
|
||||
detail=f"Knowledge note {note.title} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return KnowledgeNoteRead.model_validate(note)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notes",
|
||||
response_model=list[KnowledgeNoteRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_notes(
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
keyword: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[KnowledgeNoteRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await note_crud.list_notes(db, study_id, site_name=site_name, keyword=keyword, skip=skip, limit=limit)
|
||||
return [KnowledgeNoteRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notes/{note_id}",
|
||||
response_model=KnowledgeNoteRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_note(
|
||||
study_id: uuid.UUID,
|
||||
note_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> KnowledgeNoteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.get_note(db, note_id)
|
||||
if not note or note.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||
return KnowledgeNoteRead.model_validate(note)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/notes/{note_id}",
|
||||
response_model=KnowledgeNoteRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_note(
|
||||
study_id: uuid.UUID,
|
||||
note_id: uuid.UUID,
|
||||
note_in: KnowledgeNoteUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> KnowledgeNoteRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.get_note(db, note_id)
|
||||
if not note or note.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||
note = await note_crud.update_note(db, note, note_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="knowledge_note",
|
||||
entity_id=note_id,
|
||||
action="UPDATE_KNOWLEDGE_NOTE",
|
||||
detail=f"Knowledge note {note_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return KnowledgeNoteRead.model_validate(note)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notes/{note_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_note(
|
||||
study_id: uuid.UUID,
|
||||
note_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
note = await note_crud.get_note(db, note_id)
|
||||
if not note or note.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||
await note_crud.delete_note(db, note)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="knowledge_note",
|
||||
entity_id=note_id,
|
||||
action="DELETE_KNOWLEDGE_NOTE",
|
||||
detail=f"Knowledge note {note_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -1,208 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import attachment as attachment_crud
|
||||
from app.crud import milestone as milestone_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.crud import site as site_crud
|
||||
from app.schemas.milestone import MilestoneCreate, MilestoneRead, MilestoneUpdate
|
||||
from app.schemas.user import UserDisplay
|
||||
from app.schemas.milestone import SiteDisplay
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=MilestoneRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
||||
)
|
||||
async def create_milestone(
|
||||
study_id: uuid.UUID,
|
||||
milestone_in: MilestoneCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> MilestoneRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
if milestone_in.planned_date and milestone_in.planned_date < date.today():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划日期不得早于今天")
|
||||
milestone = await milestone_crud.create(db, study_id, milestone_in)
|
||||
owner = None
|
||||
site = None
|
||||
if milestone.owner_id:
|
||||
owner = await user_crud.get_by_id(db, milestone.owner_id)
|
||||
if milestone.site_id:
|
||||
site = await site_crud.get_site(db, milestone.site_id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="milestone",
|
||||
entity_id=milestone.id,
|
||||
action="CREATE_MILESTONE",
|
||||
detail=f"Milestone {milestone.id} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return MilestoneRead(
|
||||
id=milestone.id,
|
||||
study_id=milestone.study_id,
|
||||
type=milestone.type,
|
||||
name=milestone.name,
|
||||
planned_date=milestone.planned_date,
|
||||
actual_date=milestone.actual_date,
|
||||
status=milestone.status,
|
||||
owner_id=milestone.owner_id,
|
||||
site_id=milestone.site_id,
|
||||
owner=UserDisplay.model_validate(owner) if owner else None,
|
||||
site=SiteDisplay.model_validate(site) if site else None,
|
||||
notes=milestone.notes,
|
||||
created_at=milestone.created_at,
|
||||
updated_at=milestone.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[MilestoneRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_milestones(
|
||||
study_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[MilestoneRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
milestones = await milestone_crud.list_milestones(db, study_id)
|
||||
owner_ids = {m.owner_id for m in milestones if m.owner_id}
|
||||
site_ids = {m.site_id for m in milestones if m.site_id}
|
||||
users_map = await user_crud.get_users_by_ids(db, owner_ids)
|
||||
sites_map = await site_crud.get_sites_by_ids(db, site_ids)
|
||||
result: list[MilestoneRead] = []
|
||||
for m in milestones:
|
||||
owner = users_map.get(m.owner_id)
|
||||
site = sites_map.get(m.site_id)
|
||||
result.append(
|
||||
MilestoneRead(
|
||||
id=m.id,
|
||||
study_id=m.study_id,
|
||||
type=m.type,
|
||||
name=m.name,
|
||||
planned_date=m.planned_date,
|
||||
actual_date=m.actual_date,
|
||||
status=m.status,
|
||||
owner_id=m.owner_id,
|
||||
site_id=m.site_id,
|
||||
owner=UserDisplay.model_validate(owner) if owner else None,
|
||||
site=SiteDisplay.model_validate(site) if site else None,
|
||||
notes=m.notes,
|
||||
created_at=m.created_at,
|
||||
updated_at=m.updated_at,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{milestone_id}",
|
||||
response_model=MilestoneRead,
|
||||
dependencies=[Depends(require_study_roles(["PM"]))],
|
||||
)
|
||||
async def update_milestone(
|
||||
study_id: uuid.UUID,
|
||||
milestone_id: uuid.UUID,
|
||||
milestone_in: MilestoneUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> MilestoneRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
milestone = await milestone_crud.get(db, milestone_id)
|
||||
if not milestone or milestone.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
||||
if milestone_in.planned_date and milestone_in.planned_date < date.today():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="计划日期不得早于今天")
|
||||
if milestone_in.actual_date and milestone_in.actual_date < date.today():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="实际日期不得早于今天")
|
||||
if milestone_in.actual_date:
|
||||
attachments = await attachment_crud.list_attachments(db, study_id, "ethics_node", milestone_id)
|
||||
if not attachments:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先上传伦理批件后再更新实际日期")
|
||||
milestone_in.status = "DONE"
|
||||
old_status = milestone.status
|
||||
updated = await milestone_crud.update(db, milestone, milestone_in)
|
||||
owner = None
|
||||
site = None
|
||||
if updated.owner_id:
|
||||
owner = await user_crud.get_by_id(db, updated.owner_id)
|
||||
if updated.site_id:
|
||||
site = await site_crud.get_site(db, updated.site_id)
|
||||
detail = None
|
||||
if milestone_in.status and milestone_in.status != old_status:
|
||||
detail = f"milestone {milestone_id} status {old_status} -> {milestone_in.status}"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="milestone",
|
||||
entity_id=milestone_id,
|
||||
action="UPDATE_MILESTONE",
|
||||
detail=detail or "Milestone updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return MilestoneRead(
|
||||
id=updated.id,
|
||||
study_id=updated.study_id,
|
||||
type=updated.type,
|
||||
name=updated.name,
|
||||
planned_date=updated.planned_date,
|
||||
actual_date=updated.actual_date,
|
||||
status=updated.status,
|
||||
owner_id=updated.owner_id,
|
||||
site_id=updated.site_id,
|
||||
owner=UserDisplay.model_validate(owner) if owner else None,
|
||||
site=SiteDisplay.model_validate(site) if site else None,
|
||||
notes=updated.notes,
|
||||
created_at=updated.created_at,
|
||||
updated_at=updated.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{milestone_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def delete_milestone(
|
||||
study_id: uuid.UUID,
|
||||
milestone_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
milestone = await milestone_crud.get(db, milestone_id)
|
||||
if not milestone or milestone.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Milestone not found")
|
||||
await milestone_crud.delete_milestone(db, milestone)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="milestone",
|
||||
entity_id=milestone_id,
|
||||
action="DELETE_MILESTONE",
|
||||
detail=f"Milestone {milestone_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return None
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, comments, attachments, audit_logs, milestones, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard, faq_categories, faqs, constants
|
||||
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
@@ -9,24 +9,19 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
|
||||
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
|
||||
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
|
||||
api_router.include_router(comments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/comments", tags=["comments"])
|
||||
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
|
||||
api_router.include_router(attachments.global_router, prefix="/attachments", tags=["attachments"])
|
||||
api_router.include_router(audit_logs.router, prefix="/studies/{study_id}/audit-logs", tags=["audit-logs"])
|
||||
api_router.include_router(milestones.router, prefix="/studies/{study_id}/milestones", tags=["milestones"])
|
||||
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(subjects.router, prefix="/studies/{study_id}/subjects", tags=["subjects"])
|
||||
api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{subject_id}/visits", tags=["visits"])
|
||||
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
|
||||
api_router.include_router(issues.router, prefix="/studies/{study_id}/issues", tags=["issues"])
|
||||
api_router.include_router(data_queries.router, prefix="/studies/{study_id}/data-queries", tags=["data-queries"])
|
||||
api_router.include_router(verifications.router, prefix="/studies/{study_id}/verifications", tags=["verifications"])
|
||||
api_router.include_router(imp_products.router, prefix="/studies/{study_id}/imp/products", tags=["imp-products"])
|
||||
api_router.include_router(imp_batches.router, prefix="/studies/{study_id}/imp/batches", tags=["imp-batches"])
|
||||
api_router.include_router(imp_inventory.router, prefix="/studies/{study_id}/imp/inventory", tags=["imp-inventory"])
|
||||
api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"])
|
||||
api_router.include_router(finance.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
||||
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
|
||||
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
|
||||
api_router.include_router(finance_specials.router, prefix="/studies/{study_id}/finance", tags=["finance-specials"])
|
||||
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
|
||||
api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
|
||||
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
|
||||
api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"])
|
||||
api_router.include_router(faq_categories.router, prefix="/faqs/categories", tags=["faq-categories"])
|
||||
api_router.include_router(faqs.router, prefix="/faqs/items", tags=["faqs"])
|
||||
api_router.include_router(constants.router, prefix="/constants", tags=["faq"])
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import startup as startup_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.startup import (
|
||||
KickoffMeetingCreate,
|
||||
KickoffMeetingRead,
|
||||
KickoffMeetingUpdate,
|
||||
StartupEthicsCreate,
|
||||
StartupEthicsRead,
|
||||
StartupEthicsUpdate,
|
||||
StartupFeasibilityCreate,
|
||||
StartupFeasibilityRead,
|
||||
StartupFeasibilityUpdate,
|
||||
TrainingAuthorizationCreate,
|
||||
TrainingAuthorizationRead,
|
||||
TrainingAuthorizationUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/feasibility",
|
||||
response_model=StartupFeasibilityRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_feasibility(
|
||||
study_id: uuid.UUID,
|
||||
record_in: StartupFeasibilityCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StartupFeasibilityRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.create_feasibility(db, study_id, record_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_feasibility",
|
||||
entity_id=record.id,
|
||||
action="CREATE_STARTUP_FEASIBILITY",
|
||||
detail="Startup feasibility record created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return StartupFeasibilityRead.model_validate(record)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feasibility",
|
||||
response_model=list[StartupFeasibilityRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_feasibilities(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[StartupFeasibilityRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await startup_crud.list_feasibilities(db, study_id, skip=skip, limit=limit)
|
||||
return [StartupFeasibilityRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feasibility/{record_id}",
|
||||
response_model=StartupFeasibilityRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_feasibility(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> StartupFeasibilityRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_feasibility(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found")
|
||||
return StartupFeasibilityRead.model_validate(record)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/feasibility/{record_id}",
|
||||
response_model=StartupFeasibilityRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_feasibility(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
record_in: StartupFeasibilityUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StartupFeasibilityRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_feasibility(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found")
|
||||
record = await startup_crud.update_feasibility(db, record, record_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_feasibility",
|
||||
entity_id=record_id,
|
||||
action="UPDATE_STARTUP_FEASIBILITY",
|
||||
detail="Startup feasibility record updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return StartupFeasibilityRead.model_validate(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/feasibility/{record_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_feasibility(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_feasibility(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feasibility record not found")
|
||||
await startup_crud.delete_feasibility(db, record)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_feasibility",
|
||||
entity_id=record_id,
|
||||
action="DELETE_STARTUP_FEASIBILITY",
|
||||
detail="Startup feasibility record deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/ethics",
|
||||
response_model=StartupEthicsRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_ethics(
|
||||
study_id: uuid.UUID,
|
||||
record_in: StartupEthicsCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StartupEthicsRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.create_ethics(db, study_id, record_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_ethics",
|
||||
entity_id=record.id,
|
||||
action="CREATE_STARTUP_ETHICS",
|
||||
detail="Startup ethics record created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return StartupEthicsRead.model_validate(record)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ethics",
|
||||
response_model=list[StartupEthicsRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_ethics(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[StartupEthicsRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await startup_crud.list_ethics(db, study_id, skip=skip, limit=limit)
|
||||
return [StartupEthicsRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/ethics/{record_id}",
|
||||
response_model=StartupEthicsRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_ethics(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> StartupEthicsRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_ethics(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found")
|
||||
return StartupEthicsRead.model_validate(record)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/ethics/{record_id}",
|
||||
response_model=StartupEthicsRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_ethics(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
record_in: StartupEthicsUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> StartupEthicsRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_ethics(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found")
|
||||
record = await startup_crud.update_ethics(db, record, record_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_ethics",
|
||||
entity_id=record_id,
|
||||
action="UPDATE_STARTUP_ETHICS",
|
||||
detail="Startup ethics record updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return StartupEthicsRead.model_validate(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/ethics/{record_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_ethics(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_ethics(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ethics record not found")
|
||||
await startup_crud.delete_ethics(db, record)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_ethics",
|
||||
entity_id=record_id,
|
||||
action="DELETE_STARTUP_ETHICS",
|
||||
detail="Startup ethics record deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/kickoff",
|
||||
response_model=KickoffMeetingRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_kickoff(
|
||||
study_id: uuid.UUID,
|
||||
meeting_in: KickoffMeetingCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> KickoffMeetingRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
meeting = await startup_crud.create_kickoff(db, study_id, meeting_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_kickoff",
|
||||
entity_id=meeting.id,
|
||||
action="CREATE_KICKOFF_MEETING",
|
||||
detail="Kickoff meeting created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return KickoffMeetingRead.model_validate(meeting)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kickoff",
|
||||
response_model=list[KickoffMeetingRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_kickoffs(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[KickoffMeetingRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await startup_crud.list_kickoffs(db, study_id, skip=skip, limit=limit)
|
||||
return [KickoffMeetingRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kickoff/{meeting_id}",
|
||||
response_model=KickoffMeetingRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_kickoff(
|
||||
study_id: uuid.UUID,
|
||||
meeting_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> KickoffMeetingRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||
if not meeting or meeting.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found")
|
||||
return KickoffMeetingRead.model_validate(meeting)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/kickoff/{meeting_id}",
|
||||
response_model=KickoffMeetingRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_kickoff(
|
||||
study_id: uuid.UUID,
|
||||
meeting_id: uuid.UUID,
|
||||
meeting_in: KickoffMeetingUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> KickoffMeetingRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||
if not meeting or meeting.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found")
|
||||
meeting = await startup_crud.update_kickoff(db, meeting, meeting_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_kickoff",
|
||||
entity_id=meeting_id,
|
||||
action="UPDATE_KICKOFF_MEETING",
|
||||
detail="Kickoff meeting updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return KickoffMeetingRead.model_validate(meeting)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/kickoff/{meeting_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_kickoff(
|
||||
study_id: uuid.UUID,
|
||||
meeting_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
meeting = await startup_crud.get_kickoff(db, meeting_id)
|
||||
if not meeting or meeting.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kickoff meeting not found")
|
||||
await startup_crud.delete_kickoff(db, meeting)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="startup_kickoff",
|
||||
entity_id=meeting_id,
|
||||
action="DELETE_KICKOFF_MEETING",
|
||||
detail="Kickoff meeting deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/training-authorizations",
|
||||
response_model=TrainingAuthorizationRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_training_authorization(
|
||||
study_id: uuid.UUID,
|
||||
record_in: TrainingAuthorizationCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> TrainingAuthorizationRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.create_training_authorization(db, study_id, record_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="training_authorization",
|
||||
entity_id=record.id,
|
||||
action="CREATE_TRAINING_AUTH",
|
||||
detail="Training authorization created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return TrainingAuthorizationRead.model_validate(record)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/training-authorizations",
|
||||
response_model=list[TrainingAuthorizationRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_training_authorizations(
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[TrainingAuthorizationRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit)
|
||||
return [TrainingAuthorizationRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/training-authorizations/{record_id}",
|
||||
response_model=TrainingAuthorizationRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_training_authorization(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TrainingAuthorizationRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_training_authorization(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found")
|
||||
return TrainingAuthorizationRead.model_validate(record)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/training-authorizations/{record_id}",
|
||||
response_model=TrainingAuthorizationRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_training_authorization(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
record_in: TrainingAuthorizationUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> TrainingAuthorizationRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_training_authorization(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found")
|
||||
record = await startup_crud.update_training_authorization(db, record, record_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="training_authorization",
|
||||
entity_id=record_id,
|
||||
action="UPDATE_TRAINING_AUTH",
|
||||
detail="Training authorization updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return TrainingAuthorizationRead.model_validate(record)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/training-authorizations/{record_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_training_authorization(
|
||||
study_id: uuid.UUID,
|
||||
record_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
record = await startup_crud.get_training_authorization(db, record_id)
|
||||
if not record or record.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Training authorization not found")
|
||||
await startup_crud.delete_training_authorization(db, record)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="training_authorization",
|
||||
entity_id=record_id,
|
||||
action="DELETE_TRAINING_AUTH",
|
||||
detail="Training authorization deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import subject_history as history_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryRead, SubjectHistoryUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.post(
|
||||
"/histories",
|
||||
response_model=SubjectHistoryRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_history(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
history_in: SubjectHistoryCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> SubjectHistoryRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
if history_in.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Subject mismatch")
|
||||
history = await history_crud.create_history(db, study_id, history_in, created_by=current_user.id)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="subject_history",
|
||||
entity_id=history.id,
|
||||
action="CREATE_SUBJECT_HISTORY",
|
||||
detail="Subject history created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return SubjectHistoryRead.model_validate(history)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/histories",
|
||||
response_model=list[SubjectHistoryRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_histories(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[SubjectHistoryRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await history_crud.list_histories(db, study_id, subject_id, skip=skip, limit=limit)
|
||||
return [SubjectHistoryRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/histories/{history_id}",
|
||||
response_model=SubjectHistoryRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_history(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
history_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubjectHistoryRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
history = await history_crud.get_history(db, history_id)
|
||||
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found")
|
||||
return SubjectHistoryRead.model_validate(history)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/histories/{history_id}",
|
||||
response_model=SubjectHistoryRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_history(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
history_id: uuid.UUID,
|
||||
history_in: SubjectHistoryUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> SubjectHistoryRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
history = await history_crud.get_history(db, history_id)
|
||||
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found")
|
||||
history = await history_crud.update_history(db, history, history_in)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="subject_history",
|
||||
entity_id=history_id,
|
||||
action="UPDATE_SUBJECT_HISTORY",
|
||||
detail="Subject history updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return SubjectHistoryRead.model_validate(history)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/histories/{history_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_history(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
history_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
history = await history_crud.get_history(db, history_id)
|
||||
if not history or history.study_id != study_id or history.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="History not found")
|
||||
await history_crud.delete_history(db, history)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="subject_history",
|
||||
entity_id=history_id,
|
||||
action="DELETE_SUBJECT_HISTORY",
|
||||
detail="Subject history deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
@@ -58,13 +58,33 @@ async def list_subjects(
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
status_filter: str | None = None,
|
||||
subject_no: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[SubjectRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
subjects = await subject_crud.list_subjects(db, study_id, site_id=site_id, status=status_filter)
|
||||
subjects = await subject_crud.list_subjects(
|
||||
db, study_id, site_id=site_id, status=status_filter, subject_no=subject_no
|
||||
)
|
||||
return list(subjects)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{subject_id}",
|
||||
response_model=SubjectRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_subject(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> SubjectRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
subject = await subject_crud.get_subject(db, subject_id)
|
||||
if not subject or subject.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
|
||||
return subject
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{subject_id}",
|
||||
response_model=SubjectRead,
|
||||
@@ -100,3 +120,31 @@ async def update_subject(
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{subject_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_subject(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
subject = await subject_crud.get_subject(db, subject_id)
|
||||
if not subject or subject.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Subject not found")
|
||||
await subject_crud.delete_subject(db, subject)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="subject",
|
||||
entity_id=subject_id,
|
||||
action="DELETE_SUBJECT",
|
||||
detail=f"Subject {subject_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import verification as verification_crud
|
||||
from app.crud import study as study_crud
|
||||
from app.schemas.verification import VerificationCreate, VerificationRead, VerificationUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
|
||||
study = await study_crud.get(db, study_id)
|
||||
if not study:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
|
||||
return study
|
||||
|
||||
|
||||
@router.put(
|
||||
"/",
|
||||
response_model=VerificationRead,
|
||||
status_code=status.HTTP_200_OK,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def upsert_verification(
|
||||
study_id: uuid.UUID,
|
||||
payload: VerificationCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> VerificationRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
try:
|
||||
vp = await verification_crud.upsert_progress(db, study_id, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="verification",
|
||||
entity_id=vp.id,
|
||||
action="UPSERT_VERIFICATION",
|
||||
detail=f"{payload.level} progress subject {payload.subject_id} upserted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return VerificationRead.model_validate(vp)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[VerificationRead],
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def list_verifications(
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
level: str | None = None,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> list[VerificationRead]:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
items = await verification_crud.list_progress(db, study_id, site_id=site_id, subject_id=subject_id, level=level)
|
||||
return [VerificationRead.model_validate(i) for i in items]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/subjects/{subject_id}/{level}",
|
||||
response_model=VerificationRead,
|
||||
dependencies=[Depends(require_study_member())],
|
||||
)
|
||||
async def get_verification(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
level: str,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> VerificationRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
vp = await verification_crud.get_progress(db, study_id, subject_id, level)
|
||||
if not vp:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Verification not found")
|
||||
return VerificationRead.model_validate(vp)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{verification_id}",
|
||||
response_model=VerificationRead,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def update_verification(
|
||||
study_id: uuid.UUID,
|
||||
verification_id: uuid.UUID,
|
||||
payload: VerificationUpdate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> VerificationRead:
|
||||
await _ensure_study_exists(db, study_id)
|
||||
vp = await verification_crud.get_by_id(db, verification_id)
|
||||
if not vp or vp.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Verification not found")
|
||||
|
||||
updated = await verification_crud.update_progress(db, vp, payload)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="verification",
|
||||
entity_id=verification_id,
|
||||
action="UPSERT_VERIFICATION",
|
||||
detail=f"{updated.level} progress subject {updated.subject_id} updated",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return VerificationRead.model_validate(updated)
|
||||
@@ -7,7 +7,7 @@ from app.core.deps import get_current_user, get_db_session, require_study_member
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import subject as subject_crud
|
||||
from app.crud import visit as visit_crud
|
||||
from app.schemas.visit import VisitRead, VisitUpdate
|
||||
from app.schemas.visit import VisitCreate, VisitRead, VisitUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -34,6 +34,44 @@ async def list_visits(
|
||||
return list(visits)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=VisitRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def create_visit(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
visit_in: VisitCreate,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> VisitRead:
|
||||
subject = await _ensure_subject(db, study_id, subject_id)
|
||||
if visit_in.subject_id != subject_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Subject mismatch")
|
||||
visit = await visit_crud.create_visit(
|
||||
db,
|
||||
study_id=study_id,
|
||||
visit_in=visit_in,
|
||||
subject=subject,
|
||||
visit_code=visit_in.visit_code,
|
||||
visit_name=visit_in.visit_name,
|
||||
planned_date=visit_in.planned_date,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="visit",
|
||||
entity_id=visit.id,
|
||||
action="CREATE_VISIT",
|
||||
detail=f"Visit {visit.visit_code} created",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return visit
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{visit_id}",
|
||||
response_model=VisitRead,
|
||||
@@ -68,3 +106,32 @@ async def update_visit(
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{visit_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
|
||||
)
|
||||
async def delete_visit(
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
visit_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
await _ensure_subject(db, study_id, subject_id)
|
||||
visit = await visit_crud.get_visit(db, visit_id)
|
||||
if not visit or visit.subject_id != subject_id or visit.study_id != study_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Visit not found")
|
||||
await visit_crud.delete_visit(db, visit)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="visit",
|
||||
entity_id=visit_id,
|
||||
action="DELETE_VISIT",
|
||||
detail=f"Visit {visit_id} deleted",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"]
|
||||
AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"]
|
||||
AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"]
|
||||
@@ -1,2 +0,0 @@
|
||||
FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"]
|
||||
FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"]
|
||||
@@ -1,10 +0,0 @@
|
||||
IMP_TX_TYPES = [
|
||||
"RECEIPT",
|
||||
"DISPENSE",
|
||||
"RETURN",
|
||||
"RECONCILE",
|
||||
"DESTROY",
|
||||
"TRANSFER_IN",
|
||||
"TRANSFER_OUT",
|
||||
]
|
||||
IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"]
|
||||
@@ -1,3 +0,0 @@
|
||||
ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"]
|
||||
ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
|
||||
ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"]
|
||||
@@ -1,2 +0,0 @@
|
||||
SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"]
|
||||
VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"]
|
||||
@@ -113,3 +113,8 @@ async def update_ae(db: AsyncSession, study_id: uuid.UUID, ae: AdverseEvent, ae_
|
||||
await db.commit()
|
||||
await db.refresh(ae)
|
||||
return ae
|
||||
|
||||
|
||||
async def delete_ae(db: AsyncSession, ae: AdverseEvent) -> None:
|
||||
await db.delete(ae)
|
||||
await db.commit()
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.comment import Comment
|
||||
from app.schemas.comment import CommentCreate
|
||||
|
||||
|
||||
async def create_comment(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
comment_in: CommentCreate,
|
||||
created_by: uuid.UUID,
|
||||
) -> Comment:
|
||||
comment = Comment(
|
||||
study_id=study_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
content=comment_in.content,
|
||||
quote_comment_id=comment_in.quote_comment_id,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
return comment
|
||||
|
||||
|
||||
async def list_comments(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
) -> Sequence[Comment]:
|
||||
result = await db.execute(
|
||||
select(Comment)
|
||||
.where(
|
||||
Comment.study_id == study_id,
|
||||
Comment.entity_type == entity_type,
|
||||
Comment.entity_id == entity_id,
|
||||
Comment.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Comment.created_at.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_comment(db: AsyncSession, comment_id: uuid.UUID, *, include_deleted: bool = False) -> Comment | None:
|
||||
stmt = select(Comment).where(Comment.id == comment_id)
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(Comment.is_deleted.is_(False))
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_comments_by_ids(
|
||||
db: AsyncSession,
|
||||
ids: set[uuid.UUID],
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
) -> dict[uuid.UUID, Comment]:
|
||||
if not ids:
|
||||
return {}
|
||||
stmt = select(Comment).where(Comment.id.in_(ids))
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(Comment.is_deleted.is_(False))
|
||||
result = await db.execute(stmt)
|
||||
comments = result.scalars().all()
|
||||
return {c.id: c for c in comments}
|
||||
|
||||
|
||||
async def soft_delete_comment(db: AsyncSession, comment: Comment) -> Comment:
|
||||
comment.is_deleted = True
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
return comment
|
||||
@@ -1,117 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.data_query import DataQuery
|
||||
from app.models.site import Site
|
||||
from app.models.subject import Subject
|
||||
from app.schemas.data_query import DataQueryCreate, DataQueryUpdate
|
||||
|
||||
|
||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
|
||||
if site_id:
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site or site.study_id != study_id:
|
||||
raise ValueError("Site not found in study")
|
||||
subject_site = None
|
||||
if subject_id:
|
||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||
subj = result.scalar_one_or_none()
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise ValueError("Subject not found in study")
|
||||
subject_site = subj.site_id
|
||||
if site_id and subject_site and site_id != subject_site:
|
||||
raise ValueError("Site and subject mismatch")
|
||||
|
||||
|
||||
async def create_query(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
query_in: DataQueryCreate,
|
||||
*,
|
||||
created_by: uuid.UUID,
|
||||
) -> DataQuery:
|
||||
await _validate_site_subject(db, study_id, query_in.site_id, query_in.subject_id)
|
||||
dq = DataQuery(
|
||||
study_id=study_id,
|
||||
site_id=query_in.site_id,
|
||||
subject_id=query_in.subject_id,
|
||||
visit_id=query_in.visit_id,
|
||||
title=query_in.title,
|
||||
description=query_in.description,
|
||||
category=query_in.category,
|
||||
priority=query_in.priority,
|
||||
assigned_to=query_in.assigned_to,
|
||||
due_date=query_in.due_date,
|
||||
status="OPEN",
|
||||
resolution=None,
|
||||
closed_at=None,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(dq)
|
||||
await db.commit()
|
||||
await db.refresh(dq)
|
||||
return dq
|
||||
|
||||
|
||||
async def get_query(db: AsyncSession, query_id: uuid.UUID) -> DataQuery | None:
|
||||
result = await db.execute(select(DataQuery).where(DataQuery.id == query_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_queries(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
*,
|
||||
status: str | None = None,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
assigned_to: uuid.UUID | None = None,
|
||||
overdue: bool | None = None,
|
||||
category: str | None = None,
|
||||
priority: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[DataQuery]:
|
||||
stmt = select(DataQuery).where(DataQuery.study_id == study_id)
|
||||
if status:
|
||||
stmt = stmt.where(DataQuery.status == status)
|
||||
if site_id:
|
||||
stmt = stmt.where(DataQuery.site_id == site_id)
|
||||
if subject_id:
|
||||
stmt = stmt.where(DataQuery.subject_id == subject_id)
|
||||
if assigned_to:
|
||||
stmt = stmt.where(DataQuery.assigned_to == assigned_to)
|
||||
if category:
|
||||
stmt = stmt.where(DataQuery.category == category)
|
||||
if priority:
|
||||
stmt = stmt.where(DataQuery.priority == priority)
|
||||
if overdue is True:
|
||||
stmt = stmt.where(DataQuery.due_date < date.today(), DataQuery.status != "CLOSED")
|
||||
if overdue is False:
|
||||
stmt = stmt.where((DataQuery.due_date >= date.today()) | (DataQuery.due_date.is_(None)) | (DataQuery.status == "CLOSED"))
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_query(db: AsyncSession, dq: DataQuery, dq_in: DataQueryUpdate) -> DataQuery:
|
||||
update_data = dq_in.model_dump(exclude_unset=True)
|
||||
if "status" in update_data:
|
||||
if update_data["status"] == "CLOSED":
|
||||
update_data["closed_at"] = datetime.now(timezone.utc)
|
||||
else:
|
||||
update_data["closed_at"] = None
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(DataQuery)
|
||||
.where(DataQuery.id == dq.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(dq)
|
||||
return dq
|
||||
@@ -0,0 +1,76 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.drug_shipment import DrugShipment
|
||||
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentUpdate
|
||||
|
||||
|
||||
async def create_shipment(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
shipment_in: DrugShipmentCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> DrugShipment:
|
||||
shipment = DrugShipment(
|
||||
study_id=study_id,
|
||||
direction=shipment_in.direction,
|
||||
site_name=shipment_in.site_name,
|
||||
drug_desc=shipment_in.drug_desc,
|
||||
ship_date=shipment_in.ship_date,
|
||||
carrier=shipment_in.carrier,
|
||||
tracking_no=shipment_in.tracking_no,
|
||||
from_party=shipment_in.from_party,
|
||||
to_party=shipment_in.to_party,
|
||||
status=shipment_in.status,
|
||||
remark=shipment_in.remark,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(shipment)
|
||||
await db.commit()
|
||||
await db.refresh(shipment)
|
||||
return shipment
|
||||
|
||||
|
||||
async def get_shipment(db: AsyncSession, shipment_id: uuid.UUID) -> DrugShipment | None:
|
||||
result = await db.execute(select(DrugShipment).where(DrugShipment.id == shipment_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_shipments(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
tracking_no: str | None = None,
|
||||
status: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[DrugShipment]:
|
||||
stmt = select(DrugShipment).where(DrugShipment.study_id == study_id)
|
||||
if site_name:
|
||||
stmt = stmt.where(DrugShipment.site_name.ilike(f"%{site_name}%"))
|
||||
if tracking_no:
|
||||
stmt = stmt.where(DrugShipment.tracking_no.ilike(f"%{tracking_no}%"))
|
||||
if status:
|
||||
stmt = stmt.where(DrugShipment.status == status)
|
||||
stmt = stmt.order_by(DrugShipment.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_shipment(
|
||||
db: AsyncSession, shipment: DrugShipment, shipment_in: DrugShipmentUpdate
|
||||
) -> DrugShipment:
|
||||
update_data = shipment_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(shipment, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(shipment)
|
||||
return shipment
|
||||
|
||||
|
||||
async def delete_shipment(db: AsyncSession, shipment: DrugShipment) -> None:
|
||||
await db.delete(shipment)
|
||||
await db.commit()
|
||||
+2
-142
@@ -1,150 +1,10 @@
|
||||
import uuid
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.finance_contract import FinanceContract
|
||||
from app.schemas.finance_contract import FinanceContractCreate, FinanceContractUpdate
|
||||
|
||||
|
||||
async def create_contract(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
contract_in: FinanceContractCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> FinanceContract:
|
||||
contract = FinanceContract(
|
||||
study_id=study_id,
|
||||
site_name=contract_in.site_name,
|
||||
contract_no=contract_in.contract_no,
|
||||
signed_date=contract_in.signed_date,
|
||||
amount=contract_in.amount,
|
||||
currency=contract_in.currency,
|
||||
remark=contract_in.remark,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(contract)
|
||||
await db.commit()
|
||||
await db.refresh(contract)
|
||||
return contract
|
||||
|
||||
|
||||
async def get_contract(db: AsyncSession, contract_id: uuid.UUID) -> FinanceContract | None:
|
||||
result = await db.execute(select(FinanceContract).where(FinanceContract.id == contract_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_contracts(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
contract_no: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[FinanceContract]:
|
||||
stmt = select(FinanceContract).where(FinanceContract.study_id == study_id)
|
||||
if site_name:
|
||||
stmt = stmt.where(FinanceContract.site_name.ilike(f"%{site_name}%"))
|
||||
if contract_no:
|
||||
stmt = stmt.where(FinanceContract.contract_no.ilike(f"%{contract_no}%"))
|
||||
stmt = stmt.order_by(FinanceContract.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_contract(
|
||||
db: AsyncSession, contract: FinanceContract, contract_in: FinanceContractUpdate
|
||||
) -> FinanceContract:
|
||||
update_data = contract_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(contract, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(contract)
|
||||
return contract
|
||||
|
||||
|
||||
async def delete_contract(db: AsyncSession, contract: FinanceContract) -> None:
|
||||
await db.delete(contract)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,69 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.finance_special import FinanceSpecial
|
||||
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialUpdate
|
||||
|
||||
|
||||
async def create_special(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
special_in: FinanceSpecialCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> FinanceSpecial:
|
||||
special = FinanceSpecial(
|
||||
study_id=study_id,
|
||||
site_name=special_in.site_name,
|
||||
fee_type=special_in.fee_type,
|
||||
amount=special_in.amount,
|
||||
occur_date=special_in.occur_date,
|
||||
staff_name=special_in.staff_name,
|
||||
remark=special_in.remark,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(special)
|
||||
await db.commit()
|
||||
await db.refresh(special)
|
||||
return special
|
||||
|
||||
|
||||
async def get_special(db: AsyncSession, special_id: uuid.UUID) -> FinanceSpecial | None:
|
||||
result = await db.execute(select(FinanceSpecial).where(FinanceSpecial.id == special_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_specials(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
fee_type: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[FinanceSpecial]:
|
||||
stmt = select(FinanceSpecial).where(FinanceSpecial.study_id == study_id)
|
||||
if site_name:
|
||||
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
|
||||
if fee_type:
|
||||
stmt = stmt.where(FinanceSpecial.fee_type == fee_type)
|
||||
stmt = stmt.order_by(FinanceSpecial.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_special(
|
||||
db: AsyncSession, special: FinanceSpecial, special_in: FinanceSpecialUpdate
|
||||
) -> FinanceSpecial:
|
||||
update_data = special_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(special, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(special)
|
||||
return special
|
||||
|
||||
|
||||
async def delete_special(db: AsyncSession, special: FinanceSpecial) -> None:
|
||||
await db.delete(special)
|
||||
await db.commit()
|
||||
@@ -1,57 +0,0 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imp_batch import ImpBatch
|
||||
from app.models.imp_product import ImpProduct
|
||||
from app.schemas.imp import BatchCreate, BatchUpdate
|
||||
|
||||
|
||||
async def create_batch(db: AsyncSession, study_id: uuid.UUID, batch_in: BatchCreate) -> ImpBatch:
|
||||
result = await db.execute(select(ImpProduct).where(ImpProduct.id == batch_in.product_id))
|
||||
product = result.scalar_one_or_none()
|
||||
if not product or product.study_id != study_id:
|
||||
raise ValueError("Product not found in study")
|
||||
|
||||
batch = ImpBatch(
|
||||
study_id=study_id,
|
||||
product_id=batch_in.product_id,
|
||||
batch_no=batch_in.batch_no,
|
||||
expiry_date=batch_in.expiry_date,
|
||||
manufacture_date=batch_in.manufacture_date,
|
||||
status="ACTIVE",
|
||||
)
|
||||
db.add(batch)
|
||||
await db.commit()
|
||||
await db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
async def get_batch(db: AsyncSession, batch_id: uuid.UUID) -> ImpBatch | None:
|
||||
result = await db.execute(select(ImpBatch).where(ImpBatch.id == batch_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_batches(db: AsyncSession, study_id: uuid.UUID, product_id: uuid.UUID | None = None, status: str | None = None) -> Sequence[ImpBatch]:
|
||||
stmt = select(ImpBatch).where(ImpBatch.study_id == study_id)
|
||||
if product_id:
|
||||
stmt = stmt.where(ImpBatch.product_id == product_id)
|
||||
if status:
|
||||
stmt = stmt.where(ImpBatch.status == status)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_batch(db: AsyncSession, batch: ImpBatch, batch_in: BatchUpdate) -> ImpBatch:
|
||||
update_data = batch_in.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(ImpBatch)
|
||||
.where(ImpBatch.id == batch.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(batch)
|
||||
return batch
|
||||
@@ -1,46 +0,0 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imp_inventory import ImpInventory
|
||||
from app.models.imp_batch import ImpBatch
|
||||
|
||||
|
||||
async def get_inventory_for_update(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory | None:
|
||||
result = await db.execute(
|
||||
select(ImpInventory)
|
||||
.where(
|
||||
ImpInventory.study_id == study_id,
|
||||
ImpInventory.site_id == site_id,
|
||||
ImpInventory.batch_id == batch_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_or_create_inventory(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID) -> ImpInventory:
|
||||
inv = await get_inventory_for_update(db, study_id, site_id, batch_id)
|
||||
if inv:
|
||||
return inv
|
||||
inv = ImpInventory(study_id=study_id, site_id=site_id, batch_id=batch_id, quantity_on_hand=0)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
return inv
|
||||
|
||||
|
||||
async def list_inventory(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
product_id: uuid.UUID | None = None,
|
||||
) -> Sequence[ImpInventory]:
|
||||
stmt = select(ImpInventory).where(ImpInventory.study_id == study_id)
|
||||
if site_id:
|
||||
stmt = stmt.where(ImpInventory.site_id == site_id)
|
||||
if product_id:
|
||||
stmt = stmt.join(ImpBatch, ImpBatch.id == ImpInventory.batch_id).where(ImpBatch.product_id == product_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -1,50 +0,0 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imp_product import ImpProduct
|
||||
from app.schemas.imp import ProductCreate, ProductUpdate
|
||||
|
||||
|
||||
async def create_product(db: AsyncSession, study_id: uuid.UUID, product_in: ProductCreate) -> ImpProduct:
|
||||
product = ImpProduct(
|
||||
study_id=study_id,
|
||||
name=product_in.name,
|
||||
form=product_in.form,
|
||||
strength=product_in.strength,
|
||||
unit=product_in.unit,
|
||||
description=product_in.description,
|
||||
is_active=product_in.is_active,
|
||||
)
|
||||
db.add(product)
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
return product
|
||||
|
||||
|
||||
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> ImpProduct | None:
|
||||
result = await db.execute(select(ImpProduct).where(ImpProduct.id == product_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_products(db: AsyncSession, study_id: uuid.UUID, is_active: bool | None = None) -> Sequence[ImpProduct]:
|
||||
stmt = select(ImpProduct).where(ImpProduct.study_id == study_id)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(ImpProduct.is_active == is_active)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_product(db: AsyncSession, product: ImpProduct, product_in: ProductUpdate) -> ImpProduct:
|
||||
update_data = product_in.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(ImpProduct)
|
||||
.where(ImpProduct.id == product.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
return product
|
||||
@@ -1,70 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.imp_transaction import ImpTransaction
|
||||
|
||||
|
||||
async def create_tx(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
batch_id: uuid.UUID,
|
||||
subject_id: uuid.UUID | None,
|
||||
tx_type: str,
|
||||
quantity: int,
|
||||
tx_date: date,
|
||||
reference: str | None,
|
||||
notes: str | None,
|
||||
created_by: uuid.UUID,
|
||||
) -> ImpTransaction:
|
||||
tx = ImpTransaction(
|
||||
study_id=study_id,
|
||||
site_id=site_id,
|
||||
batch_id=batch_id,
|
||||
subject_id=subject_id,
|
||||
tx_type=tx_type,
|
||||
quantity=quantity,
|
||||
tx_date=tx_date,
|
||||
reference=reference,
|
||||
notes=notes,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(tx)
|
||||
await db.flush()
|
||||
return tx
|
||||
|
||||
|
||||
async def list_txs(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
*,
|
||||
site_id: uuid.UUID | None = None,
|
||||
batch_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
tx_type: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[ImpTransaction]:
|
||||
stmt = select(ImpTransaction).where(ImpTransaction.study_id == study_id)
|
||||
if site_id:
|
||||
stmt = stmt.where(ImpTransaction.site_id == site_id)
|
||||
if batch_id:
|
||||
stmt = stmt.where(ImpTransaction.batch_id == batch_id)
|
||||
if subject_id:
|
||||
stmt = stmt.where(ImpTransaction.subject_id == subject_id)
|
||||
if tx_type:
|
||||
stmt = stmt.where(ImpTransaction.tx_type == tx_type)
|
||||
if date_from:
|
||||
stmt = stmt.where(ImpTransaction.tx_date >= date_from)
|
||||
if date_to:
|
||||
stmt = stmt.where(ImpTransaction.tx_date <= date_to)
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -1,99 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.issue import Issue
|
||||
from app.models.site import Site
|
||||
from app.models.subject import Subject
|
||||
from app.schemas.issue import IssueCreate, IssueUpdate
|
||||
|
||||
|
||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
|
||||
if site_id:
|
||||
result = await db.execute(select(Site).where(Site.id == site_id))
|
||||
site = result.scalar_one_or_none()
|
||||
if not site or site.study_id != study_id:
|
||||
raise ValueError("Site not found in study")
|
||||
if subject_id:
|
||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||
subj = result.scalar_one_or_none()
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise ValueError("Subject not found in study")
|
||||
|
||||
|
||||
async def create_issue(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
issue_in: IssueCreate,
|
||||
*,
|
||||
created_by: uuid.UUID,
|
||||
) -> Issue:
|
||||
await _validate_site_subject(db, study_id, issue_in.site_id, issue_in.subject_id)
|
||||
issue = Issue(
|
||||
study_id=study_id,
|
||||
site_id=issue_in.site_id,
|
||||
subject_id=issue_in.subject_id,
|
||||
title=issue_in.title,
|
||||
description=issue_in.description,
|
||||
category=issue_in.category,
|
||||
level=issue_in.level,
|
||||
owner_id=issue_in.owner_id,
|
||||
due_date=issue_in.due_date,
|
||||
status="OPEN",
|
||||
capa=None,
|
||||
closed_at=None,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(issue)
|
||||
await db.commit()
|
||||
await db.refresh(issue)
|
||||
return issue
|
||||
|
||||
|
||||
async def get_issue(db: AsyncSession, issue_id: uuid.UUID) -> Issue | None:
|
||||
result = await db.execute(select(Issue).where(Issue.id == issue_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_issues(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
status: str | None = None,
|
||||
level: str | None = None,
|
||||
category: str | None = None,
|
||||
overdue: bool | None = None,
|
||||
) -> Sequence[Issue]:
|
||||
stmt = select(Issue).where(Issue.study_id == study_id)
|
||||
if status:
|
||||
stmt = stmt.where(Issue.status == status)
|
||||
if level:
|
||||
stmt = stmt.where(Issue.level == level)
|
||||
if category:
|
||||
stmt = stmt.where(Issue.category == category)
|
||||
if overdue is True:
|
||||
stmt = stmt.where(Issue.due_date < date.today(), Issue.status != "CLOSED")
|
||||
if overdue is False:
|
||||
stmt = stmt.where((Issue.due_date >= date.today()) | (Issue.due_date.is_(None)) | (Issue.status == "CLOSED"))
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_issue(db: AsyncSession, issue: Issue, issue_in: IssueUpdate) -> Issue:
|
||||
update_data = issue_in.model_dump(exclude_unset=True)
|
||||
if "status" in update_data:
|
||||
if update_data["status"] == "CLOSED":
|
||||
update_data["closed_at"] = datetime.now(timezone.utc)
|
||||
else:
|
||||
update_data["closed_at"] = None
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(Issue)
|
||||
.where(Issue.id == issue.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(issue)
|
||||
return issue
|
||||
@@ -0,0 +1,67 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.knowledge_note import KnowledgeNote
|
||||
from app.schemas.knowledge_note import KnowledgeNoteCreate, KnowledgeNoteUpdate
|
||||
|
||||
|
||||
async def create_note(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
note_in: KnowledgeNoteCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> KnowledgeNote:
|
||||
note = KnowledgeNote(
|
||||
study_id=study_id,
|
||||
site_name=note_in.site_name,
|
||||
title=note_in.title,
|
||||
content=note_in.content,
|
||||
level=note_in.level,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def get_note(db: AsyncSession, note_id: uuid.UUID) -> KnowledgeNote | None:
|
||||
result = await db.execute(select(KnowledgeNote).where(KnowledgeNote.id == note_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_notes(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_name: str | None = None,
|
||||
keyword: str | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[KnowledgeNote]:
|
||||
stmt = select(KnowledgeNote).where(KnowledgeNote.study_id == study_id)
|
||||
if site_name:
|
||||
stmt = stmt.where(KnowledgeNote.site_name.ilike(f"%{site_name}%"))
|
||||
if keyword:
|
||||
stmt = stmt.where(KnowledgeNote.title.ilike(f"%{keyword}%"))
|
||||
stmt = stmt.order_by(KnowledgeNote.updated_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_note(
|
||||
db: AsyncSession, note: KnowledgeNote, note_in: KnowledgeNoteUpdate
|
||||
) -> KnowledgeNote:
|
||||
update_data = note_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(note, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def delete_note(db: AsyncSession, note: KnowledgeNote) -> None:
|
||||
await db.delete(note)
|
||||
await db.commit()
|
||||
@@ -1,54 +0,0 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import delete, select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.milestone import Milestone
|
||||
from app.schemas.milestone import MilestoneCreate, MilestoneUpdate
|
||||
|
||||
|
||||
async def create(db: AsyncSession, study_id: uuid.UUID, milestone_in: MilestoneCreate) -> Milestone:
|
||||
milestone = Milestone(
|
||||
study_id=study_id,
|
||||
type=milestone_in.type,
|
||||
name=milestone_in.name or milestone_in.type,
|
||||
planned_date=milestone_in.planned_date,
|
||||
actual_date=None,
|
||||
status=milestone_in.status or "NOT_STARTED",
|
||||
owner_id=milestone_in.owner_id,
|
||||
site_id=milestone_in.site_id,
|
||||
notes=milestone_in.notes,
|
||||
)
|
||||
db.add(milestone)
|
||||
await db.commit()
|
||||
await db.refresh(milestone)
|
||||
return milestone
|
||||
|
||||
|
||||
async def get(db: AsyncSession, milestone_id: uuid.UUID) -> Milestone | None:
|
||||
result = await db.execute(select(Milestone).where(Milestone.id == milestone_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]:
|
||||
result = await db.execute(select(Milestone).where(Milestone.study_id == study_id).order_by(Milestone.planned_date))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update(db: AsyncSession, milestone: Milestone, milestone_in: MilestoneUpdate) -> Milestone:
|
||||
update_data = milestone_in.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(Milestone)
|
||||
.where(Milestone.id == milestone.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(milestone)
|
||||
return milestone
|
||||
|
||||
|
||||
async def delete_milestone(db: AsyncSession, milestone: Milestone) -> None:
|
||||
await db.execute(delete(Milestone).where(Milestone.id == milestone.id))
|
||||
await db.commit()
|
||||
@@ -0,0 +1,257 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.startup_feasibility import StartupFeasibility
|
||||
from app.models.startup_ethics import StartupEthics
|
||||
from app.models.kickoff_meeting import KickoffMeeting
|
||||
from app.models.training_authorization import TrainingAuthorization
|
||||
from app.schemas.startup import (
|
||||
KickoffMeetingCreate,
|
||||
KickoffMeetingUpdate,
|
||||
StartupEthicsCreate,
|
||||
StartupEthicsUpdate,
|
||||
StartupFeasibilityCreate,
|
||||
StartupFeasibilityUpdate,
|
||||
TrainingAuthorizationCreate,
|
||||
TrainingAuthorizationUpdate,
|
||||
)
|
||||
|
||||
|
||||
async def create_feasibility(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
record_in: StartupFeasibilityCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> StartupFeasibility:
|
||||
record = StartupFeasibility(
|
||||
study_id=study_id,
|
||||
submit_date=record_in.submit_date,
|
||||
accept_date=record_in.accept_date,
|
||||
approved_date=record_in.approved_date,
|
||||
project_no=record_in.project_no,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def get_feasibility(db: AsyncSession, record_id: uuid.UUID) -> StartupFeasibility | None:
|
||||
result = await db.execute(select(StartupFeasibility).where(StartupFeasibility.id == record_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_feasibilities(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[StartupFeasibility]:
|
||||
stmt = (
|
||||
select(StartupFeasibility)
|
||||
.where(StartupFeasibility.study_id == study_id)
|
||||
.order_by(StartupFeasibility.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_feasibility(
|
||||
db: AsyncSession, record: StartupFeasibility, record_in: StartupFeasibilityUpdate
|
||||
) -> StartupFeasibility:
|
||||
update_data = record_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(record, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_feasibility(db: AsyncSession, record: StartupFeasibility) -> None:
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_ethics(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
record_in: StartupEthicsCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> StartupEthics:
|
||||
record = StartupEthics(
|
||||
study_id=study_id,
|
||||
submit_date=record_in.submit_date,
|
||||
accept_date=record_in.accept_date,
|
||||
meeting_date=record_in.meeting_date,
|
||||
approved_date=record_in.approved_date,
|
||||
approval_no=record_in.approval_no,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def get_ethics(db: AsyncSession, record_id: uuid.UUID) -> StartupEthics | None:
|
||||
result = await db.execute(select(StartupEthics).where(StartupEthics.id == record_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_ethics(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[StartupEthics]:
|
||||
stmt = (
|
||||
select(StartupEthics)
|
||||
.where(StartupEthics.study_id == study_id)
|
||||
.order_by(StartupEthics.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_ethics(
|
||||
db: AsyncSession, record: StartupEthics, record_in: StartupEthicsUpdate
|
||||
) -> StartupEthics:
|
||||
update_data = record_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(record, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_ethics(db: AsyncSession, record: StartupEthics) -> None:
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_kickoff(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
meeting_in: KickoffMeetingCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> KickoffMeeting:
|
||||
meeting = KickoffMeeting(
|
||||
study_id=study_id,
|
||||
kickoff_date=meeting_in.kickoff_date,
|
||||
attendees=meeting_in.attendees,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(meeting)
|
||||
await db.commit()
|
||||
await db.refresh(meeting)
|
||||
return meeting
|
||||
|
||||
|
||||
async def get_kickoff(db: AsyncSession, meeting_id: uuid.UUID) -> KickoffMeeting | None:
|
||||
result = await db.execute(select(KickoffMeeting).where(KickoffMeeting.id == meeting_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_kickoffs(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> Sequence[KickoffMeeting]:
|
||||
stmt = (
|
||||
select(KickoffMeeting)
|
||||
.where(KickoffMeeting.study_id == study_id)
|
||||
.order_by(KickoffMeeting.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_kickoff(
|
||||
db: AsyncSession, meeting: KickoffMeeting, meeting_in: KickoffMeetingUpdate
|
||||
) -> KickoffMeeting:
|
||||
update_data = meeting_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(meeting, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(meeting)
|
||||
return meeting
|
||||
|
||||
|
||||
async def delete_kickoff(db: AsyncSession, meeting: KickoffMeeting) -> None:
|
||||
await db.delete(meeting)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def create_training_authorization(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
record_in: TrainingAuthorizationCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> TrainingAuthorization:
|
||||
record = TrainingAuthorization(
|
||||
study_id=study_id,
|
||||
name=record_in.name,
|
||||
role=record_in.role,
|
||||
site_name=record_in.site_name,
|
||||
trained=record_in.trained,
|
||||
authorized=record_in.authorized,
|
||||
trained_date=record_in.trained_date,
|
||||
authorized_date=record_in.authorized_date,
|
||||
remark=record_in.remark,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(record)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def get_training_authorization(
|
||||
db: AsyncSession, record_id: uuid.UUID
|
||||
) -> TrainingAuthorization | None:
|
||||
result = await db.execute(select(TrainingAuthorization).where(TrainingAuthorization.id == record_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_training_authorizations(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
) -> Sequence[TrainingAuthorization]:
|
||||
stmt = (
|
||||
select(TrainingAuthorization)
|
||||
.where(TrainingAuthorization.study_id == study_id)
|
||||
.order_by(TrainingAuthorization.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_training_authorization(
|
||||
db: AsyncSession, record: TrainingAuthorization, record_in: TrainingAuthorizationUpdate
|
||||
) -> TrainingAuthorization:
|
||||
update_data = record_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(record, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
async def delete_training_authorization(db: AsyncSession, record: TrainingAuthorization) -> None:
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
@@ -57,12 +57,15 @@ async def list_subjects(
|
||||
study_id: uuid.UUID,
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import uuid
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.subject import Subject
|
||||
from app.models.subject_history import SubjectHistory
|
||||
from app.schemas.subject_history import SubjectHistoryCreate, SubjectHistoryUpdate
|
||||
|
||||
|
||||
async def _ensure_subject(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID) -> None:
|
||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||
subject = result.scalar_one_or_none()
|
||||
if not subject or subject.study_id != study_id:
|
||||
raise ValueError("Subject not found in study")
|
||||
|
||||
|
||||
async def create_history(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
history_in: SubjectHistoryCreate,
|
||||
created_by: uuid.UUID | None,
|
||||
) -> SubjectHistory:
|
||||
await _ensure_subject(db, study_id, history_in.subject_id)
|
||||
history = SubjectHistory(
|
||||
study_id=study_id,
|
||||
subject_id=history_in.subject_id,
|
||||
record_date=history_in.record_date,
|
||||
content=history_in.content,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return history
|
||||
|
||||
|
||||
async def get_history(db: AsyncSession, history_id: uuid.UUID) -> SubjectHistory | None:
|
||||
result = await db.execute(select(SubjectHistory).where(SubjectHistory.id == history_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_histories(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
subject_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
) -> Sequence[SubjectHistory]:
|
||||
stmt = (
|
||||
select(SubjectHistory)
|
||||
.where(SubjectHistory.study_id == study_id, SubjectHistory.subject_id == subject_id)
|
||||
.order_by(SubjectHistory.record_date.desc().nullslast(), SubjectHistory.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_history(
|
||||
db: AsyncSession, history: SubjectHistory, history_in: SubjectHistoryUpdate
|
||||
) -> SubjectHistory:
|
||||
update_data = history_in.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(history, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(history)
|
||||
return history
|
||||
|
||||
|
||||
async def delete_history(db: AsyncSession, history: SubjectHistory) -> None:
|
||||
await db.delete(history)
|
||||
await db.commit()
|
||||
@@ -1,108 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Sequence
|
||||
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.subject import Subject
|
||||
from app.models.verification import VerificationProgress
|
||||
from app.schemas.verification import VerificationCreate, VerificationUpdate
|
||||
|
||||
|
||||
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, subject_id: uuid.UUID):
|
||||
result = await db.execute(select(Subject).where(Subject.id == subject_id))
|
||||
subj = result.scalar_one_or_none()
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise ValueError("Subject not found in study")
|
||||
if subj.site_id != site_id:
|
||||
raise ValueError("Site does not match subject")
|
||||
|
||||
|
||||
async def upsert_progress(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
data: VerificationCreate,
|
||||
) -> VerificationProgress:
|
||||
await _validate_site_subject(db, study_id, data.site_id, data.subject_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(VerificationProgress).where(
|
||||
VerificationProgress.study_id == study_id,
|
||||
VerificationProgress.subject_id == data.subject_id,
|
||||
VerificationProgress.level == data.level,
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
update_data = data.model_dump()
|
||||
await db.execute(
|
||||
sa_update(VerificationProgress)
|
||||
.where(VerificationProgress.id == existing.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return existing
|
||||
|
||||
vp = VerificationProgress(
|
||||
study_id=study_id,
|
||||
site_id=data.site_id,
|
||||
subject_id=data.subject_id,
|
||||
level=data.level,
|
||||
percent=data.percent,
|
||||
last_verified_at=data.last_verified_at,
|
||||
verifier_id=data.verifier_id,
|
||||
notes=data.notes,
|
||||
)
|
||||
db.add(vp)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
|
||||
|
||||
async def get_progress(db: AsyncSession, study_id: uuid.UUID, subject_id: uuid.UUID, level: str) -> VerificationProgress | None:
|
||||
result = await db.execute(
|
||||
select(VerificationProgress).where(
|
||||
VerificationProgress.study_id == study_id,
|
||||
VerificationProgress.subject_id == subject_id,
|
||||
VerificationProgress.level == level,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_by_id(db: AsyncSession, verification_id: uuid.UUID) -> VerificationProgress | None:
|
||||
result = await db.execute(select(VerificationProgress).where(VerificationProgress.id == verification_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_progress(
|
||||
db: AsyncSession,
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID | None = None,
|
||||
subject_id: uuid.UUID | None = None,
|
||||
level: str | None = None,
|
||||
) -> Sequence[VerificationProgress]:
|
||||
stmt = select(VerificationProgress).where(VerificationProgress.study_id == study_id)
|
||||
if site_id:
|
||||
stmt = stmt.where(VerificationProgress.site_id == site_id)
|
||||
if subject_id:
|
||||
stmt = stmt.where(VerificationProgress.subject_id == subject_id)
|
||||
if level:
|
||||
stmt = stmt.where(VerificationProgress.level == level)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_progress(db: AsyncSession, vp: VerificationProgress, data: VerificationUpdate) -> VerificationProgress:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if update_data:
|
||||
await db.execute(
|
||||
sa_update(VerificationProgress)
|
||||
.where(VerificationProgress.id == vp.id)
|
||||
.values(**update_data)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(vp)
|
||||
return vp
|
||||
@@ -58,3 +58,8 @@ async def update_visit(db: AsyncSession, visit: Visit, visit_in: VisitUpdate) ->
|
||||
await db.commit()
|
||||
await db.refresh(visit)
|
||||
return visit
|
||||
|
||||
|
||||
async def delete_visit(db: AsyncSession, visit: Visit) -> None:
|
||||
await db.delete(visit)
|
||||
await db.commit()
|
||||
|
||||
@@ -5,21 +5,22 @@ from app.models.user import User # noqa: F401
|
||||
from app.models.study import Study # noqa: F401
|
||||
from app.models.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
|
||||
|
||||
@@ -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": "常量字典"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
__tablename__ = "comments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
quote_comment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("comments.id"), nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
@@ -1,32 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class DataQuery(Base):
|
||||
__tablename__ = "data_queries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
||||
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
|
||||
visit_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
assigned_to: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
|
||||
resolution: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class DrugShipment(Base):
|
||||
__tablename__ = "drug_shipments"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
direction: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
drug_desc: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
ship_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
tracking_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
from_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
to_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class FinanceContract(Base):
|
||||
__tablename__ = "finance_contracts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
contract_no: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
signed_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="CNY")
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class FinanceSpecial(Base):
|
||||
__tablename__ = "finance_specials"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
fee_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
occur_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
staff_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,25 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class ImpBatch(Base):
|
||||
__tablename__ = "imp_batches"
|
||||
__table_args__ = (UniqueConstraint("study_id", "batch_no", "product_id", name="uq_imp_batch_no"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_products.id"), index=True, nullable=False)
|
||||
batch_no: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
expiry_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
manufacture_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ACTIVE")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,20 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class ImpInventory(Base):
|
||||
__tablename__ = "imp_inventory"
|
||||
__table_args__ = (UniqueConstraint("study_id", "site_id", "batch_id", name="uq_imp_inventory_site_batch"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
||||
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
|
||||
quantity_on_hand: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
@@ -1,25 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class ImpTransaction(Base):
|
||||
__tablename__ = "imp_transactions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
||||
batch_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("imp_batches.id"), index=True, nullable=False)
|
||||
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), nullable=True)
|
||||
tx_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
tx_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
reference: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
@@ -1,31 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Issue(Base):
|
||||
__tablename__ = "issues"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
||||
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
level: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
|
||||
capa: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, JSON, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class KickoffMeeting(Base):
|
||||
__tablename__ = "kickoff_meetings"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
kickoff_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
attendees: Mapped[list[str] | None] = mapped_column(JSON, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,25 +1,23 @@
|
||||
import uuid
|
||||
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()
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class StartupEthics(Base):
|
||||
__tablename__ = "startup_ethics"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
meeting_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
approval_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class StartupFeasibility(Base):
|
||||
__tablename__ = "startup_feasibility"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
submit_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
accept_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
approved_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
project_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class SubjectHistory(Base):
|
||||
__tablename__ = "subject_histories"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
|
||||
record_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class TrainingAuthorization(Base):
|
||||
__tablename__ = "training_authorizations"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
role: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
site_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
trained: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
authorized: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
trained_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
authorized_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, date
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class VerificationProgress(Base):
|
||||
__tablename__ = "verification_progress"
|
||||
__table_args__ = (UniqueConstraint("study_id", "subject_id", "level", name="uq_verification_subject_level"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||
site_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False)
|
||||
subject_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=False)
|
||||
level: Mapped[str] = mapped_column(String(10), nullable=False) # SDR / SDV
|
||||
percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_verified_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
verifier_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,31 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
quote_comment_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CommentQuote(BaseModel):
|
||||
id: uuid.UUID
|
||||
content: str
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
is_deleted: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CommentRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
content: str
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
quote_comment_id: uuid.UUID | None = None
|
||||
quote: CommentQuote | None = None
|
||||
is_deleted: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,51 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class DataQueryCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
category: str
|
||||
priority: str
|
||||
site_id: Optional[uuid.UUID] = None
|
||||
subject_id: Optional[uuid.UUID] = None
|
||||
visit_id: Optional[uuid.UUID] = None
|
||||
assigned_to: Optional[uuid.UUID] = None
|
||||
due_date: Optional[date] = None
|
||||
|
||||
|
||||
class DataQueryUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assigned_to: Optional[uuid.UUID] = None
|
||||
due_date: Optional[date] = None
|
||||
status: Optional[str] = None
|
||||
resolution: Optional[str] = None
|
||||
|
||||
|
||||
class DataQueryRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_id: Optional[uuid.UUID]
|
||||
subject_id: Optional[uuid.UUID]
|
||||
visit_id: Optional[uuid.UUID]
|
||||
title: str
|
||||
description: Optional[str]
|
||||
category: str
|
||||
priority: str
|
||||
assigned_to: Optional[uuid.UUID]
|
||||
due_date: Optional[date]
|
||||
status: str
|
||||
resolution: Optional[str]
|
||||
closed_at: Optional[datetime]
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
is_overdue: bool | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,51 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class DrugShipmentCreate(BaseModel):
|
||||
direction: str
|
||||
site_name: str
|
||||
drug_desc: str
|
||||
ship_date: Optional[date] = None
|
||||
carrier: Optional[str] = None
|
||||
tracking_no: Optional[str] = None
|
||||
from_party: Optional[str] = None
|
||||
to_party: Optional[str] = None
|
||||
status: str
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class DrugShipmentUpdate(BaseModel):
|
||||
direction: Optional[str] = None
|
||||
site_name: Optional[str] = None
|
||||
drug_desc: Optional[str] = None
|
||||
ship_date: Optional[date] = None
|
||||
carrier: Optional[str] = None
|
||||
tracking_no: Optional[str] = None
|
||||
from_party: Optional[str] = None
|
||||
to_party: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class DrugShipmentRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
direction: str
|
||||
site_name: str
|
||||
drug_desc: str
|
||||
ship_date: Optional[date]
|
||||
carrier: Optional[str]
|
||||
tracking_no: Optional[str]
|
||||
from_party: Optional[str]
|
||||
to_party: Optional[str]
|
||||
status: str
|
||||
remark: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,67 +1,6 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from 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
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FinanceContractCreate(BaseModel):
|
||||
site_name: str
|
||||
contract_no: str
|
||||
signed_date: Optional[date] = None
|
||||
amount: Decimal = Field(gt=0)
|
||||
currency: str = "CNY"
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FinanceContractUpdate(BaseModel):
|
||||
site_name: Optional[str] = None
|
||||
contract_no: Optional[str] = None
|
||||
signed_date: Optional[date] = None
|
||||
amount: Optional[Decimal] = Field(default=None, gt=0)
|
||||
currency: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FinanceContractRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_name: str
|
||||
contract_no: str
|
||||
signed_date: Optional[date]
|
||||
amount: Decimal
|
||||
currency: str
|
||||
remark: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FinanceSpecialCreate(BaseModel):
|
||||
site_name: str
|
||||
fee_type: str
|
||||
amount: Decimal = Field(gt=0)
|
||||
occur_date: Optional[date] = None
|
||||
staff_name: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FinanceSpecialUpdate(BaseModel):
|
||||
site_name: Optional[str] = None
|
||||
fee_type: Optional[str] = None
|
||||
amount: Optional[Decimal] = Field(default=None, gt=0)
|
||||
occur_date: Optional[date] = None
|
||||
staff_name: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class FinanceSpecialRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_name: str
|
||||
fee_type: str
|
||||
amount: Decimal
|
||||
occur_date: Optional[date]
|
||||
staff_name: Optional[str]
|
||||
remark: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,102 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
name: str
|
||||
form: Optional[str] = None
|
||||
strength: Optional[str] = None
|
||||
unit: str
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
form: Optional[str] = None
|
||||
strength: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ProductRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
name: str
|
||||
form: Optional[str]
|
||||
strength: Optional[str]
|
||||
unit: str
|
||||
description: Optional[str]
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class BatchCreate(BaseModel):
|
||||
product_id: uuid.UUID
|
||||
batch_no: str
|
||||
expiry_date: Optional[date] = None
|
||||
manufacture_date: Optional[date] = None
|
||||
|
||||
|
||||
class BatchUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
expiry_date: Optional[date] = None
|
||||
manufacture_date: Optional[date] = None
|
||||
|
||||
|
||||
class BatchRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
batch_no: str
|
||||
expiry_date: Optional[date]
|
||||
manufacture_date: Optional[date]
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class InventoryRead(BaseModel):
|
||||
site_id: uuid.UUID
|
||||
batch_id: uuid.UUID
|
||||
quantity_on_hand: int
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TxCreate(BaseModel):
|
||||
site_id: uuid.UUID
|
||||
batch_id: uuid.UUID
|
||||
subject_id: Optional[uuid.UUID] = None
|
||||
tx_type: str
|
||||
quantity: int = Field(gt=0)
|
||||
tx_date: date
|
||||
reference: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class TxRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_id: uuid.UUID
|
||||
batch_id: uuid.UUID
|
||||
subject_id: Optional[uuid.UUID]
|
||||
tx_type: str
|
||||
quantity: int
|
||||
tx_date: date
|
||||
reference: Optional[str]
|
||||
notes: Optional[str]
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,49 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class IssueCreate(BaseModel):
|
||||
title: str
|
||||
category: str
|
||||
level: str
|
||||
due_date: Optional[date] = None
|
||||
owner_id: Optional[uuid.UUID] = None
|
||||
site_id: Optional[uuid.UUID] = None
|
||||
subject_id: Optional[uuid.UUID] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class IssueUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
level: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
owner_id: Optional[uuid.UUID] = None
|
||||
status: Optional[str] = None
|
||||
capa: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class IssueRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_id: Optional[uuid.UUID]
|
||||
subject_id: Optional[uuid.UUID]
|
||||
title: str
|
||||
description: Optional[str]
|
||||
category: str
|
||||
level: str
|
||||
owner_id: Optional[uuid.UUID]
|
||||
due_date: Optional[date]
|
||||
status: str
|
||||
capa: Optional[str]
|
||||
closed_at: Optional[datetime]
|
||||
created_by: uuid.UUID
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
is_overdue: bool | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,33 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class KnowledgeNoteCreate(BaseModel):
|
||||
site_name: str
|
||||
title: str
|
||||
content: str
|
||||
level: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeNoteUpdate(BaseModel):
|
||||
site_name: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
level: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeNoteRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_name: str
|
||||
title: str
|
||||
content: str
|
||||
level: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,52 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.schemas.user import UserDisplay
|
||||
|
||||
|
||||
class SiteDisplay(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: Optional[str] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
class MilestoneCreate(BaseModel):
|
||||
type: str
|
||||
name: Optional[str] = None
|
||||
planned_date: Optional[date] = None
|
||||
owner_id: Optional[uuid.UUID] = None
|
||||
site_id: Optional[uuid.UUID] = None
|
||||
notes: Optional[str] = None
|
||||
status: str = Field(default="NOT_STARTED")
|
||||
|
||||
|
||||
class MilestoneUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
planned_date: Optional[date] = None
|
||||
actual_date: Optional[date] = None
|
||||
status: Optional[str] = None
|
||||
owner_id: Optional[uuid.UUID] = None
|
||||
site_id: Optional[uuid.UUID] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MilestoneRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
type: str
|
||||
name: str
|
||||
planned_date: Optional[date]
|
||||
actual_date: Optional[date]
|
||||
status: str
|
||||
owner_id: Optional[uuid.UUID]
|
||||
site_id: Optional[uuid.UUID] = None
|
||||
owner: Optional[UserDisplay] = None
|
||||
site: Optional[SiteDisplay] = None
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,126 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class StartupFeasibilityCreate(BaseModel):
|
||||
submit_date: Optional[date] = None
|
||||
accept_date: Optional[date] = None
|
||||
approved_date: Optional[date] = None
|
||||
project_no: Optional[str] = None
|
||||
|
||||
|
||||
class StartupFeasibilityUpdate(BaseModel):
|
||||
submit_date: Optional[date] = None
|
||||
accept_date: Optional[date] = None
|
||||
approved_date: Optional[date] = None
|
||||
project_no: Optional[str] = None
|
||||
|
||||
|
||||
class StartupFeasibilityRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
submit_date: Optional[date]
|
||||
accept_date: Optional[date]
|
||||
approved_date: Optional[date]
|
||||
project_no: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class StartupEthicsCreate(BaseModel):
|
||||
submit_date: Optional[date] = None
|
||||
accept_date: Optional[date] = None
|
||||
meeting_date: Optional[date] = None
|
||||
approved_date: Optional[date] = None
|
||||
approval_no: Optional[str] = None
|
||||
|
||||
|
||||
class StartupEthicsUpdate(BaseModel):
|
||||
submit_date: Optional[date] = None
|
||||
accept_date: Optional[date] = None
|
||||
meeting_date: Optional[date] = None
|
||||
approved_date: Optional[date] = None
|
||||
approval_no: Optional[str] = None
|
||||
|
||||
|
||||
class StartupEthicsRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
submit_date: Optional[date]
|
||||
accept_date: Optional[date]
|
||||
meeting_date: Optional[date]
|
||||
approved_date: Optional[date]
|
||||
approval_no: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class KickoffMeetingCreate(BaseModel):
|
||||
kickoff_date: Optional[date] = None
|
||||
attendees: Optional[list[str]] = None
|
||||
|
||||
|
||||
class KickoffMeetingUpdate(BaseModel):
|
||||
kickoff_date: Optional[date] = None
|
||||
attendees: Optional[list[str]] = None
|
||||
|
||||
|
||||
class KickoffMeetingRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
kickoff_date: Optional[date]
|
||||
attendees: Optional[list[str]]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class TrainingAuthorizationCreate(BaseModel):
|
||||
name: str
|
||||
role: Optional[str] = None
|
||||
site_name: Optional[str] = None
|
||||
trained: bool = False
|
||||
authorized: bool = False
|
||||
trained_date: Optional[date] = None
|
||||
authorized_date: Optional[date] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class TrainingAuthorizationUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
site_name: Optional[str] = None
|
||||
trained: Optional[bool] = None
|
||||
authorized: Optional[bool] = None
|
||||
trained_date: Optional[date] = None
|
||||
authorized_date: Optional[date] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class TrainingAuthorizationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
name: str
|
||||
role: Optional[str]
|
||||
site_name: Optional[str]
|
||||
trained: bool
|
||||
authorized: bool
|
||||
trained_date: Optional[date]
|
||||
authorized_date: Optional[date]
|
||||
remark: Optional[str]
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -0,0 +1,29 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SubjectHistoryCreate(BaseModel):
|
||||
subject_id: uuid.UUID
|
||||
record_date: Optional[date] = None
|
||||
content: str
|
||||
|
||||
|
||||
class SubjectHistoryUpdate(BaseModel):
|
||||
record_date: Optional[date] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class SubjectHistoryRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
subject_id: uuid.UUID
|
||||
record_date: Optional[date]
|
||||
content: str
|
||||
created_by: Optional[uuid.UUID]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,38 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class VerificationCreate(BaseModel):
|
||||
site_id: uuid.UUID
|
||||
subject_id: uuid.UUID
|
||||
level: str
|
||||
percent: int = Field(ge=0, le=100)
|
||||
last_verified_at: Optional[date] = None
|
||||
verifier_id: Optional[uuid.UUID] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class VerificationUpdate(BaseModel):
|
||||
percent: Optional[int] = Field(default=None, ge=0, le=100)
|
||||
last_verified_at: Optional[date] = None
|
||||
verifier_id: Optional[uuid.UUID] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class VerificationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
study_id: uuid.UUID
|
||||
site_id: uuid.UUID
|
||||
subject_id: uuid.UUID
|
||||
level: str
|
||||
percent: int
|
||||
last_verified_at: Optional[date]
|
||||
verifier_id: Optional[uuid.UUID]
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -1,85 +0,0 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.crud import imp_inventory as inventory_crud
|
||||
from app.crud import imp_tx as tx_crud
|
||||
from app.crud import audit as audit_crud
|
||||
from app.models.imp_batch import ImpBatch
|
||||
from app.models.site import Site
|
||||
from app.models.subject import Subject
|
||||
|
||||
TxType = Literal["RECEIPT", "DISPENSE", "RETURN", "RECONCILE", "DESTROY", "TRANSFER_IN", "TRANSFER_OUT"]
|
||||
|
||||
POSITIVE_TYPES = {"RECEIPT", "RETURN", "TRANSFER_IN"}
|
||||
NEGATIVE_TYPES = {"DISPENSE", "DESTROY", "TRANSFER_OUT"}
|
||||
|
||||
|
||||
async def _validate_entities(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID, batch_id: uuid.UUID, subject_id: uuid.UUID | None):
|
||||
site = await db.get(Site, site_id)
|
||||
if not site or site.study_id != study_id:
|
||||
raise ValueError("Site not found in study")
|
||||
batch = await db.get(ImpBatch, batch_id)
|
||||
if not batch or batch.study_id != study_id:
|
||||
raise ValueError("Batch not found in study")
|
||||
# batch status check could be extended
|
||||
if subject_id:
|
||||
subj = await db.get(Subject, subject_id)
|
||||
if not subj or subj.study_id != study_id:
|
||||
raise ValueError("Subject not found in study")
|
||||
|
||||
|
||||
async def create_transaction_and_apply_inventory(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
study_id: uuid.UUID,
|
||||
site_id: uuid.UUID,
|
||||
batch_id: uuid.UUID,
|
||||
subject_id: uuid.UUID | None,
|
||||
tx_type: TxType,
|
||||
quantity: int,
|
||||
tx_date,
|
||||
reference: str | None,
|
||||
notes: str | None,
|
||||
operator_id: uuid.UUID,
|
||||
operator_role: str,
|
||||
) -> tuple:
|
||||
if quantity <= 0:
|
||||
raise ValueError("Quantity must be positive")
|
||||
await _validate_entities(db, study_id, site_id, batch_id, subject_id)
|
||||
|
||||
async with db.begin_nested():
|
||||
inv = await inventory_crud.get_or_create_inventory(db, study_id, site_id, batch_id)
|
||||
delta = quantity if tx_type in POSITIVE_TYPES else -quantity
|
||||
new_qoh = inv.quantity_on_hand + delta
|
||||
if new_qoh < 0:
|
||||
raise ValueError("Insufficient inventory")
|
||||
|
||||
inv.quantity_on_hand = new_qoh
|
||||
tx = await tx_crud.create_tx(
|
||||
db,
|
||||
study_id=study_id,
|
||||
site_id=site_id,
|
||||
batch_id=batch_id,
|
||||
subject_id=subject_id,
|
||||
tx_type=tx_type,
|
||||
quantity=quantity,
|
||||
tx_date=tx_date,
|
||||
reference=reference,
|
||||
notes=notes,
|
||||
created_by=operator_id,
|
||||
)
|
||||
await db.flush()
|
||||
detail = f"{tx_type} {quantity} units batch {batch_id} at site {site_id}, QOH -> {new_qoh}"
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=study_id,
|
||||
entity_type="imp_tx",
|
||||
entity_id=tx.id,
|
||||
action="CREATE_IMP_TX",
|
||||
detail=detail,
|
||||
operator_id=operator_id,
|
||||
operator_role=operator_role,
|
||||
)
|
||||
return tx
|
||||
Reference in New Issue
Block a user