Step 7:AE + 风险/问题管理(PV)
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
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 ae as ae_crud
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import member as member_crud
|
||||||
|
from app.crud import study as study_crud
|
||||||
|
from app.schemas.ae import AECreate, AERead, AEUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"}
|
||||||
|
ALLOWED_UPDATE_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(ae: AERead) -> bool:
|
||||||
|
return bool(ae.report_due_date and date.today() > ae.report_due_date and ae.status != "CLOSED")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/",
|
||||||
|
response_model=AERead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def create_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
ae_in: AECreate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> AERead:
|
||||||
|
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_CREATE_ROLES:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
try:
|
||||||
|
ae = await ae_crud.create_ae(db, study_id, ae_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="ae",
|
||||||
|
entity_id=ae.id,
|
||||||
|
action="CREATE_AE",
|
||||||
|
detail=f"AE {ae.id} created",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
data = AERead.model_validate(ae)
|
||||||
|
data.is_overdue = _is_overdue(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/",
|
||||||
|
response_model=list[AERead],
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def list_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
status_filter: str | None = None,
|
||||||
|
seriousness: str | None = None,
|
||||||
|
site_id: uuid.UUID | None = None,
|
||||||
|
subject_id: uuid.UUID | None = None,
|
||||||
|
overdue: bool | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> list[AERead]:
|
||||||
|
await _ensure_study_exists(db, study_id)
|
||||||
|
aes = await ae_crud.list_ae(db, study_id, status=status_filter, seriousness=seriousness, site_id=site_id, subject_id=subject_id, overdue=overdue)
|
||||||
|
result: list[AERead] = []
|
||||||
|
for item in aes:
|
||||||
|
obj = AERead.model_validate(item)
|
||||||
|
obj.is_overdue = _is_overdue(obj)
|
||||||
|
result.append(obj)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{ae_id}",
|
||||||
|
response_model=AERead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def get_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
ae_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> AERead:
|
||||||
|
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")
|
||||||
|
data = AERead.model_validate(ae)
|
||||||
|
data.is_overdue = _is_overdue(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{ae_id}",
|
||||||
|
response_model=AERead,
|
||||||
|
dependencies=[Depends(require_study_member())],
|
||||||
|
)
|
||||||
|
async def update_ae(
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
ae_id: uuid.UUID,
|
||||||
|
ae_in: AEUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
|
) -> AERead:
|
||||||
|
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":
|
||||||
|
pass
|
||||||
|
elif member_role in ALLOWED_UPDATE_ROLES:
|
||||||
|
pass
|
||||||
|
elif member_role == "CRA":
|
||||||
|
if ae.created_by != current_user.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only creator can update AE")
|
||||||
|
if ae_in.status == "CLOSED":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="CRA cannot close AE")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
|
||||||
|
|
||||||
|
old_status = ae.status
|
||||||
|
updated = await ae_crud.update_ae(db, ae, ae_in)
|
||||||
|
|
||||||
|
detail = None
|
||||||
|
action = "UPDATE_AE"
|
||||||
|
if ae_in.status and ae_in.status != old_status:
|
||||||
|
detail = f"AE {ae_id} status {old_status} -> {ae_in.status}"
|
||||||
|
action = "AE_STATUS_CHANGE"
|
||||||
|
await audit_crud.log_action(
|
||||||
|
db,
|
||||||
|
study_id=study_id,
|
||||||
|
entity_type="ae",
|
||||||
|
entity_id=ae_id,
|
||||||
|
action=action,
|
||||||
|
detail=detail or "AE updated",
|
||||||
|
operator_id=current_user.id,
|
||||||
|
operator_role=current_user.role,
|
||||||
|
)
|
||||||
|
data = AERead.model_validate(updated)
|
||||||
|
data.is_overdue = _is_overdue(data)
|
||||||
|
return data
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits
|
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||||
@@ -16,3 +16,5 @@ api_router.include_router(tasks.router, prefix="/studies/{study_id}/tasks", tags
|
|||||||
api_router.include_router(dashboard.router, prefix="/studies/{study_id}/dashboard", tags=["dashboard"])
|
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(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(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"])
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import select, update as sa_update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.ae import AdverseEvent
|
||||||
|
from app.models.site import Site
|
||||||
|
from app.models.subject import Subject
|
||||||
|
from app.schemas.ae import AECreate, AEUpdate
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_due_date(onset: date | None, seriousness: str) -> date | None:
|
||||||
|
if onset is None:
|
||||||
|
return None
|
||||||
|
delta = 1 if seriousness == "SERIOUS" else 7
|
||||||
|
return onset + timedelta(days=delta)
|
||||||
|
|
||||||
|
|
||||||
|
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_ae(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
ae_in: AECreate,
|
||||||
|
*,
|
||||||
|
created_by: uuid.UUID,
|
||||||
|
) -> AdverseEvent:
|
||||||
|
await _validate_site_subject(db, study_id, ae_in.site_id, ae_in.subject_id)
|
||||||
|
due_date = _calc_due_date(ae_in.onset_date, ae_in.seriousness)
|
||||||
|
ae = AdverseEvent(
|
||||||
|
study_id=study_id,
|
||||||
|
site_id=ae_in.site_id,
|
||||||
|
subject_id=ae_in.subject_id,
|
||||||
|
visit_id=ae_in.visit_id,
|
||||||
|
term=ae_in.term,
|
||||||
|
onset_date=ae_in.onset_date,
|
||||||
|
resolution_date=None,
|
||||||
|
seriousness=ae_in.seriousness,
|
||||||
|
severity=ae_in.severity,
|
||||||
|
causality=ae_in.causality,
|
||||||
|
action_taken=None,
|
||||||
|
outcome=None,
|
||||||
|
reported_to_sponsor=False,
|
||||||
|
report_due_date=due_date,
|
||||||
|
status="NEW",
|
||||||
|
description=ae_in.description,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
db.add(ae)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(ae)
|
||||||
|
return ae
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ae(db: AsyncSession, ae_id: uuid.UUID) -> AdverseEvent | None:
|
||||||
|
result = await db.execute(select(AdverseEvent).where(AdverseEvent.id == ae_id))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def list_ae(
|
||||||
|
db: AsyncSession,
|
||||||
|
study_id: uuid.UUID,
|
||||||
|
status: str | None = None,
|
||||||
|
seriousness: str | None = None,
|
||||||
|
site_id: uuid.UUID | None = None,
|
||||||
|
subject_id: uuid.UUID | None = None,
|
||||||
|
overdue: bool | None = None,
|
||||||
|
) -> Sequence[AdverseEvent]:
|
||||||
|
stmt = select(AdverseEvent).where(AdverseEvent.study_id == study_id)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(AdverseEvent.status == status)
|
||||||
|
if seriousness:
|
||||||
|
stmt = stmt.where(AdverseEvent.seriousness == seriousness)
|
||||||
|
if site_id:
|
||||||
|
stmt = stmt.where(AdverseEvent.site_id == site_id)
|
||||||
|
if subject_id:
|
||||||
|
stmt = stmt.where(AdverseEvent.subject_id == subject_id)
|
||||||
|
if overdue is True:
|
||||||
|
stmt = stmt.where(AdverseEvent.report_due_date < date.today(), AdverseEvent.status != "CLOSED")
|
||||||
|
if overdue is False:
|
||||||
|
stmt = stmt.where((AdverseEvent.report_due_date >= date.today()) | (AdverseEvent.report_due_date.is_(None)) | (AdverseEvent.status == "CLOSED"))
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_ae(db: AsyncSession, ae: AdverseEvent, ae_in: AEUpdate) -> AdverseEvent:
|
||||||
|
update_data = ae_in.model_dump(exclude_unset=True)
|
||||||
|
if "onset_date" in update_data or "seriousness" in update_data:
|
||||||
|
onset = update_data.get("onset_date", ae.onset_date)
|
||||||
|
seriousness = update_data.get("seriousness", ae.seriousness)
|
||||||
|
update_data["report_due_date"] = _calc_due_date(onset, seriousness)
|
||||||
|
if update_data:
|
||||||
|
await db.execute(
|
||||||
|
sa_update(AdverseEvent)
|
||||||
|
.where(AdverseEvent.id == ae.id)
|
||||||
|
.values(**update_data)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(ae)
|
||||||
|
return ae
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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
|
||||||
@@ -12,3 +12,5 @@ from app.models.milestone import Milestone # noqa: F401
|
|||||||
from app.models.task import Task # noqa: F401
|
from app.models.task import Task # noqa: F401
|
||||||
from app.models.subject import Subject # noqa: F401
|
from app.models.subject import Subject # noqa: F401
|
||||||
from app.models.visit import Visit # 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
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
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 AdverseEvent(Base):
|
||||||
|
__tablename__ = "adverse_events"
|
||||||
|
|
||||||
|
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)
|
||||||
|
term: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
onset_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
resolution_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
seriousness: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
severity: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
causality: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
action_taken: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
outcome: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
reported_to_sponsor: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
report_due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="NEW")
|
||||||
|
description: 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())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base_class import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Issue(Base):
|
||||||
|
__tablename__ = "issues"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
|
||||||
|
site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
|
||||||
|
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
category: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
level: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||||
|
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="OPEN")
|
||||||
|
capa: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AECreate(BaseModel):
|
||||||
|
site_id: Optional[uuid.UUID] = None
|
||||||
|
subject_id: Optional[uuid.UUID] = None
|
||||||
|
visit_id: Optional[uuid.UUID] = None
|
||||||
|
term: str
|
||||||
|
onset_date: Optional[date] = None
|
||||||
|
seriousness: str
|
||||||
|
severity: str
|
||||||
|
causality: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AEUpdate(BaseModel):
|
||||||
|
resolution_date: Optional[date] = None
|
||||||
|
seriousness: Optional[str] = None
|
||||||
|
severity: Optional[str] = None
|
||||||
|
causality: Optional[str] = None
|
||||||
|
action_taken: Optional[str] = None
|
||||||
|
outcome: Optional[str] = None
|
||||||
|
reported_to_sponsor: Optional[bool] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AERead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
study_id: uuid.UUID
|
||||||
|
site_id: Optional[uuid.UUID]
|
||||||
|
subject_id: Optional[uuid.UUID]
|
||||||
|
visit_id: Optional[uuid.UUID]
|
||||||
|
term: str
|
||||||
|
onset_date: Optional[date]
|
||||||
|
resolution_date: Optional[date]
|
||||||
|
seriousness: str
|
||||||
|
severity: str
|
||||||
|
causality: Optional[str]
|
||||||
|
action_taken: Optional[str]
|
||||||
|
outcome: Optional[str]
|
||||||
|
reported_to_sponsor: bool
|
||||||
|
report_due_date: Optional[date]
|
||||||
|
status: str
|
||||||
|
description: Optional[str]
|
||||||
|
created_by: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
is_overdue: bool | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user