diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py index bcb6ab2e..e28d1f46 100644 --- a/backend/app/api/v1/aes.py +++ b/backend/app/api/v1/aes.py @@ -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, + ) diff --git a/backend/app/api/v1/comments.py b/backend/app/api/v1/comments.py deleted file mode 100644 index ea173965..00000000 --- a/backend/app/api/v1/comments.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/api/v1/constants.py b/backend/app/api/v1/constants.py deleted file mode 100644 index 73b7bb9f..00000000 --- a/backend/app/api/v1/constants.py +++ /dev/null @@ -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}, - } diff --git a/backend/app/api/v1/data_queries.py b/backend/app/api/v1/data_queries.py deleted file mode 100644 index f5a359f2..00000000 --- a/backend/app/api/v1/data_queries.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py new file mode 100644 index 00000000..e1e099f4 --- /dev/null +++ b/backend/app/api/v1/drug_shipments.py @@ -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, + ) diff --git a/backend/app/api/v1/finance.py b/backend/app/api/v1/finance.py deleted file mode 100644 index 2ece9cd3..00000000 --- a/backend/app/api/v1/finance.py +++ /dev/null @@ -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)) diff --git a/backend/app/api/v1/finance_contracts.py b/backend/app/api/v1/finance_contracts.py new file mode 100644 index 00000000..b0cb351f --- /dev/null +++ b/backend/app/api/v1/finance_contracts.py @@ -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, + ) diff --git a/backend/app/api/v1/finance_specials.py b/backend/app/api/v1/finance_specials.py new file mode 100644 index 00000000..381cab4f --- /dev/null +++ b/backend/app/api/v1/finance_specials.py @@ -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, + ) diff --git a/backend/app/api/v1/imp.py b/backend/app/api/v1/imp.py deleted file mode 100644 index 8546a549..00000000 --- a/backend/app/api/v1/imp.py +++ /dev/null @@ -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() diff --git a/backend/app/api/v1/imp_batches.py b/backend/app/api/v1/imp_batches.py deleted file mode 100644 index d2f71163..00000000 --- a/backend/app/api/v1/imp_batches.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/imp_inventory.py b/backend/app/api/v1/imp_inventory.py deleted file mode 100644 index a3ae849b..00000000 --- a/backend/app/api/v1/imp_inventory.py +++ /dev/null @@ -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] diff --git a/backend/app/api/v1/imp_products.py b/backend/app/api/v1/imp_products.py deleted file mode 100644 index 7e53d58d..00000000 --- a/backend/app/api/v1/imp_products.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/imp_transactions.py b/backend/app/api/v1/imp_transactions.py deleted file mode 100644 index 1be8b3a0..00000000 --- a/backend/app/api/v1/imp_transactions.py +++ /dev/null @@ -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] diff --git a/backend/app/api/v1/issues.py b/backend/app/api/v1/issues.py deleted file mode 100644 index 677171fd..00000000 --- a/backend/app/api/v1/issues.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/knowledge_notes.py b/backend/app/api/v1/knowledge_notes.py new file mode 100644 index 00000000..215dded8 --- /dev/null +++ b/backend/app/api/v1/knowledge_notes.py @@ -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, + ) diff --git a/backend/app/api/v1/milestones.py b/backend/app/api/v1/milestones.py deleted file mode 100644 index b280a23a..00000000 --- a/backend/app/api/v1/milestones.py +++ /dev/null @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 3ecb4006..1e28401a 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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"]) diff --git a/backend/app/api/v1/startup.py b/backend/app/api/v1/startup.py new file mode 100644 index 00000000..bfe86e29 --- /dev/null +++ b/backend/app/api/v1/startup.py @@ -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, + ) diff --git a/backend/app/api/v1/subject_histories.py b/backend/app/api/v1/subject_histories.py new file mode 100644 index 00000000..c2c77be5 --- /dev/null +++ b/backend/app/api/v1/subject_histories.py @@ -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, + ) diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index fb832180..4b730fa8 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -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, + ) diff --git a/backend/app/api/v1/verifications.py b/backend/app/api/v1/verifications.py deleted file mode 100644 index b643ce95..00000000 --- a/backend/app/api/v1/verifications.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index e38beeb6..7ebd6c13 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -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, + ) diff --git a/backend/app/constants/ae.py b/backend/app/constants/ae.py deleted file mode 100644 index 4e8e888a..00000000 --- a/backend/app/constants/ae.py +++ /dev/null @@ -1,3 +0,0 @@ -AE_SERIOUSNESS = ["NON_SERIOUS", "SERIOUS"] -AE_SEVERITY = ["G1", "G2", "G3", "G4", "G5"] -AE_STATUS = ["NEW", "FOLLOW_UP", "CLOSED"] diff --git a/backend/app/constants/finance.py b/backend/app/constants/finance.py deleted file mode 100644 index be96fa69..00000000 --- a/backend/app/constants/finance.py +++ /dev/null @@ -1,2 +0,0 @@ -FINANCE_STATUS = ["DRAFT", "SUBMITTED", "APPROVED", "REJECTED", "PAID"] -FINANCE_CATEGORY = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"] diff --git a/backend/app/constants/imp.py b/backend/app/constants/imp.py deleted file mode 100644 index d442304c..00000000 --- a/backend/app/constants/imp.py +++ /dev/null @@ -1,10 +0,0 @@ -IMP_TX_TYPES = [ - "RECEIPT", - "DISPENSE", - "RETURN", - "RECONCILE", - "DESTROY", - "TRANSFER_IN", - "TRANSFER_OUT", -] -IMP_BATCH_STATUS = ["ACTIVE", "QUARANTINED", "EXPIRED", "CLOSED"] diff --git a/backend/app/constants/issue.py b/backend/app/constants/issue.py deleted file mode 100644 index 10d1e760..00000000 --- a/backend/app/constants/issue.py +++ /dev/null @@ -1,3 +0,0 @@ -ISSUE_CATEGORY = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"] -ISSUE_LEVEL = ["LOW", "MEDIUM", "HIGH", "CRITICAL"] -ISSUE_STATUS = ["OPEN", "MITIGATING", "CLOSED"] diff --git a/backend/app/constants/subject.py b/backend/app/constants/subject.py deleted file mode 100644 index 7be69108..00000000 --- a/backend/app/constants/subject.py +++ /dev/null @@ -1,2 +0,0 @@ -SUBJECT_STATUS = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"] -VISIT_STATUS = ["PLANNED", "DONE", "MISSED", "CANCELLED"] diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py index b613f907..f2e05571 100644 --- a/backend/app/crud/ae.py +++ b/backend/app/crud/ae.py @@ -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() diff --git a/backend/app/crud/comment.py b/backend/app/crud/comment.py deleted file mode 100644 index 93d31e25..00000000 --- a/backend/app/crud/comment.py +++ /dev/null @@ -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 diff --git a/backend/app/crud/data_query.py b/backend/app/crud/data_query.py deleted file mode 100644 index b2803ddc..00000000 --- a/backend/app/crud/data_query.py +++ /dev/null @@ -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 diff --git a/backend/app/crud/drug_shipment.py b/backend/app/crud/drug_shipment.py new file mode 100644 index 00000000..48824c15 --- /dev/null +++ b/backend/app/crud/drug_shipment.py @@ -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() diff --git a/backend/app/crud/finance.py b/backend/app/crud/finance.py index aab82b5f..830bd8ec 100644 --- a/backend/app/crud/finance.py +++ b/backend/app/crud/finance.py @@ -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, diff --git a/backend/app/crud/finance_contract.py b/backend/app/crud/finance_contract.py new file mode 100644 index 00000000..b9c91a00 --- /dev/null +++ b/backend/app/crud/finance_contract.py @@ -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() diff --git a/backend/app/crud/finance_special.py b/backend/app/crud/finance_special.py new file mode 100644 index 00000000..6642bc26 --- /dev/null +++ b/backend/app/crud/finance_special.py @@ -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() diff --git a/backend/app/crud/imp_batch.py b/backend/app/crud/imp_batch.py deleted file mode 100644 index 75b698f5..00000000 --- a/backend/app/crud/imp_batch.py +++ /dev/null @@ -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 diff --git a/backend/app/crud/imp_inventory.py b/backend/app/crud/imp_inventory.py deleted file mode 100644 index a97dd4a2..00000000 --- a/backend/app/crud/imp_inventory.py +++ /dev/null @@ -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() diff --git a/backend/app/crud/imp_product.py b/backend/app/crud/imp_product.py deleted file mode 100644 index 493d5cbb..00000000 --- a/backend/app/crud/imp_product.py +++ /dev/null @@ -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 diff --git a/backend/app/crud/imp_tx.py b/backend/app/crud/imp_tx.py deleted file mode 100644 index 68de9bd2..00000000 --- a/backend/app/crud/imp_tx.py +++ /dev/null @@ -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() diff --git a/backend/app/crud/issue.py b/backend/app/crud/issue.py deleted file mode 100644 index fdbc1d48..00000000 --- a/backend/app/crud/issue.py +++ /dev/null @@ -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 diff --git a/backend/app/crud/knowledge_note.py b/backend/app/crud/knowledge_note.py new file mode 100644 index 00000000..d90f596d --- /dev/null +++ b/backend/app/crud/knowledge_note.py @@ -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() diff --git a/backend/app/crud/milestone.py b/backend/app/crud/milestone.py deleted file mode 100644 index 9b065a1a..00000000 --- a/backend/app/crud/milestone.py +++ /dev/null @@ -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() diff --git a/backend/app/crud/startup.py b/backend/app/crud/startup.py new file mode 100644 index 00000000..6c9ce892 --- /dev/null +++ b/backend/app/crud/startup.py @@ -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() diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index b9ce3d2c..04a08b7a 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -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() diff --git a/backend/app/crud/subject_history.py b/backend/app/crud/subject_history.py new file mode 100644 index 00000000..504e43ca --- /dev/null +++ b/backend/app/crud/subject_history.py @@ -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() diff --git a/backend/app/crud/verification.py b/backend/app/crud/verification.py deleted file mode 100644 index abe73eeb..00000000 --- a/backend/app/crud/verification.py +++ /dev/null @@ -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 diff --git a/backend/app/crud/visit.py b/backend/app/crud/visit.py index 8d6f55ac..315a0626 100644 --- a/backend/app/crud/visit.py +++ b/backend/app/crud/visit.py @@ -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() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 1e8c11e8..d77cb6dd 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index d0d46837..0a8d94e0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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": "常量字典"}, ], ) diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py deleted file mode 100644 index e28c9019..00000000 --- a/backend/app/models/comment.py +++ /dev/null @@ -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") diff --git a/backend/app/models/data_query.py b/backend/app/models/data_query.py deleted file mode 100644 index 135f5bce..00000000 --- a/backend/app/models/data_query.py +++ /dev/null @@ -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() - ) diff --git a/backend/app/models/drug_shipment.py b/backend/app/models/drug_shipment.py new file mode 100644 index 00000000..010c5644 --- /dev/null +++ b/backend/app/models/drug_shipment.py @@ -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() + ) diff --git a/backend/app/models/finance_contract.py b/backend/app/models/finance_contract.py new file mode 100644 index 00000000..66d2e2ad --- /dev/null +++ b/backend/app/models/finance_contract.py @@ -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() + ) diff --git a/backend/app/models/finance_special.py b/backend/app/models/finance_special.py new file mode 100644 index 00000000..ef951b76 --- /dev/null +++ b/backend/app/models/finance_special.py @@ -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() + ) diff --git a/backend/app/models/imp_batch.py b/backend/app/models/imp_batch.py deleted file mode 100644 index 8e078bb8..00000000 --- a/backend/app/models/imp_batch.py +++ /dev/null @@ -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() - ) diff --git a/backend/app/models/imp_inventory.py b/backend/app/models/imp_inventory.py deleted file mode 100644 index 3d66798a..00000000 --- a/backend/app/models/imp_inventory.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/imp_transaction.py b/backend/app/models/imp_transaction.py deleted file mode 100644 index 5375f25f..00000000 --- a/backend/app/models/imp_transaction.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/issue.py b/backend/app/models/issue.py deleted file mode 100644 index 33136c80..00000000 --- a/backend/app/models/issue.py +++ /dev/null @@ -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() - ) diff --git a/backend/app/models/kickoff_meeting.py b/backend/app/models/kickoff_meeting.py new file mode 100644 index 00000000..e3f39709 --- /dev/null +++ b/backend/app/models/kickoff_meeting.py @@ -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() + ) diff --git a/backend/app/models/imp_product.py b/backend/app/models/knowledge_note.py similarity index 50% rename from backend/app/models/imp_product.py rename to backend/app/models/knowledge_note.py index 673c3e09..b58a3ba0 100644 --- a/backend/app/models/imp_product.py +++ b/backend/app/models/knowledge_note.py @@ -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() diff --git a/backend/app/models/startup_ethics.py b/backend/app/models/startup_ethics.py new file mode 100644 index 00000000..4eb01f92 --- /dev/null +++ b/backend/app/models/startup_ethics.py @@ -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() + ) diff --git a/backend/app/models/startup_feasibility.py b/backend/app/models/startup_feasibility.py new file mode 100644 index 00000000..75a80652 --- /dev/null +++ b/backend/app/models/startup_feasibility.py @@ -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() + ) diff --git a/backend/app/models/subject_history.py b/backend/app/models/subject_history.py new file mode 100644 index 00000000..7dc5eafa --- /dev/null +++ b/backend/app/models/subject_history.py @@ -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() + ) diff --git a/backend/app/models/training_authorization.py b/backend/app/models/training_authorization.py new file mode 100644 index 00000000..48470ee4 --- /dev/null +++ b/backend/app/models/training_authorization.py @@ -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() + ) diff --git a/backend/app/models/verification.py b/backend/app/models/verification.py deleted file mode 100644 index 6802b1b2..00000000 --- a/backend/app/models/verification.py +++ /dev/null @@ -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() - ) diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py deleted file mode 100644 index b44ee2f4..00000000 --- a/backend/app/schemas/comment.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/data_query.py b/backend/app/schemas/data_query.py deleted file mode 100644 index 0d44b183..00000000 --- a/backend/app/schemas/data_query.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/drug_shipment.py b/backend/app/schemas/drug_shipment.py new file mode 100644 index 00000000..be0e9055 --- /dev/null +++ b/backend/app/schemas/drug_shipment.py @@ -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) diff --git a/backend/app/schemas/finance.py b/backend/app/schemas/finance.py index 9f85239e..9b2a82ba 100644 --- a/backend/app/schemas/finance.py +++ b/backend/app/schemas/finance.py @@ -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 diff --git a/backend/app/schemas/finance_contract.py b/backend/app/schemas/finance_contract.py new file mode 100644 index 00000000..647caaef --- /dev/null +++ b/backend/app/schemas/finance_contract.py @@ -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) diff --git a/backend/app/schemas/finance_special.py b/backend/app/schemas/finance_special.py new file mode 100644 index 00000000..6dcfcefc --- /dev/null +++ b/backend/app/schemas/finance_special.py @@ -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) diff --git a/backend/app/schemas/imp.py b/backend/app/schemas/imp.py deleted file mode 100644 index 1a0e7f5e..00000000 --- a/backend/app/schemas/imp.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/issue.py b/backend/app/schemas/issue.py deleted file mode 100644 index bf798032..00000000 --- a/backend/app/schemas/issue.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/knowledge_note.py b/backend/app/schemas/knowledge_note.py new file mode 100644 index 00000000..038dd5e0 --- /dev/null +++ b/backend/app/schemas/knowledge_note.py @@ -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) diff --git a/backend/app/schemas/milestone.py b/backend/app/schemas/milestone.py deleted file mode 100644 index 574d0f78..00000000 --- a/backend/app/schemas/milestone.py +++ /dev/null @@ -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) diff --git a/backend/app/schemas/startup.py b/backend/app/schemas/startup.py new file mode 100644 index 00000000..a2d5b046 --- /dev/null +++ b/backend/app/schemas/startup.py @@ -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) diff --git a/backend/app/schemas/subject_history.py b/backend/app/schemas/subject_history.py new file mode 100644 index 00000000..24b61fc5 --- /dev/null +++ b/backend/app/schemas/subject_history.py @@ -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) diff --git a/backend/app/schemas/verification.py b/backend/app/schemas/verification.py deleted file mode 100644 index 587c21bf..00000000 --- a/backend/app/schemas/verification.py +++ /dev/null @@ -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) diff --git a/backend/app/services/imp.py b/backend/app/services/imp.py deleted file mode 100644 index cd82c026..00000000 --- a/backend/app/services/imp.py +++ /dev/null @@ -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 diff --git a/database/init.sql b/database/init.sql index 5bd95a78..3d9fb543 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1,60 +1,142 @@ --- --- PostgreSQL database dump --- +-- Idempotent schema for CTMS (derived from current SQLAlchemy models) --- Dumped from database version 15.15 --- Dumped by pg_dump version 15.15 +DO $$ +BEGIN + CREATE TYPE public.user_role AS ENUM ('ADMIN', 'PM', 'CRA', 'PV', 'IMP'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; -SET statement_timeout = 0; -SET lock_timeout = 0; -SET idle_in_transaction_session_timeout = 0; -SET client_encoding = 'UTF8'; -SET standard_conforming_strings = on; -SELECT pg_catalog.set_config('search_path', '', false); -SET check_function_bodies = false; -SET xmloption = content; -SET client_min_messages = warning; -SET row_security = off; +DO $$ +BEGIN + CREATE TYPE public.user_status AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; -SET default_tablespace = ''; - -SET default_table_access_method = heap; - --- --- Name: user_role; Type: TYPE; Schema: public; Owner: ctms_user --- - -CREATE TYPE public.user_role AS ENUM ( - 'ADMIN', - 'PM', - 'CRA', - 'PV', - 'IMP' +CREATE TABLE IF NOT EXISTS public.users ( + id uuid PRIMARY KEY, + email character varying(255) NOT NULL, + password_hash character varying(255) NOT NULL, + full_name character varying(255) NOT NULL, + role public.user_role NOT NULL, + department character varying(255) NOT NULL, + status public.user_status NOT NULL DEFAULT 'PENDING', + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + approved_by uuid, + approved_at timestamp with time zone, + avatar_url character varying(500), + CONSTRAINT uq_users_email UNIQUE (email) ); - -ALTER TYPE public.user_role OWNER TO ctms_user; - --- --- Name: user_status; Type: TYPE; Schema: public; Owner: ctms_user --- - -CREATE TYPE public.user_status AS ENUM ( - 'PENDING', - 'ACTIVE', - 'REJECTED', - 'DISABLED' +CREATE TABLE IF NOT EXISTS public.studies ( + id uuid PRIMARY KEY, + code character varying(50) NOT NULL, + name character varying(200) NOT NULL, + sponsor character varying(200), + protocol_no character varying(100), + phase character varying(50), + status character varying(20) NOT NULL DEFAULT 'DRAFT', + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_studies_code UNIQUE (code), + CONSTRAINT fk_studies_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); +CREATE TABLE IF NOT EXISTS public.sites ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + name character varying(200) NOT NULL, + city character varying(100), + pi_name character varying(100), + contact character varying(100), + is_active boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_sites_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT +); -ALTER TYPE public.user_status OWNER TO ctms_user; +CREATE TABLE IF NOT EXISTS public.study_members ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + user_id uuid NOT NULL, + role_in_study character varying(20) NOT NULL, + is_active boolean NOT NULL DEFAULT true, + added_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_study_member UNIQUE (study_id, user_id), + CONSTRAINT fk_study_members_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_study_members_user_id FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE RESTRICT +); --- --- Name: adverse_events; Type: TABLE; Schema: public; Owner: ctms_user --- +CREATE TABLE IF NOT EXISTS public.milestones ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + type character varying(50) NOT NULL, + name character varying(200) NOT NULL, + planned_date date, + actual_date date, + status character varying(20) NOT NULL DEFAULT 'NOT_STARTED', + site_id uuid, + owner_id uuid, + notes text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_milestones_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_milestones_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE RESTRICT, + CONSTRAINT fk_milestones_owner_id FOREIGN KEY (owner_id) REFERENCES public.users(id) ON DELETE RESTRICT +); -CREATE TABLE public.adverse_events ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.subjects ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + site_id uuid NOT NULL, + subject_no character varying(50) NOT NULL, + status character varying(20) NOT NULL DEFAULT 'SCREENING', + screening_date date, + enrollment_date date, + completion_date date, + drop_reason text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_subject_study_no UNIQUE (study_id, subject_no), + CONSTRAINT fk_subjects_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_subjects_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.subject_histories ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + subject_id uuid NOT NULL, + record_date date, + content text NOT NULL, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_subject_histories_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_subject_histories_subject_id FOREIGN KEY (subject_id) REFERENCES public.subjects(id) ON DELETE RESTRICT, + CONSTRAINT fk_subject_histories_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.visits ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + subject_id uuid NOT NULL, + visit_code character varying(50) NOT NULL, + visit_name character varying(200) NOT NULL, + planned_date date, + actual_date date, + status character varying(20) NOT NULL DEFAULT 'PLANNED', + window_start date, + window_end date, + notes text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_visits_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_visits_subject_id FOREIGN KEY (subject_id) REFERENCES public.subjects(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.adverse_events ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, site_id uuid, subject_id uuid, @@ -67,170 +149,53 @@ CREATE TABLE public.adverse_events ( causality character varying(50), action_taken text, outcome character varying(50), - reported_to_sponsor boolean NOT NULL, + reported_to_sponsor boolean NOT NULL DEFAULT false, report_due_date date, - status character varying(20) NOT NULL, + status character varying(20) NOT NULL DEFAULT 'NEW', description text, created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_adverse_events_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_adverse_events_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE RESTRICT, + CONSTRAINT fk_adverse_events_subject_id FOREIGN KEY (subject_id) REFERENCES public.subjects(id) ON DELETE RESTRICT, + CONSTRAINT fk_adverse_events_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.adverse_events OWNER TO ctms_user; - --- --- Name: attachments; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.attachments ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.finance_contracts ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - entity_type character varying(50) NOT NULL, - entity_id uuid NOT NULL, - filename character varying(255) NOT NULL, - file_path character varying(500) NOT NULL, - file_size integer NOT NULL, - content_type character varying(100), - uploaded_by uuid NOT NULL, - uploaded_at timestamp with time zone DEFAULT now() NOT NULL, - is_deleted boolean DEFAULT false NOT NULL + site_name character varying(255) NOT NULL, + contract_no character varying(100) NOT NULL, + signed_date date, + amount numeric(12, 2) NOT NULL, + currency character varying(10) NOT NULL DEFAULT 'CNY', + remark text, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_finance_contracts_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_contracts_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.attachments OWNER TO ctms_user; - --- --- Name: audit_logs; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.audit_logs ( - id uuid NOT NULL, - study_id uuid, - entity_type character varying(50) NOT NULL, - entity_id uuid, - action character varying(50) NOT NULL, - detail text, - operator_id uuid NOT NULL, - operator_role character varying(50) NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.audit_logs OWNER TO ctms_user; - --- --- Name: comments; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.comments ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.finance_specials ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - entity_type character varying(50) NOT NULL, - entity_id uuid NOT NULL, - content text NOT NULL, - quote_comment_id uuid, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - is_deleted boolean DEFAULT false NOT NULL + site_name character varying(255) NOT NULL, + fee_type character varying(50) NOT NULL, + amount numeric(12, 2) NOT NULL, + occur_date date, + staff_name character varying(100), + remark text, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_finance_specials_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_specials_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.comments OWNER TO ctms_user; - --- --- Name: data_queries; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.data_queries ( - id uuid NOT NULL, - study_id uuid NOT NULL, - site_id uuid, - subject_id uuid, - visit_id uuid, - title character varying(255) NOT NULL, - description text, - category character varying(50) NOT NULL, - priority character varying(20) NOT NULL, - assigned_to uuid, - due_date date, - status character varying(20) NOT NULL, - resolution text, - closed_at timestamp with time zone, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.data_queries OWNER TO ctms_user; - --- --- Name: faq_categories; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.faq_categories ( - id uuid NOT NULL, - study_id uuid, - name character varying(100) NOT NULL, - description text, - sort_order integer NOT NULL, - is_active boolean NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.faq_categories OWNER TO ctms_user; - --- --- Name: faq_items; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.faq_items ( - id uuid NOT NULL, - study_id uuid, - category_id uuid NOT NULL, - best_reply_id uuid, - status character varying(20) DEFAULT 'PENDING'::character varying NOT NULL, - resolved_by_confirm boolean DEFAULT false NOT NULL, - question text NOT NULL, - answer text NOT NULL, - keywords character varying(255), - version integer NOT NULL, - is_active boolean NOT NULL, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.faq_items OWNER TO ctms_user; - --- --- Name: faq_replies; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.faq_replies ( - id uuid NOT NULL, - faq_id uuid NOT NULL, - study_id uuid, - content text NOT NULL, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - quote_reply_id uuid, - is_deleted boolean DEFAULT false NOT NULL -); - - -ALTER TABLE public.faq_replies OWNER TO ctms_user; - --- --- Name: finance_items; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.finance_items ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.finance_items ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, site_id uuid, subject_id uuid, @@ -238,10 +203,10 @@ CREATE TABLE public.finance_items ( category character varying(50) NOT NULL, title character varying(255) NOT NULL, description text, - currency character varying(10) NOT NULL, - amount numeric(12,2) NOT NULL, + currency character varying(10) NOT NULL DEFAULT 'CNY', + amount numeric(12, 2) NOT NULL, occur_date date NOT NULL, - status character varying(20) NOT NULL, + status character varying(20) NOT NULL DEFAULT 'DRAFT', submitted_at timestamp with time zone, approved_at timestamp with time zone, rejected_at timestamp with time zone, @@ -250,1498 +215,447 @@ CREATE TABLE public.finance_items ( payer_id uuid, reject_reason text, created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_finance_items_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_items_site_id FOREIGN KEY (site_id) REFERENCES public.sites(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_items_subject_id FOREIGN KEY (subject_id) REFERENCES public.subjects(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_items_approver_id FOREIGN KEY (approver_id) REFERENCES public.users(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_items_payer_id FOREIGN KEY (payer_id) REFERENCES public.users(id) ON DELETE RESTRICT, + CONSTRAINT fk_finance_items_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.finance_items OWNER TO ctms_user; - --- --- Name: imp_batches; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.imp_batches ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.drug_shipments ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - product_id uuid NOT NULL, - batch_no character varying(100) NOT NULL, - expiry_date date, - manufacture_date date, - status character varying(20) NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.imp_batches OWNER TO ctms_user; - --- --- Name: imp_inventory; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.imp_inventory ( - id uuid NOT NULL, - study_id uuid NOT NULL, - site_id uuid NOT NULL, - batch_id uuid NOT NULL, - quantity_on_hand integer NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.imp_inventory OWNER TO ctms_user; - --- --- Name: imp_products; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.imp_products ( - id uuid NOT NULL, - study_id uuid NOT NULL, - name character varying(255) NOT NULL, - form character varying(100), - strength character varying(100), - unit character varying(50) NOT NULL, - description text, - is_active boolean NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.imp_products OWNER TO ctms_user; - --- --- Name: imp_transactions; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.imp_transactions ( - id uuid NOT NULL, - study_id uuid NOT NULL, - site_id uuid NOT NULL, - batch_id uuid NOT NULL, - subject_id uuid, - tx_type character varying(20) NOT NULL, - quantity integer NOT NULL, - tx_date date NOT NULL, - reference character varying(255), - notes text, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.imp_transactions OWNER TO ctms_user; - --- --- Name: issues; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.issues ( - id uuid NOT NULL, - study_id uuid NOT NULL, - site_id uuid, - subject_id uuid, - title character varying(255) NOT NULL, - description text, - category character varying(50) NOT NULL, - level character varying(20) NOT NULL, - owner_id uuid, - due_date date, - status character varying(20) NOT NULL, - capa text, - closed_at timestamp with time zone, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.issues OWNER TO ctms_user; - --- --- Name: milestones; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.milestones ( - id uuid NOT NULL, - study_id uuid NOT NULL, - type character varying(50) NOT NULL, - name character varying(200) NOT NULL, - planned_date date, - actual_date date, - status character varying(20) NOT NULL, - site_id uuid, - owner_id uuid, - notes text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.milestones OWNER TO ctms_user; - --- --- Name: sites; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.sites ( - id uuid NOT NULL, - study_id uuid NOT NULL, - name character varying(200) NOT NULL, - city character varying(100), - pi_name character varying(100), - contact character varying(100), - is_active boolean DEFAULT true NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - - -ALTER TABLE public.sites OWNER TO ctms_user; - --- --- Name: studies; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.studies ( - id uuid NOT NULL, - code character varying(50) NOT NULL, - name character varying(200) NOT NULL, - sponsor character varying(200), - protocol_no character varying(100), - phase character varying(50), - status character varying(20) NOT NULL, + direction character varying(20) NOT NULL, + site_name character varying(255) NOT NULL, + drug_desc text NOT NULL, + ship_date date, + carrier character varying(100), + tracking_no character varying(100), + from_party character varying(255), + to_party character varying(255), + status character varying(30) NOT NULL, + remark text, created_by uuid, - created_at timestamp with time zone DEFAULT now() NOT NULL + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_drug_shipments_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_drug_shipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.studies OWNER TO ctms_user; - --- --- Name: study_members; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.study_members ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.startup_feasibility ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - user_id uuid NOT NULL, - role_in_study character varying(20) NOT NULL, - is_active boolean DEFAULT true NOT NULL, - added_at timestamp with time zone DEFAULT now() NOT NULL + submit_date date, + accept_date date, + approved_date date, + project_no character varying(100), + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_startup_feasibility_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_startup_feasibility_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.study_members OWNER TO ctms_user; - --- --- Name: subjects; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.subjects ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.startup_ethics ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - site_id uuid NOT NULL, - subject_no character varying(50) NOT NULL, - status character varying(20) NOT NULL, - screening_date date, - enrollment_date date, - completion_date date, - drop_reason text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + submit_date date, + accept_date date, + meeting_date date, + approved_date date, + approval_no character varying(100), + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_startup_ethics_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_startup_ethics_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.subjects OWNER TO ctms_user; - --- --- --- Name: users; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.users ( - id uuid NOT NULL, - email character varying(255) NOT NULL, - password_hash character varying(255) NOT NULL, - full_name character varying(255) NOT NULL, - role public.user_role NOT NULL, - department character varying(255) NOT NULL, - status public.user_status DEFAULT 'PENDING'::public.user_status NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL, - approved_by uuid, - approved_at timestamp with time zone, - avatar_url character varying(500) -); - - -ALTER TABLE public.users OWNER TO ctms_user; - --- --- Name: verification_progress; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.verification_progress ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.kickoff_meetings ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - site_id uuid NOT NULL, - subject_id uuid NOT NULL, - level character varying(10) NOT NULL, - percent integer NOT NULL, - last_verified_at date, - verifier_id uuid, - notes text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + kickoff_date date, + attendees json, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_kickoff_meetings_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_kickoff_meetings_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.verification_progress OWNER TO ctms_user; - --- --- Name: visits; Type: TABLE; Schema: public; Owner: ctms_user --- - -CREATE TABLE public.visits ( - id uuid NOT NULL, +CREATE TABLE IF NOT EXISTS public.training_authorizations ( + id uuid PRIMARY KEY, study_id uuid NOT NULL, - subject_id uuid NOT NULL, - visit_code character varying(50) NOT NULL, - visit_name character varying(200) NOT NULL, - planned_date date, - actual_date date, - status character varying(20) NOT NULL, - window_start date, - window_end date, - notes text, - created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + name character varying(100) NOT NULL, + role character varying(100), + site_name character varying(255), + trained boolean NOT NULL DEFAULT false, + authorized boolean NOT NULL DEFAULT false, + trained_date date, + authorized_date date, + remark text, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_training_authorizations_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_training_authorizations_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); - -ALTER TABLE public.visits OWNER TO ctms_user; - --- --- Data for Name: adverse_events; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.adverse_events (id, study_id, site_id, subject_id, visit_id, term, onset_date, resolution_date, seriousness, severity, causality, action_taken, outcome, reported_to_sponsor, report_due_date, status, description, created_by, created_at, updated_at) FROM stdin; -aaaaaaa1-aaaa-aaaa-aaaa-aaaaaaaaaaa1 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 88888888-8888-8888-8888-888888888888 99999999-9999-9999-9999-999999999999 轻微头痛 2025-01-18 2025-01-19 NON_SERIOUS G1 RELATED 休息观察 RECOVERED f 2025-01-25 CLOSED 访视后出现轻微头痛,已恢复 33333333-3333-3333-3333-333333333333 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: attachments; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.attachments (id, study_id, entity_type, entity_id, filename, file_path, file_size, content_type, uploaded_by, uploaded_at, is_deleted) FROM stdin; -\. - - --- --- Data for Name: audit_logs; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.audit_logs (id, study_id, entity_type, entity_id, action, detail, operator_id, operator_role, created_at) FROM stdin; -99998888-7777-6666-5555-444433332222 44444444-4444-4444-4444-444444444444 study 44444444-4444-4444-4444-444444444444 CREATE 创建示例项目并导入 demo 数据 11111111-1111-1111-1111-111111111111 ADMIN 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: comments; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.comments (id, study_id, entity_type, entity_id, content, created_by, created_at, is_deleted) FROM stdin; -\. - - --- --- Data for Name: data_queries; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.data_queries (id, study_id, site_id, subject_id, visit_id, title, description, category, priority, assigned_to, due_date, status, resolution, closed_at, created_by, created_at, updated_at) FROM stdin; -ccccccc1-cccc-cccc-cccc-ccccccccccc1 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 88888888-8888-8888-8888-888888888888 99999999-9999-9999-9999-999999999999 血压录入缺失 V1 访视缺少舒张压 SAFETY HIGH 33333333-3333-3333-3333-333333333333 2025-01-22 OPEN \N \N 33333333-3333-3333-3333-333333333333 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: faq_categories; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.faq_categories (id, study_id, name, description, sort_order, is_active, created_at, updated_at) FROM stdin; -eeeeeee1-eeee-eeee-eeee-eeeeeeeeeee1 44444444-4444-4444-4444-444444444444 访视相关 访视常见问答 1 t 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: faq_items; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.faq_items (id, study_id, category_id, best_reply_id, status, resolved_by_confirm, question, answer, keywords, version, is_active, created_by, created_at, updated_at) FROM stdin; -fffffff1-ffff-ffff-ffff-fffffffffff1 44444444-4444-4444-4444-444444444444 eeeeeee1-eeee-eeee-eeee-eeeeeeeeeee1 \N PENDING f V1 访视需要携带什么? 携带受试者知情同意书、病历和药物记录本。 访视,携带物品 1 t 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: faq_replies; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.faq_replies (id, faq_id, study_id, content, created_by, created_at, quote_reply_id, is_deleted) FROM stdin; -\. - - --- --- Data for Name: finance_items; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.finance_items (id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at) FROM stdin; -11112222-3333-4444-5555-666677778888 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 88888888-8888-8888-8888-888888888888 99999999-9999-9999-9999-999999999999 SUBJECT_STIPEND V1 随访补贴 受试者 V1 访视补贴 CNY 200.00 2025-01-18 PAID 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 \N 2025-12-18 00:55:17.568504+00 22222222-2222-2222-2222-222222222222 11111111-1111-1111-1111-111111111111 \N 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: imp_batches; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.imp_batches (id, study_id, product_id, batch_no, expiry_date, manufacture_date, status, created_at, updated_at) FROM stdin; -abcd0000-1111-2222-3333-444455556667 44444444-4444-4444-4444-444444444444 abcd0000-1111-2222-3333-444455556666 BATCH-001 2026-12-31 2024-12-01 ACTIVE 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: imp_inventory; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.imp_inventory (id, study_id, site_id, batch_id, quantity_on_hand, updated_at) FROM stdin; -abcd0000-1111-2222-3333-444455556668 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 abcd0000-1111-2222-3333-444455556667 100 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: imp_products; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.imp_products (id, study_id, name, form, strength, unit, description, is_active, created_at, updated_at) FROM stdin; -abcd0000-1111-2222-3333-444455556666 44444444-4444-4444-4444-444444444444 DP-001 片剂 tablet 50mg mg IMP 示例产品 t 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: imp_transactions; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.imp_transactions (id, study_id, site_id, batch_id, subject_id, tx_type, quantity, tx_date, reference, notes, created_by, created_at) FROM stdin; -abcd0000-1111-2222-3333-444455556669 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 abcd0000-1111-2222-3333-444455556667 88888888-8888-8888-8888-888888888888 DISPENSE 2 2025-01-18 V1给药 基线访视发药 33333333-3333-3333-3333-333333333333 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: issues; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.issues (id, study_id, site_id, subject_id, title, description, category, level, owner_id, due_date, status, capa, closed_at, created_by, created_at, updated_at) FROM stdin; -bbbbbbb1-bbbb-bbbb-bbbb-bbbbbbbbbbb1 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 88888888-8888-8888-8888-888888888888 实验室报告延迟 实验室报告上传滞后 ISSUE MEDIUM 22222222-2222-2222-2222-222222222222 2025-01-25 OPEN 督促供应商提速 \N 22222222-2222-2222-2222-222222222222 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: milestones; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.milestones (id, study_id, type, name, planned_date, actual_date, status, site_id, owner_id, notes, created_at, updated_at) FROM stdin; -66666666-6666-6666-6666-666666666666 44444444-4444-4444-4444-444444444444 SIV 启动会 2025-01-10 2025-01-12 DONE 55555555-5555-5555-5555-555555555555 22222222-2222-2222-2222-222222222222 现场启动完成 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: sites; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.sites (id, study_id, name, city, pi_name, contact, is_active, created_at) FROM stdin; -55555555-5555-5555-5555-555555555555 44444444-4444-4444-4444-444444444444 北京协和医院 北京 李主任 010-12345678 t 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: studies; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.studies (id, code, name, sponsor, protocol_no, phase, status, created_by, created_at) FROM stdin; -44444444-4444-4444-4444-444444444444 STUDY-001 示例项目:糖尿病新药 Demo Pharma DP-001 Phase II ACTIVE 11111111-1111-1111-1111-111111111111 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: study_members; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.study_members (id, study_id, user_id, role_in_study, is_active, added_at) FROM stdin; -aaaa1111-2222-3333-4444-555566667777 44444444-4444-4444-4444-444444444444 11111111-1111-1111-1111-111111111111 ADMIN t 2025-12-18 00:55:17.568504+00 -bbbb1111-2222-3333-4444-555566667777 44444444-4444-4444-4444-444444444444 22222222-2222-2222-2222-222222222222 PM t 2025-12-18 00:55:17.568504+00 -cccc1111-2222-3333-4444-555566667777 44444444-4444-4444-4444-444444444444 33333333-3333-3333-3333-333333333333 CRA t 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: subjects; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.subjects (id, study_id, site_id, subject_no, status, screening_date, enrollment_date, completion_date, drop_reason, created_at, updated_at) FROM stdin; -88888888-8888-8888-8888-888888888888 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 SUBJ-001 ENROLLED 2025-01-15 2025-01-18 \N \N 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: users; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.users (id, email, password_hash, full_name, role, department, status, created_at, updated_at, approved_by, approved_at, avatar_url) FROM stdin; -11111111-1111-1111-1111-111111111111 admin@example.com $2b$12$wvADh3A4snBRZH9/24KWbO22h6mgE3TNquzD6cy0Zu7kKKnCouwzW System Admin ADMIN SYSTEM ACTIVE 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 \N \N \N -22222222-2222-2222-2222-222222222222 pm@example.com $2b$12$qbIguZoGxPDhczU98iWKE.01lpBFnrrw5kbcQdwLLw.oduq9feGcu Project Manager PM PMO ACTIVE 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 \N \N \N -33333333-3333-3333-3333-333333333333 cra@example.com $2b$12$3VtakMu1oJXLMOgu0ylNj.F6iqmIkRgP1XI51hzoIMwHccjeMW2nO CRA User CRA CRA ACTIVE 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 \N \N \N -\. - - --- --- Data for Name: verification_progress; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.verification_progress (id, study_id, site_id, subject_id, level, percent, last_verified_at, verifier_id, notes, created_at, updated_at) FROM stdin; -ddddddd1-dddd-dddd-dddd-ddddddddddd1 44444444-4444-4444-4444-444444444444 55555555-5555-5555-5555-555555555555 88888888-8888-8888-8888-888888888888 SDV 50 2025-01-19 33333333-3333-3333-3333-333333333333 已完成前50% SDV 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Data for Name: visits; Type: TABLE DATA; Schema: public; Owner: ctms_user --- - -COPY public.visits (id, study_id, subject_id, visit_code, visit_name, planned_date, actual_date, status, window_start, window_end, notes, created_at, updated_at) FROM stdin; -99999999-9999-9999-9999-999999999999 44444444-4444-4444-4444-444444444444 88888888-8888-8888-8888-888888888888 V1 基线访视 2025-01-18 2025-01-18 DONE 2025-01-16 2025-01-20 无偏差 2025-12-18 00:55:17.568504+00 2025-12-18 00:55:17.568504+00 -\. - - --- --- Name: adverse_events adverse_events_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.adverse_events - ADD CONSTRAINT adverse_events_pkey PRIMARY KEY (id); - - --- --- Name: attachments attachments_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.attachments - ADD CONSTRAINT attachments_pkey PRIMARY KEY (id); - - --- --- Name: audit_logs audit_logs_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.audit_logs - ADD CONSTRAINT audit_logs_pkey PRIMARY KEY (id); - - --- --- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.comments - ADD CONSTRAINT comments_pkey PRIMARY KEY (id); - - --- --- Name: data_queries data_queries_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.data_queries - ADD CONSTRAINT data_queries_pkey PRIMARY KEY (id); - - --- --- Name: faq_categories faq_categories_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_categories - ADD CONSTRAINT faq_categories_pkey PRIMARY KEY (id); - - --- --- Name: faq_items faq_items_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_items - ADD CONSTRAINT faq_items_pkey PRIMARY KEY (id); - - --- --- Name: faq_replies faq_replies_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_pkey PRIMARY KEY (id); - - --- --- Name: finance_items finance_items_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_pkey PRIMARY KEY (id); - - --- --- Name: imp_batches imp_batches_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_batches - ADD CONSTRAINT imp_batches_pkey PRIMARY KEY (id); - - --- --- Name: imp_inventory imp_inventory_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_inventory - ADD CONSTRAINT imp_inventory_pkey PRIMARY KEY (id); - - --- --- Name: imp_products imp_products_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_products - ADD CONSTRAINT imp_products_pkey PRIMARY KEY (id); - - --- --- Name: imp_transactions imp_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_transactions - ADD CONSTRAINT imp_transactions_pkey PRIMARY KEY (id); - - --- --- Name: issues issues_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.issues - ADD CONSTRAINT issues_pkey PRIMARY KEY (id); - - --- --- Name: milestones milestones_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.milestones - ADD CONSTRAINT milestones_pkey PRIMARY KEY (id); - - --- --- Name: sites sites_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.sites - ADD CONSTRAINT sites_pkey PRIMARY KEY (id); - - --- --- Name: studies studies_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.studies - ADD CONSTRAINT studies_pkey PRIMARY KEY (id); - - --- --- Name: study_members study_members_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.study_members - ADD CONSTRAINT study_members_pkey PRIMARY KEY (id); - - --- --- Name: subjects subjects_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.subjects - ADD CONSTRAINT subjects_pkey PRIMARY KEY (id); - - --- --- - - - --- --- Name: faq_categories uq_faq_category_name; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_categories - ADD CONSTRAINT uq_faq_category_name UNIQUE (study_id, name); - - --- --- Name: imp_batches uq_imp_batch_no; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_batches - ADD CONSTRAINT uq_imp_batch_no UNIQUE (study_id, batch_no, product_id); - - --- --- Name: imp_inventory uq_imp_inventory_site_batch; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_inventory - ADD CONSTRAINT uq_imp_inventory_site_batch UNIQUE (study_id, site_id, batch_id); - - --- --- Name: imp_products uq_imp_product_name; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_products - ADD CONSTRAINT uq_imp_product_name UNIQUE (study_id, name); - - --- --- Name: study_members uq_study_member; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.study_members - ADD CONSTRAINT uq_study_member UNIQUE (study_id, user_id); - - --- --- Name: subjects uq_subject_study_no; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.subjects - ADD CONSTRAINT uq_subject_study_no UNIQUE (study_id, subject_no); - - --- --- Name: verification_progress uq_verification_subject_level; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.verification_progress - ADD CONSTRAINT uq_verification_subject_level UNIQUE (study_id, subject_id, level); - - --- --- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.users - ADD CONSTRAINT users_pkey PRIMARY KEY (id); - - --- --- Name: verification_progress verification_progress_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.verification_progress - ADD CONSTRAINT verification_progress_pkey PRIMARY KEY (id); - - --- --- Name: visits visits_pkey; Type: CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.visits - ADD CONSTRAINT visits_pkey PRIMARY KEY (id); - - --- --- Name: ix_adverse_events_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_adverse_events_site_id ON public.adverse_events USING btree (site_id); - - --- --- Name: ix_adverse_events_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_adverse_events_study_id ON public.adverse_events USING btree (study_id); - - --- --- Name: ix_adverse_events_subject_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_adverse_events_subject_id ON public.adverse_events USING btree (subject_id); - - --- --- Name: ix_comments_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_comments_study_id ON public.comments USING btree (study_id); - - --- --- Name: ix_data_queries_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_data_queries_site_id ON public.data_queries USING btree (site_id); - - --- --- Name: ix_data_queries_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_data_queries_study_id ON public.data_queries USING btree (study_id); - - --- --- Name: ix_data_queries_subject_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_data_queries_subject_id ON public.data_queries USING btree (subject_id); - - --- --- Name: ix_faq_categories_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_faq_categories_study_id ON public.faq_categories USING btree (study_id); - - --- --- Name: ix_faq_items_category_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_faq_items_category_id ON public.faq_items USING btree (category_id); - --- --- Name: ix_faq_items_best_reply_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_faq_items_best_reply_id ON public.faq_items USING btree (best_reply_id); - - --- --- Name: ix_faq_items_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_faq_items_study_id ON public.faq_items USING btree (study_id); - - --- --- Name: ix_faq_replies_faq_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_faq_replies_faq_id ON public.faq_replies USING btree (faq_id); - - --- --- Name: ix_faq_replies_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_faq_replies_study_id ON public.faq_replies USING btree (study_id); - - --- --- Name: ix_finance_items_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_finance_items_site_id ON public.finance_items USING btree (site_id); - - --- --- Name: ix_finance_items_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_finance_items_study_id ON public.finance_items USING btree (study_id); - - --- --- Name: ix_finance_items_subject_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_finance_items_subject_id ON public.finance_items USING btree (subject_id); - - --- --- Name: ix_imp_batches_product_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_batches_product_id ON public.imp_batches USING btree (product_id); - - --- --- Name: ix_imp_batches_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_batches_study_id ON public.imp_batches USING btree (study_id); - - --- --- Name: ix_imp_inventory_batch_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_inventory_batch_id ON public.imp_inventory USING btree (batch_id); - - --- --- Name: ix_imp_inventory_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_inventory_site_id ON public.imp_inventory USING btree (site_id); - - --- --- Name: ix_imp_inventory_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_inventory_study_id ON public.imp_inventory USING btree (study_id); - - --- --- Name: ix_imp_products_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_products_study_id ON public.imp_products USING btree (study_id); - - --- --- Name: ix_imp_transactions_batch_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_transactions_batch_id ON public.imp_transactions USING btree (batch_id); - - --- --- Name: ix_imp_transactions_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_transactions_site_id ON public.imp_transactions USING btree (site_id); - - --- --- Name: ix_imp_transactions_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_imp_transactions_study_id ON public.imp_transactions USING btree (study_id); - - --- --- Name: ix_issues_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_issues_site_id ON public.issues USING btree (site_id); - - --- --- Name: ix_issues_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_issues_study_id ON public.issues USING btree (study_id); - - --- --- Name: ix_issues_subject_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_issues_subject_id ON public.issues USING btree (subject_id); - - --- --- Name: ix_milestones_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_milestones_study_id ON public.milestones USING btree (study_id); - - --- --- Name: ix_sites_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_sites_study_id ON public.sites USING btree (study_id); - - --- --- Name: ix_studies_code; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE UNIQUE INDEX ix_studies_code ON public.studies USING btree (code); - - --- --- Name: ix_study_members_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_study_members_study_id ON public.study_members USING btree (study_id); - - --- --- Name: ix_subjects_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_subjects_site_id ON public.subjects USING btree (site_id); - - --- --- Name: ix_subjects_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_subjects_study_id ON public.subjects USING btree (study_id); - - --- --- - - - --- --- Name: ix_users_email; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE UNIQUE INDEX ix_users_email ON public.users USING btree (email); - - --- --- Name: ix_verification_progress_site_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_verification_progress_site_id ON public.verification_progress USING btree (site_id); - - --- --- Name: ix_verification_progress_study_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_verification_progress_study_id ON public.verification_progress USING btree (study_id); - - --- --- Name: ix_verification_progress_subject_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_verification_progress_subject_id ON public.verification_progress USING btree (subject_id); - - --- --- Name: ix_visits_subject_id; Type: INDEX; Schema: public; Owner: ctms_user --- - -CREATE INDEX ix_visits_subject_id ON public.visits USING btree (subject_id); - - --- --- Name: adverse_events adverse_events_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.adverse_events - ADD CONSTRAINT adverse_events_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: adverse_events adverse_events_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.adverse_events - ADD CONSTRAINT adverse_events_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: adverse_events adverse_events_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.adverse_events - ADD CONSTRAINT adverse_events_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: adverse_events adverse_events_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.adverse_events - ADD CONSTRAINT adverse_events_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- Name: attachments attachments_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.attachments - ADD CONSTRAINT attachments_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: attachments attachments_uploaded_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.attachments - ADD CONSTRAINT attachments_uploaded_by_fkey FOREIGN KEY (uploaded_by) REFERENCES public.users(id); - - --- --- Name: audit_logs audit_logs_operator_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.audit_logs - ADD CONSTRAINT audit_logs_operator_id_fkey FOREIGN KEY (operator_id) REFERENCES public.users(id); - - --- --- Name: audit_logs audit_logs_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.audit_logs - ADD CONSTRAINT audit_logs_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: comments comments_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.comments - ADD CONSTRAINT comments_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: comments comments_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.comments - ADD CONSTRAINT comments_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: data_queries data_queries_assigned_to_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.data_queries - ADD CONSTRAINT data_queries_assigned_to_fkey FOREIGN KEY (assigned_to) REFERENCES public.users(id); - - --- --- Name: data_queries data_queries_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.data_queries - ADD CONSTRAINT data_queries_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: data_queries data_queries_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.data_queries - ADD CONSTRAINT data_queries_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: data_queries data_queries_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.data_queries - ADD CONSTRAINT data_queries_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: data_queries data_queries_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.data_queries - ADD CONSTRAINT data_queries_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- Name: faq_categories faq_categories_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_categories - ADD CONSTRAINT faq_categories_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: faq_items faq_items_category_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_items - ADD CONSTRAINT faq_items_category_id_fkey FOREIGN KEY (category_id) REFERENCES public.faq_categories(id); - --- --- Name: faq_items faq_items_best_reply_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_items - ADD CONSTRAINT faq_items_best_reply_id_fkey FOREIGN KEY (best_reply_id) REFERENCES public.faq_replies(id) ON DELETE SET NULL; - - --- --- Name: faq_items faq_items_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_items - ADD CONSTRAINT faq_items_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: faq_items faq_items_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_items - ADD CONSTRAINT faq_items_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: faq_replies faq_replies_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: faq_replies faq_replies_faq_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_faq_id_fkey FOREIGN KEY (faq_id) REFERENCES public.faq_items(id); - - --- --- Name: faq_replies faq_replies_quote_reply_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_quote_reply_id_fkey FOREIGN KEY (quote_reply_id) REFERENCES public.faq_replies(id); - - --- --- Name: faq_replies faq_replies_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: finance_items finance_items_approver_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_approver_id_fkey FOREIGN KEY (approver_id) REFERENCES public.users(id); - - --- --- Name: finance_items finance_items_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: finance_items finance_items_payer_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_payer_id_fkey FOREIGN KEY (payer_id) REFERENCES public.users(id); - - --- --- Name: finance_items finance_items_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: finance_items finance_items_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: finance_items finance_items_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.finance_items - ADD CONSTRAINT finance_items_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- Name: imp_batches imp_batches_product_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_batches - ADD CONSTRAINT imp_batches_product_id_fkey FOREIGN KEY (product_id) REFERENCES public.imp_products(id); - - --- --- Name: imp_batches imp_batches_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_batches - ADD CONSTRAINT imp_batches_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: imp_inventory imp_inventory_batch_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_inventory - ADD CONSTRAINT imp_inventory_batch_id_fkey FOREIGN KEY (batch_id) REFERENCES public.imp_batches(id); - - --- --- Name: imp_inventory imp_inventory_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_inventory - ADD CONSTRAINT imp_inventory_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: imp_inventory imp_inventory_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_inventory - ADD CONSTRAINT imp_inventory_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: imp_products imp_products_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_products - ADD CONSTRAINT imp_products_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: imp_transactions imp_transactions_batch_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_transactions - ADD CONSTRAINT imp_transactions_batch_id_fkey FOREIGN KEY (batch_id) REFERENCES public.imp_batches(id); - - --- --- Name: imp_transactions imp_transactions_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_transactions - ADD CONSTRAINT imp_transactions_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: imp_transactions imp_transactions_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_transactions - ADD CONSTRAINT imp_transactions_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: imp_transactions imp_transactions_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_transactions - ADD CONSTRAINT imp_transactions_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: imp_transactions imp_transactions_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.imp_transactions - ADD CONSTRAINT imp_transactions_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- Name: issues issues_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.issues - ADD CONSTRAINT issues_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: issues issues_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.issues - ADD CONSTRAINT issues_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES public.users(id); - - --- --- Name: issues issues_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.issues - ADD CONSTRAINT issues_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: issues issues_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.issues - ADD CONSTRAINT issues_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: issues issues_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.issues - ADD CONSTRAINT issues_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- Name: milestones milestones_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.milestones - ADD CONSTRAINT milestones_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES public.users(id); - --- --- Name: milestones milestones_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.milestones - ADD CONSTRAINT milestones_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: milestones milestones_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.milestones - ADD CONSTRAINT milestones_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: sites sites_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.sites - ADD CONSTRAINT sites_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: studies studies_created_by_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.studies - ADD CONSTRAINT studies_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); - - --- --- Name: study_members study_members_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.study_members - ADD CONSTRAINT study_members_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: study_members study_members_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.study_members - ADD CONSTRAINT study_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id); - - --- --- Name: subjects subjects_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.subjects - ADD CONSTRAINT subjects_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: subjects subjects_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.subjects - ADD CONSTRAINT subjects_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- - - - --- --- - - - --- --- - - - --- --- - - - --- --- Name: verification_progress verification_progress_site_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.verification_progress - ADD CONSTRAINT verification_progress_site_id_fkey FOREIGN KEY (site_id) REFERENCES public.sites(id); - - --- --- Name: verification_progress verification_progress_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.verification_progress - ADD CONSTRAINT verification_progress_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: verification_progress verification_progress_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.verification_progress - ADD CONSTRAINT verification_progress_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- Name: verification_progress verification_progress_verifier_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.verification_progress - ADD CONSTRAINT verification_progress_verifier_id_fkey FOREIGN KEY (verifier_id) REFERENCES public.users(id); - - --- --- Name: visits visits_study_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.visits - ADD CONSTRAINT visits_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - - --- --- Name: visits visits_subject_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: ctms_user --- - -ALTER TABLE ONLY public.visits - ADD CONSTRAINT visits_subject_id_fkey FOREIGN KEY (subject_id) REFERENCES public.subjects(id); - - --- --- PostgreSQL database dump complete --- +CREATE TABLE IF NOT EXISTS public.knowledge_notes ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + site_name character varying(255) NOT NULL, + title character varying(255) NOT NULL, + content text NOT NULL, + level character varying(50), + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_knowledge_notes_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_knowledge_notes_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.faq_categories ( + id uuid PRIMARY KEY, + study_id uuid, + name character varying(100) NOT NULL, + description text, + sort_order integer NOT NULL DEFAULT 0, + is_active boolean NOT NULL DEFAULT true, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_faq_category_name UNIQUE (study_id, name), + CONSTRAINT fk_faq_categories_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.faq_items ( + id uuid PRIMARY KEY, + study_id uuid, + category_id uuid NOT NULL, + best_reply_id uuid, + status character varying(20) NOT NULL DEFAULT 'PENDING', + resolved_by_confirm boolean NOT NULL DEFAULT false, + question text NOT NULL, + answer text NOT NULL, + keywords character varying(255), + version integer NOT NULL DEFAULT 1, + is_active boolean NOT NULL DEFAULT true, + created_by uuid NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_faq_items_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_faq_items_category_id FOREIGN KEY (category_id) REFERENCES public.faq_categories(id) ON DELETE RESTRICT, + CONSTRAINT fk_faq_items_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.faq_replies ( + id uuid PRIMARY KEY, + faq_id uuid NOT NULL, + study_id uuid, + content text NOT NULL, + created_by uuid NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + quote_reply_id uuid, + is_deleted boolean NOT NULL DEFAULT false, + CONSTRAINT fk_faq_replies_faq_id FOREIGN KEY (faq_id) REFERENCES public.faq_items(id) ON DELETE RESTRICT, + CONSTRAINT fk_faq_replies_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_faq_replies_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT, + CONSTRAINT fk_faq_replies_quote_reply_id FOREIGN KEY (quote_reply_id) REFERENCES public.faq_replies(id) ON DELETE RESTRICT +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_faq_items_best_reply_id' + ) THEN + ALTER TABLE public.faq_items + ADD CONSTRAINT fk_faq_items_best_reply_id + FOREIGN KEY (best_reply_id) REFERENCES public.faq_replies(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS public.attachments ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + entity_type character varying(50) NOT NULL, + entity_id uuid NOT NULL, + filename character varying(255) NOT NULL, + file_path character varying(500) NOT NULL, + file_size integer NOT NULL, + content_type character varying(100), + uploaded_by uuid NOT NULL, + uploaded_at timestamp with time zone NOT NULL DEFAULT now(), + is_deleted boolean NOT NULL DEFAULT false, + CONSTRAINT fk_attachments_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_attachments_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id uuid PRIMARY KEY, + study_id uuid, + entity_type character varying(50) NOT NULL, + entity_id uuid, + action character varying(50) NOT NULL, + detail text, + operator_id uuid NOT NULL, + operator_role character varying(50) NOT NULL, + created_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_audit_logs_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_audit_logs_operator_id FOREIGN KEY (operator_id) REFERENCES public.users(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS ix_sites_study_id ON public.sites (study_id); +CREATE INDEX IF NOT EXISTS ix_study_members_study_id ON public.study_members (study_id); +CREATE INDEX IF NOT EXISTS ix_milestones_study_id ON public.milestones (study_id); +CREATE INDEX IF NOT EXISTS ix_subjects_study_id ON public.subjects (study_id); +CREATE INDEX IF NOT EXISTS ix_subjects_site_id ON public.subjects (site_id); +CREATE INDEX IF NOT EXISTS ix_subject_histories_study_id ON public.subject_histories (study_id); +CREATE INDEX IF NOT EXISTS ix_subject_histories_subject_id ON public.subject_histories (subject_id); +CREATE INDEX IF NOT EXISTS ix_visits_subject_id ON public.visits (subject_id); +CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (study_id); +CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id); +CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id); +CREATE INDEX IF NOT EXISTS ix_finance_contracts_study_id ON public.finance_contracts (study_id); +CREATE INDEX IF NOT EXISTS ix_finance_specials_study_id ON public.finance_specials (study_id); +CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id); +CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id); +CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id); +CREATE INDEX IF NOT EXISTS ix_drug_shipments_study_id ON public.drug_shipments (study_id); +CREATE INDEX IF NOT EXISTS ix_startup_feasibility_study_id ON public.startup_feasibility (study_id); +CREATE INDEX IF NOT EXISTS ix_startup_ethics_study_id ON public.startup_ethics (study_id); +CREATE INDEX IF NOT EXISTS ix_kickoff_meetings_study_id ON public.kickoff_meetings (study_id); +CREATE INDEX IF NOT EXISTS ix_training_authorizations_study_id ON public.training_authorizations (study_id); +CREATE INDEX IF NOT EXISTS ix_knowledge_notes_study_id ON public.knowledge_notes (study_id); +CREATE INDEX IF NOT EXISTS ix_faq_categories_study_id ON public.faq_categories (study_id); +CREATE INDEX IF NOT EXISTS ix_faq_items_study_id ON public.faq_items (study_id); +CREATE INDEX IF NOT EXISTS ix_faq_items_category_id ON public.faq_items (category_id); +CREATE INDEX IF NOT EXISTS ix_faq_replies_faq_id ON public.faq_replies (faq_id); +CREATE INDEX IF NOT EXISTS ix_faq_replies_study_id ON public.faq_replies (study_id); + +-- ========================================= +-- DEMO SEED DATA (idempotent) +-- ========================================= +INSERT INTO public.users ( + id, email, password_hash, full_name, role, department, status, created_at, updated_at +) VALUES + ('11111111-1111-1111-1111-111111111111', 'admin@example.com', '$2y$12$FMfyBt7YfZWeh/x8WMEIA.S6VUTIMryk2NxWHyOOuJvd6YrDwHAdu', 'System Admin', 'ADMIN', 'SYSTEM', 'ACTIVE', '2025-01-05 08:00:00+00', '2025-01-05 08:00:00+00'), + ('22222222-2222-2222-2222-222222222222', 'pm@example.com', '$2y$12$P9UDKu48ru84uVrlquqhXufmw/CfMEzQosC0X8eT2EmY/PgM/03j2', '项目经理', 'PM', 'PMO', 'ACTIVE', '2025-01-05 08:10:00+00', '2025-01-05 08:10:00+00'), + ('33333333-3333-3333-3333-333333333333', 'cra@example.com', '$2y$12$D/.ZeDrUByPZFPnAl9MnKuzKn2G4ctKQ9IAS/CqS2sfMdE/5NaKAK', 'CRA 用户', 'CRA', 'CRA', 'ACTIVE', '2025-01-05 08:20:00+00', '2025-01-05 08:20:00+00') +ON CONFLICT (email) DO UPDATE SET + password_hash = EXCLUDED.password_hash, + full_name = EXCLUDED.full_name, + role = EXCLUDED.role, + department = EXCLUDED.department, + status = EXCLUDED.status, + updated_at = EXCLUDED.updated_at; + +INSERT INTO public.studies ( + id, code, name, sponsor, protocol_no, phase, status, created_by, created_at +) VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'DEMO-CTMS', + '示例项目:糖尿病新药', + 'Demo Pharma', + 'DP-001', + 'Phase II', + 'ACTIVE', + (SELECT id FROM public.users WHERE email = 'admin@example.com'), + '2025-01-06 08:00:00+00' +) ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + sponsor = EXCLUDED.sponsor, + protocol_no = EXCLUDED.protocol_no, + phase = EXCLUDED.phase, + status = EXCLUDED.status, + created_by = EXCLUDED.created_by; + +INSERT INTO public.sites ( + id, study_id, name, city, pi_name, contact, is_active, created_at +) VALUES + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '北京', '李主任', '010-12345678', true, '2025-01-06 09:00:00+00'), + ('cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', '上海', '王主任', '021-12345678', true, '2025-01-06 09:10:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.study_members ( + id, study_id, user_id, role_in_study, is_active, added_at +) VALUES + ('dddddddd-dddd-dddd-dddd-dddddddddddd', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'admin@example.com'), 'ADMIN', true, '2025-01-06 10:00:00+00'), + ('eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'pm@example.com'), 'PM', true, '2025-01-06 10:05:00+00'), + ('ffffffff-ffff-ffff-ffff-ffffffffffff', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), (SELECT id FROM public.users WHERE email = 'cra@example.com'), 'CRA', true, '2025-01-06 10:10:00+00') +ON CONFLICT (study_id, user_id) DO UPDATE SET + role_in_study = EXCLUDED.role_in_study, + is_active = EXCLUDED.is_active; + +INSERT INTO public.milestones ( + id, study_id, type, name, planned_date, actual_date, status, site_id, owner_id, notes, created_at, updated_at +) VALUES + ('10101010-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'SIV', '启动会', '2025-01-10', '2025-01-12', 'DONE', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '现场启动完成', '2025-01-06 12:00:00+00', '2025-01-12 10:00:00+00'), + ('20202020-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'LPI', '首例入组', '2025-02-01', NULL, 'NOT_STARTED', 'cccccccc-cccc-cccc-cccc-cccccccccccc', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '待完成', '2025-01-06 12:10:00+00', '2025-01-06 12:10:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.finance_contracts ( + id, study_id, site_name, contract_no, signed_date, amount, currency, remark, created_by, created_at, updated_at +) VALUES + ('77777777-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', 'CON-001', '2025-01-08', 1200000.00, 'CNY', '首批合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-08 09:00:00+00', '2025-01-08 09:00:00+00'), + ('77777777-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', 'CON-002', '2025-01-10', 980000.00, 'CNY', '分中心合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 09:00:00+00', '2025-01-10 09:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.finance_specials ( + id, study_id, site_name, fee_type, amount, occur_date, staff_name, remark, created_by, created_at, updated_at +) VALUES + ('88888888-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '差旅', 5200.00, '2025-01-15', '赵敏', 'SIV 差旅费', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-15 10:00:00+00', '2025-01-15 10:00:00+00'), + ('88888888-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', '住宿', 3200.00, '2025-01-18', '李雷', '监查住宿费', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-18 10:00:00+00', '2025-01-18 10:00:00+00'), + ('88888888-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '交通', 860.00, '2025-01-20', '韩梅梅', '市内交通费', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 10:00:00+00', '2025-01-20 10:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.finance_items ( + id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at +) VALUES + ('99999999-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', NULL, NULL, 'SITE_FEE', '启动费', '中心启动费用', 'CNY', 30000.00, '2025-01-12', 'PAID', '2025-01-13 08:00:00+00', '2025-01-13 10:00:00+00', NULL, '2025-01-15 09:00:00+00', (SELECT id FROM public.users WHERE email = 'pm@example.com'), (SELECT id FROM public.users WHERE email = 'admin@example.com'), NULL, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 09:00:00+00', '2025-01-15 09:00:00+00'), + ('99999999-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', NULL, NULL, 'TRAVEL', '监查差旅', 'CRA 监查差旅报销', 'CNY', 4800.00, '2025-01-20', 'APPROVED', '2025-01-21 08:00:00+00', '2025-01-21 10:00:00+00', NULL, NULL, (SELECT id FROM public.users WHERE email = 'pm@example.com'), NULL, NULL, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 10:00:00+00', '2025-01-21 10:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.drug_shipments ( + id, study_id, direction, site_name, drug_desc, ship_date, carrier, tracking_no, from_party, to_party, status, remark, created_by, created_at, updated_at +) VALUES + ('aaaa1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', '北京协和医院', 'DP-001 片剂 50mg × 200 盒', '2025-01-12', '顺丰', 'SF100001', '中央库房', '北京协和医院药房', '运输中', '首批寄送', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 11:00:00+00', '2025-01-12 11:00:00+00'), + ('bbbb1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '寄送', '上海瑞金医院', 'DP-001 片剂 50mg × 150 盒', '2025-01-13', '京东物流', 'JD200002', '中央库房', '上海瑞金医院药房', '已签收', '分中心补货', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-14 09:00:00+00'), + ('cccc1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', '北京协和医院', '空药盒及剩余药品回收', '2025-02-05', '顺丰', 'SF100003', '北京协和医院药房', '中央库房', '已回收', '首批回收', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-05 14:00:00+00', '2025-02-06 09:00:00+00'), + ('dddd1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收', '上海瑞金医院', '冷链箱回收', '2025-02-08', '德邦', 'DB300004', '上海瑞金医院药房', '中央库房', '运输中', '回收途中', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-08 16:00:00+00', '2025-02-08 16:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.startup_feasibility ( + id, study_id, submit_date, accept_date, approved_date, project_no, created_by, created_at, updated_at +) VALUES + ('eeee1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '2025-01-02', '2025-01-05', '2025-01-10', 'FEA-BJ-001', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), + ('ffff1111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '2025-01-03', '2025-01-06', '2025-01-12', 'FEA-SH-002', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.startup_ethics ( + id, study_id, submit_date, accept_date, meeting_date, approved_date, approval_no, created_by, created_at, updated_at +) VALUES + ('1111aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '2025-01-04', '2025-01-07', '2025-01-15', '2025-01-20', 'ETH-BJ-001', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 11:00:00+00', '2025-01-20 11:00:00+00'), + ('2222aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '2025-01-05', '2025-01-08', '2025-01-16', '2025-01-22', 'ETH-SH-002', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-22 11:00:00+00', '2025-01-22 11:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.kickoff_meetings ( + id, study_id, kickoff_date, attendees, created_by, created_at, updated_at +) VALUES ( + '3333aaaa-2222-3333-4444-555555555555', + (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), + '2025-01-12', + '["张三","李四","王五"]'::json, + (SELECT id FROM public.users WHERE email = 'pm@example.com'), + '2025-01-12 13:00:00+00', + '2025-01-12 13:00:00+00' +) ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.training_authorizations ( + id, study_id, name, role, site_name, trained, authorized, trained_date, authorized_date, remark, created_by, created_at, updated_at +) VALUES + ('4444bbbb-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '赵敏', '研究医生', '北京协和医院', true, false, '2025-01-12', NULL, '待授权', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 15:00:00+00', '2025-01-12 15:00:00+00'), + ('5555bbbb-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '李雷', 'CRC', '上海瑞金医院', false, true, NULL, '2025-01-18', '先授权后补训', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-18 15:00:00+00', '2025-01-18 15:00:00+00'), + ('6666bbbb-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '韩梅梅', '药房管理员', '北京协和医院', true, true, '2025-01-10', '2025-01-12', '已完成', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 16:00:00+00', '2025-01-12 16:00:00+00'), + ('7777bbbb-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '王强', 'CRA', '上海瑞金医院', false, false, NULL, NULL, '待安排', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 16:00:00+00', '2025-01-20 16:00:00+00'), + ('8888bbbb-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '刘晨', '研究护士', '北京协和医院', true, false, '2025-01-14', NULL, '仅完成培训', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-14 16:00:00+00', '2025-01-14 16:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.subjects ( + id, study_id, site_id, subject_no, status, screening_date, enrollment_date, completion_date, drop_reason, created_at, updated_at +) VALUES + ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), + ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'), + ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00') +ON CONFLICT (study_id, subject_no) DO UPDATE SET + site_id = EXCLUDED.site_id, + status = EXCLUDED.status, + screening_date = EXCLUDED.screening_date, + enrollment_date = EXCLUDED.enrollment_date, + completion_date = EXCLUDED.completion_date, + drop_reason = EXCLUDED.drop_reason, + updated_at = EXCLUDED.updated_at; + +INSERT INTO public.subject_histories ( + id, study_id, subject_id, record_date, content, created_by, created_at, updated_at +) VALUES + ('55555555-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', '2025-01-08', '既往有高血压病史,长期服药控制。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-08 11:00:00+00', '2025-01-08 11:00:00+00'), + ('55555555-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', '2025-01-13', '糖耐量异常,需进一步评估。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-13 11:00:00+00', '2025-01-13 11:00:00+00'), + ('55555555-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', '2025-01-02', '既往病史稳定,未见严重合并症。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-02 11:00:00+00', '2025-01-02 11:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.visits ( + id, study_id, subject_id, visit_code, visit_name, planned_date, actual_date, status, window_start, window_end, notes, created_at, updated_at +) VALUES + ('44444444-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V1', '基线访视', '2025-01-09', '2025-01-10', 'DONE', '2025-01-07', '2025-01-12', '按期完成', '2025-01-10 12:00:00+00', '2025-01-10 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '11111111-2222-3333-4444-555555555555', 'V2', '随访访视', '2025-02-09', NULL, 'PLANNED', '2025-02-07', '2025-02-12', NULL, '2025-01-20 12:00:00+00', '2025-01-20 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', 'V1', '筛选访视', '2025-01-15', NULL, 'PLANNED', '2025-01-13', '2025-01-18', NULL, '2025-01-15 12:00:00+00', '2025-01-15 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-444444444444', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '22222222-3333-4444-5555-666666666666', 'V2', '基线访视', '2025-01-22', NULL, 'PLANNED', '2025-01-20', '2025-01-25', NULL, '2025-01-22 12:00:00+00', '2025-01-22 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', 'V1', '筛选访视', '2024-12-22', '2024-12-22', 'DONE', '2024-12-20', '2024-12-25', '完成筛选', '2024-12-22 12:00:00+00', '2024-12-22 12:00:00+00'), + ('44444444-aaaa-bbbb-cccc-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '33333333-4444-5555-6666-777777777777', 'V2', '随访访视', '2025-01-22', '2025-01-23', 'DONE', '2025-01-20', '2025-01-25', '随访完成', '2025-01-23 12:00:00+00', '2025-01-23 12:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.adverse_events ( + id, study_id, site_id, subject_id, visit_id, term, onset_date, resolution_date, seriousness, severity, causality, action_taken, outcome, reported_to_sponsor, report_due_date, status, description, created_by, created_at, updated_at +) VALUES + ('66666666-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', '44444444-aaaa-bbbb-cccc-111111111111', '轻微头痛', '2025-01-10', '2025-01-11', 'NON_SERIOUS', 'MILD', '可能相关', '休息观察', '恢复', true, '2025-01-20', 'CLOSED', '受试者轻微头痛,次日缓解。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-10 13:00:00+00', '2025-01-11 09:00:00+00'), + ('66666666-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', '22222222-3333-4444-5555-666666666666', NULL, '血糖升高', '2025-01-18', NULL, 'SERIOUS', 'SEVERE', '相关', '调整用药', '未恢复', false, '2025-01-28', 'NEW', '筛选期血糖异常。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-18 13:00:00+00', '2025-01-18 13:00:00+00'), + ('66666666-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-2222-3333-4444-555555555555', NULL, '轻度皮疹', '2025-02-10', NULL, 'NON_SERIOUS', 'MODERATE', '待评估', NULL, '未恢复', false, '2025-02-20', 'FOLLOW_UP', '出现皮疹,持续观察。', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-02-10 13:00:00+00', '2025-02-10 13:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.knowledge_notes ( + id, study_id, site_name, title, content, level, created_by, created_at, updated_at +) VALUES + ('4444aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '样本采集注意', '采血需在早晨空腹完成,样本 2 小时内送达。', '高', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 18:00:00+00', '2025-01-12 18:00:00+00'), + ('5555aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', '访视资料上传', '访视完成后 24 小时内完成资料上传与核对。', '中', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-15 18:00:00+00', '2025-01-15 18:00:00+00'), + ('6666aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '冷链运输提醒', '冷链记录必须随药品一起封存。', '低', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-18 18:00:00+00', '2025-01-18 18:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.faq_categories ( + id, study_id, name, description, sort_order, is_active, created_at, updated_at +) VALUES + ('7777aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '访视相关', '访视常见问题', 1, true, '2025-01-10 08:00:00+00', '2025-01-10 08:00:00+00'), + ('8888aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '用药相关', '用药与回收问题', 2, true, '2025-01-10 08:10:00+00', '2025-01-10 08:10:00+00') +ON CONFLICT (study_id, name) DO UPDATE SET + description = EXCLUDED.description, + sort_order = EXCLUDED.sort_order, + is_active = EXCLUDED.is_active, + updated_at = EXCLUDED.updated_at; + +INSERT INTO public.faq_items ( + id, study_id, category_id, best_reply_id, status, resolved_by_confirm, question, answer, keywords, version, is_active, created_by, created_at, updated_at +) VALUES + ('9999aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '7777aaaa-2222-3333-4444-555555555555', NULL, 'PENDING', false, 'V1 访视需要携带什么?', '携带知情同意书、病历及用药记录本。', '访视,携带物品', 1, true, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 09:00:00+00', '2025-01-10 09:00:00+00'), + ('aaaa9999-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '7777aaaa-2222-3333-4444-555555555555', NULL, 'PENDING', false, '访视延迟是否需要重新排期?', '若延迟超过窗口期需重新安排并备注原因。', '访视,窗口期', 1, true, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-11 09:00:00+00', '2025-01-11 09:00:00+00'), + ('bbbb9999-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '8888aaaa-2222-3333-4444-555555555555', NULL, 'PENDING', false, '药品回收需要哪些材料?', '需提供回收清单、交接单及冷链记录。', '回收,交接单', 1, true, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 09:00:00+00', '2025-01-12 09:00:00+00'), + ('cccc9999-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '8888aaaa-2222-3333-4444-555555555555', NULL, 'PENDING', false, '药品签收后如何登记?', '签收后在药品流向中登记并上传快递单。', '药品,签收', 1, true, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-13 09:00:00+00', '2025-01-13 09:00:00+00'), + ('dddd9999-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '7777aaaa-2222-3333-4444-555555555555', NULL, 'PENDING', false, '随访资料上传时间要求?', '访视结束 24 小时内上传。', '随访,资料上传', 1, true, (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-14 09:00:00+00', '2025-01-14 09:00:00+00') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.faq_replies ( + id, faq_id, study_id, content, created_by, created_at, quote_reply_id, is_deleted +) VALUES + ('eeee9999-2222-3333-4444-555555555555', '9999aaaa-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '如有特殊情况请提前沟通 PM。', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 10:00:00+00', NULL, false), + ('ffff9999-2222-3333-4444-555555555555', 'bbbb9999-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '回收材料可在下载中心获取模板。', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 10:00:00+00', NULL, false) +ON CONFLICT (id) DO NOTHING; + +UPDATE public.faq_items +SET best_reply_id = 'eeee9999-2222-3333-4444-555555555555' +WHERE id = '9999aaaa-2222-3333-4444-555555555555' + AND best_reply_id IS NULL; + +UPDATE public.faq_items +SET best_reply_id = 'ffff9999-2222-3333-4444-555555555555' +WHERE id = 'bbbb9999-2222-3333-4444-555555555555' + AND best_reply_id IS NULL; + +INSERT INTO public.attachments ( + id, study_id, entity_type, entity_id, filename, file_path, file_size, content_type, uploaded_by, uploaded_at, is_deleted +) VALUES + ('aaaa0000-1111-2222-3333-444455556666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'finance_contract', '77777777-aaaa-bbbb-cccc-111111111111', 'contract_beijing.pdf', '/code/app/uploads/study_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/finance_contract_77777777-aaaa-bbbb-cccc-111111111111/contract_beijing.pdf', 1024, 'application/pdf', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-08 10:00:00+00', false), + ('bbbb0000-1111-2222-3333-444455556666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'drug_shipment', 'aaaa1111-2222-3333-4444-555555555555', 'shipment_beijing.pdf', '/code/app/uploads/study_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/drug_shipment_aaaa1111-2222-3333-4444-555555555555/shipment_beijing.pdf', 2048, 'application/pdf', (SELECT id FROM public.users WHERE email = 'cra@example.com'), '2025-01-12 12:00:00+00', false), + ('cccc0000-1111-2222-3333-444455556666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'startup_ethics', '1111aaaa-2222-3333-4444-555555555555', 'ethics_beijing.pdf', '/code/app/uploads/study_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/startup_ethics_1111aaaa-2222-3333-4444-555555555555/ethics_beijing.pdf', 1536, 'application/pdf', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 12:00:00+00', false), + ('dddd0000-1111-2222-3333-444455556666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'knowledge_note', '4444aaaa-2222-3333-4444-555555555555', 'note_sample.txt', '/code/app/uploads/study_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/knowledge_note_4444aaaa-2222-3333-4444-555555555555/note_sample.txt', 512, 'text/plain', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-12 19:00:00+00', false) +ON CONFLICT (id) DO NOTHING; diff --git a/frontend/src/api/aes.ts b/frontend/src/api/aes.ts index 85ccb56a..acb72492 100644 --- a/frontend/src/api/aes.ts +++ b/frontend/src/api/aes.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPatch } from "./axios"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import type { ApiListResponse } from "../types/api"; export const fetchAes = (studyId: string, params?: Record) => @@ -9,3 +9,6 @@ export const createAe = (studyId: string, payload: Record) => export const updateAe = (studyId: string, aeId: string, payload: Record) => apiPatch(`/api/v1/studies/${studyId}/aes/${aeId}`, payload); + +export const deleteAe = (studyId: string, aeId: string) => + apiDelete(`/api/v1/studies/${studyId}/aes/${aeId}`); diff --git a/frontend/src/api/comments.ts b/frontend/src/api/comments.ts deleted file mode 100644 index 88ed790f..00000000 --- a/frontend/src/api/comments.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { apiGet, apiPost, apiDelete } from "./axios"; - -export const fetchComments = (studyId: string, entityType: string, entityId: string) => - apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`); - -export const createComment = ( - studyId: string, - entityType: string, - entityId: string, - payload: Record -) => apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`, payload); - -export const deleteComment = (studyId: string, entityType: string, entityId: string, commentId: string) => - apiDelete(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments/${commentId}`); diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 525e2894..e6fbe845 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -9,6 +9,3 @@ export const fetchFinanceSummary = (studyId: string) => export const fetchOverdueAesCount = (studyId: string) => apiGet>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } }); - -export const fetchOverdueQueriesCount = (studyId: string) => - apiGet>(`/api/v1/studies/${studyId}/data-queries/`, { params: { overdue: true, limit: 1 } }); diff --git a/frontend/src/api/dataQueries.ts b/frontend/src/api/dataQueries.ts deleted file mode 100644 index b72992b7..00000000 --- a/frontend/src/api/dataQueries.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { apiGet, apiPost, apiPatch } from "./axios"; -import type { ApiListResponse } from "../types/api"; - -export const fetchDataQueries = (studyId: string, params?: Record) => - apiGet>(`/api/v1/studies/${studyId}/data-queries/`, { params }); - -export const createDataQuery = (studyId: string, payload: Record) => - apiPost(`/api/v1/studies/${studyId}/data-queries/`, payload); - -export const updateDataQuery = (studyId: string, queryId: string, payload: Record) => - apiPatch(`/api/v1/studies/${studyId}/data-queries/${queryId}`, payload); diff --git a/frontend/src/api/drugShipments.ts b/frontend/src/api/drugShipments.ts new file mode 100644 index 00000000..9b832b79 --- /dev/null +++ b/frontend/src/api/drugShipments.ts @@ -0,0 +1,16 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listDrugShipments = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/drug/shipments`, { params }); + +export const getDrugShipment = (studyId: string, shipmentId: string) => + apiGet(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`); + +export const createDrugShipment = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/drug/shipments`, payload); + +export const updateDrugShipment = (studyId: string, shipmentId: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`, payload); + +export const deleteDrugShipment = (studyId: string, shipmentId: string) => + apiDelete(`/api/v1/studies/${studyId}/drug/shipments/${shipmentId}`); diff --git a/frontend/src/api/finance.ts b/frontend/src/api/finance.ts deleted file mode 100644 index 705706da..00000000 --- a/frontend/src/api/finance.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { apiGet, apiPatch, apiPost } from "./axios"; -import type { AxiosResponse } from "axios"; -import type { ApiListResponse } from "../types/api"; - -export interface FinanceItem { - id: string; - study_id: string; - site_id?: string | null; - subject_id?: string | null; - visit_id?: string | null; - category: string; - title: string; - description?: string | null; - currency: string; - amount: number | string; - occur_date: string; - status: string; - submitted_at?: string | null; - approved_at?: string | null; - rejected_at?: string | null; - paid_at?: string | null; - approver_id?: string | null; - payer_id?: string | null; - reject_reason?: string | null; - created_by: string; - created_at: string; - updated_at?: string | null; -} - -export interface FinanceSummary { - total_amount: number | string; - approved_amount: number | string; - paid_amount: number | string; - submitted_amount?: number | string; - count_total: number; - count_paid: number; -} - -export const fetchFinanceItems = ( - studyId: string, - params?: Record -): Promise>> => - apiGet(`/api/v1/studies/${studyId}/finance/items/`, { params }); - -export const fetchFinanceItem = (studyId: string, itemId: string) => - apiGet(`/api/v1/studies/${studyId}/finance/items/${itemId}`); - -export const createFinanceItem = (studyId: string, payload: Record) => - apiPost(`/api/v1/studies/${studyId}/finance/items`, payload); - -export const updateFinanceItem = (studyId: string, itemId: string, payload: Record) => - apiPatch(`/api/v1/studies/${studyId}/finance/items/${itemId}`, payload); - -export const changeFinanceStatus = ( - studyId: string, - itemId: string, - payload: { status: string; reject_reason?: string } -) => apiPost(`/api/v1/studies/${studyId}/finance/items/${itemId}/status`, payload); - -export const fetchFinanceSummary = (studyId: string, params?: Record) => - apiGet>( - `/api/v1/studies/${studyId}/finance/summary`, - { params } - ); diff --git a/frontend/src/api/financeContracts.ts b/frontend/src/api/financeContracts.ts new file mode 100644 index 00000000..0073c6a3 --- /dev/null +++ b/frontend/src/api/financeContracts.ts @@ -0,0 +1,16 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listFinanceContracts = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/finance/contracts`, { params }); + +export const getFinanceContract = (studyId: string, contractId: string) => + apiGet(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`); + +export const createFinanceContract = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/finance/contracts`, payload); + +export const updateFinanceContract = (studyId: string, contractId: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`, payload); + +export const deleteFinanceContract = (studyId: string, contractId: string) => + apiDelete(`/api/v1/studies/${studyId}/finance/contracts/${contractId}`); diff --git a/frontend/src/api/financeSpecials.ts b/frontend/src/api/financeSpecials.ts new file mode 100644 index 00000000..1e54f4df --- /dev/null +++ b/frontend/src/api/financeSpecials.ts @@ -0,0 +1,16 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listFinanceSpecials = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/finance/specials`, { params }); + +export const getFinanceSpecial = (studyId: string, specialId: string) => + apiGet(`/api/v1/studies/${studyId}/finance/specials/${specialId}`); + +export const createFinanceSpecial = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/finance/specials`, payload); + +export const updateFinanceSpecial = (studyId: string, specialId: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/finance/specials/${specialId}`, payload); + +export const deleteFinanceSpecial = (studyId: string, specialId: string) => + apiDelete(`/api/v1/studies/${studyId}/finance/specials/${specialId}`); diff --git a/frontend/src/api/impBatches.ts b/frontend/src/api/impBatches.ts deleted file mode 100644 index 3e7034cc..00000000 --- a/frontend/src/api/impBatches.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { apiGet, apiPatch, apiPost } from "./axios"; -import type { AxiosResponse } from "axios"; -import type { ApiListResponse } from "../types/api"; - -export interface ImpBatch { - id: string; - study_id: string; - product_id: string; - batch_no: string; - expiry_date?: string | null; - manufacture_date?: string | null; - status: string; - created_at: string; - updated_at: string; -} - -export const fetchImpBatches = ( - studyId: string, - params?: Record -): Promise>> => - apiGet(`/api/v1/studies/${studyId}/imp/batches/`, { params }); - -export const createImpBatch = ( - studyId: string, - payload: Record -): Promise> => - apiPost(`/api/v1/studies/${studyId}/imp/batches/`, payload); - -export const updateImpBatch = ( - studyId: string, - batchId: string, - payload: Record -): Promise> => - apiPatch(`/api/v1/studies/${studyId}/imp/batches/${batchId}`, payload); diff --git a/frontend/src/api/impInventory.ts b/frontend/src/api/impInventory.ts deleted file mode 100644 index 1b3a7f85..00000000 --- a/frontend/src/api/impInventory.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { apiGet } from "./axios"; -import type { AxiosResponse } from "axios"; -import type { ApiListResponse } from "../types/api"; - -export interface ImpInventoryRow { - site_id: string; - batch_id: string; - quantity_on_hand: number; - updated_at: string; -} - -export const fetchImpInventory = ( - studyId: string, - params?: Record -): Promise>> => - apiGet(`/api/v1/studies/${studyId}/imp/inventory/`, { params }); diff --git a/frontend/src/api/impProducts.ts b/frontend/src/api/impProducts.ts deleted file mode 100644 index c5e6b1f5..00000000 --- a/frontend/src/api/impProducts.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { apiGet, apiPatch, apiPost } from "./axios"; -import type { AxiosResponse } from "axios"; -import type { ApiListResponse } from "../types/api"; - -export interface ImpProduct { - id: string; - study_id: string; - name: string; - form?: string | null; - strength?: string | null; - unit: string; - description?: string | null; - is_active: boolean; - created_at: string; - updated_at: string; -} - -export const fetchImpProducts = ( - studyId: string -): Promise>> => - apiGet(`/api/v1/studies/${studyId}/imp/products/`); - -export const createImpProduct = ( - studyId: string, - payload: Record -): Promise> => - apiPost(`/api/v1/studies/${studyId}/imp/products/`, payload); - -export const updateImpProduct = ( - studyId: string, - productId: string, - payload: Record -): Promise> => - apiPatch(`/api/v1/studies/${studyId}/imp/products/${productId}`, payload); diff --git a/frontend/src/api/impTransactions.ts b/frontend/src/api/impTransactions.ts deleted file mode 100644 index af351c28..00000000 --- a/frontend/src/api/impTransactions.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { apiGet, apiPost } from "./axios"; -import type { AxiosResponse } from "axios"; -import type { ApiListResponse } from "../types/api"; - -export interface ImpTransaction { - id: string; - study_id: string; - site_id: string; - batch_id: string; - subject_id?: string | null; - tx_type: string; - quantity: number; - tx_date: string; - reference?: string | null; - notes?: string | null; - created_by: string; - created_at: string; -} - -export const fetchImpTransactions = ( - studyId: string, - params?: Record -): Promise>> => - apiGet(`/api/v1/studies/${studyId}/imp/transactions/`, { params }); - -export const createImpTransaction = ( - studyId: string, - payload: Record -): Promise> => - apiPost(`/api/v1/studies/${studyId}/imp/transactions/`, payload); diff --git a/frontend/src/api/issues.ts b/frontend/src/api/issues.ts deleted file mode 100644 index 8c0633f7..00000000 --- a/frontend/src/api/issues.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { apiGet, apiPost, apiPatch } from "./axios"; -import type { ApiListResponse } from "../types/api"; - -export const fetchIssues = (studyId: string, params?: Record) => - apiGet>(`/api/v1/studies/${studyId}/issues/`, { params }); - -export const createIssue = (studyId: string, payload: Record) => - apiPost(`/api/v1/studies/${studyId}/issues/`, payload); - -export const updateIssue = (studyId: string, issueId: string, payload: Record) => - apiPatch(`/api/v1/studies/${studyId}/issues/${issueId}`, payload); diff --git a/frontend/src/api/knowledgeNotes.ts b/frontend/src/api/knowledgeNotes.ts new file mode 100644 index 00000000..926e1839 --- /dev/null +++ b/frontend/src/api/knowledgeNotes.ts @@ -0,0 +1,16 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listKnowledgeNotes = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/knowledge/notes`, { params }); + +export const getKnowledgeNote = (studyId: string, noteId: string) => + apiGet(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`); + +export const createKnowledgeNote = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/knowledge/notes`, payload); + +export const updateKnowledgeNote = (studyId: string, noteId: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`, payload); + +export const deleteKnowledgeNote = (studyId: string, noteId: string) => + apiDelete(`/api/v1/studies/${studyId}/knowledge/notes/${noteId}`); diff --git a/frontend/src/api/milestones.ts b/frontend/src/api/milestones.ts deleted file mode 100644 index 867e4ac1..00000000 --- a/frontend/src/api/milestones.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { apiGet, apiPost, apiPatch, apiDelete } from "./axios"; -import type { ApiListResponse } from "../types/api"; - -export const fetchMilestones = (studyId: string) => - apiGet>(`/api/v1/studies/${studyId}/milestones/`); - -export const createMilestone = (studyId: string, payload: Record) => - apiPost(`/api/v1/studies/${studyId}/milestones/`, payload); - -export const updateMilestone = (studyId: string, milestoneId: string, payload: Record) => - apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload); - -export const deleteMilestone = (studyId: string, milestoneId: string) => - apiDelete(`/api/v1/studies/${studyId}/milestones/${milestoneId}`); diff --git a/frontend/src/api/startup.ts b/frontend/src/api/startup.ts new file mode 100644 index 00000000..e93876ce --- /dev/null +++ b/frontend/src/api/startup.ts @@ -0,0 +1,61 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listFeasibility = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/startup/feasibility`, { params }); + +export const getFeasibility = (studyId: string, id: string) => + apiGet(`/api/v1/studies/${studyId}/startup/feasibility/${id}`); + +export const createFeasibility = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/startup/feasibility`, payload); + +export const updateFeasibility = (studyId: string, id: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/startup/feasibility/${id}`, payload); + +export const deleteFeasibility = (studyId: string, id: string) => + apiDelete(`/api/v1/studies/${studyId}/startup/feasibility/${id}`); + +export const listEthics = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/startup/ethics`, { params }); + +export const getEthics = (studyId: string, id: string) => + apiGet(`/api/v1/studies/${studyId}/startup/ethics/${id}`); + +export const createEthics = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/startup/ethics`, payload); + +export const updateEthics = (studyId: string, id: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/startup/ethics/${id}`, payload); + +export const deleteEthics = (studyId: string, id: string) => + apiDelete(`/api/v1/studies/${studyId}/startup/ethics/${id}`); + +export const listKickoffs = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/startup/kickoff`, { params }); + +export const getKickoff = (studyId: string, id: string) => + apiGet(`/api/v1/studies/${studyId}/startup/kickoff/${id}`); + +export const createKickoff = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/startup/kickoff`, payload); + +export const updateKickoff = (studyId: string, id: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/startup/kickoff/${id}`, payload); + +export const deleteKickoff = (studyId: string, id: string) => + apiDelete(`/api/v1/studies/${studyId}/startup/kickoff/${id}`); + +export const listTrainingAuthorizations = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations`, { params }); + +export const getTrainingAuthorization = (studyId: string, id: string) => + apiGet(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`); + +export const createTrainingAuthorization = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/startup/training-authorizations`, payload); + +export const updateTrainingAuthorization = (studyId: string, id: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`, payload); + +export const deleteTrainingAuthorization = (studyId: string, id: string) => + apiDelete(`/api/v1/studies/${studyId}/startup/training-authorizations/${id}`); diff --git a/frontend/src/api/subjectHistories.ts b/frontend/src/api/subjectHistories.ts new file mode 100644 index 00000000..9b479792 --- /dev/null +++ b/frontend/src/api/subjectHistories.ts @@ -0,0 +1,20 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listSubjectHistories = (studyId: string, subjectId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, { params }); + +export const getSubjectHistory = (studyId: string, subjectId: string, historyId: string) => + apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`); + +export const createSubjectHistory = (studyId: string, subjectId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, payload); + +export const updateSubjectHistory = ( + studyId: string, + subjectId: string, + historyId: string, + payload: Record +) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`, payload); + +export const deleteSubjectHistory = (studyId: string, subjectId: string, historyId: string) => + apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`); diff --git a/frontend/src/api/subjects.ts b/frontend/src/api/subjects.ts index 1b38570f..b84eca47 100644 --- a/frontend/src/api/subjects.ts +++ b/frontend/src/api/subjects.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiPatch } from "./axios"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; import type { ApiListResponse } from "../types/api"; export const fetchSubjects = (studyId: string, params?: Record) => @@ -7,5 +7,11 @@ export const fetchSubjects = (studyId: string, params?: Record) => export const createSubject = (studyId: string, payload: Record) => apiPost(`/api/v1/studies/${studyId}/subjects/`, payload); +export const getSubject = (studyId: string, subjectId: string) => + apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}`); + export const updateSubject = (studyId: string, subjectId: string, payload: Record) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}`, payload); + +export const deleteSubject = (studyId: string, subjectId: string) => + apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}`); diff --git a/frontend/src/api/verifications.ts b/frontend/src/api/verifications.ts deleted file mode 100644 index 65b7c396..00000000 --- a/frontend/src/api/verifications.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { apiGet, apiPost } from "./axios"; - -export const fetchVerifications = (studyId: string, params?: Record) => - apiGet(`/api/v1/studies/${studyId}/verifications/`, { params }); - -export const upsertVerification = (studyId: string, payload: Record) => - apiPost(`/api/v1/studies/${studyId}/verifications/`, payload); - -export const fetchVerification = (studyId: string, subjectId: string, level: string) => - apiGet(`/api/v1/studies/${studyId}/verifications/subjects/${subjectId}/${level}`); diff --git a/frontend/src/api/visits.ts b/frontend/src/api/visits.ts index 1b36b92f..c5700b15 100644 --- a/frontend/src/api/visits.ts +++ b/frontend/src/api/visits.ts @@ -1,7 +1,13 @@ -import { apiGet, apiPatch } from "./axios"; +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; export const fetchVisits = (studyId: string, subjectId: string) => apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`); +export const createVisit = (studyId: string, subjectId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/`, payload); + export const updateVisit = (studyId: string, subjectId: string, visitId: string, payload: Record) => apiPatch(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`, payload); + +export const deleteVisit = (studyId: string, subjectId: string, visitId: string) => + apiDelete(`/api/v1/studies/${studyId}/subjects/${subjectId}/visits/${visitId}`); diff --git a/frontend/src/components/AeForm.vue b/frontend/src/components/AeForm.vue deleted file mode 100644 index f304182a..00000000 --- a/frontend/src/components/AeForm.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - - - diff --git a/frontend/src/components/AttachmentList.vue b/frontend/src/components/AttachmentList.vue deleted file mode 100644 index 81200a47..00000000 --- a/frontend/src/components/AttachmentList.vue +++ /dev/null @@ -1,84 +0,0 @@ - - - - - diff --git a/frontend/src/components/CommentList.vue b/frontend/src/components/CommentList.vue deleted file mode 100644 index d8b661a4..00000000 --- a/frontend/src/components/CommentList.vue +++ /dev/null @@ -1,204 +0,0 @@ - - - - - diff --git a/frontend/src/components/DataQueryForm.vue b/frontend/src/components/DataQueryForm.vue deleted file mode 100644 index 3ee02b83..00000000 --- a/frontend/src/components/DataQueryForm.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - - - diff --git a/frontend/src/components/FaqItemForm.vue b/frontend/src/components/FaqItemForm.vue index 1dec2bc2..2e63c7ba 100644 --- a/frontend/src/components/FaqItemForm.vue +++ b/frontend/src/components/FaqItemForm.vue @@ -1,5 +1,5 @@