diff --git a/backend/app/api/v1/aes.py b/backend/app/api/v1/aes.py new file mode 100644 index 00000000..9a112b88 --- /dev/null +++ b/backend/app/api/v1/aes.py @@ -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 diff --git a/backend/app/api/v1/issues.py b/backend/app/api/v1/issues.py new file mode 100644 index 00000000..677171fd --- /dev/null +++ b/backend/app/api/v1/issues.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 3f31c27b..e5174483 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, 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.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(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"]) diff --git a/backend/app/crud/ae.py b/backend/app/crud/ae.py new file mode 100644 index 00000000..ca4a1433 --- /dev/null +++ b/backend/app/crud/ae.py @@ -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 diff --git a/backend/app/crud/issue.py b/backend/app/crud/issue.py new file mode 100644 index 00000000..fdbc1d48 --- /dev/null +++ b/backend/app/crud/issue.py @@ -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 diff --git a/backend/app/db/base.py b/backend/app/db/base.py index 6cafdf6c..a1aeb9dd 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -12,3 +12,5 @@ from app.models.milestone import Milestone # noqa: F401 from app.models.task import Task # 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 diff --git a/backend/app/models/ae.py b/backend/app/models/ae.py new file mode 100644 index 00000000..b4a04f34 --- /dev/null +++ b/backend/app/models/ae.py @@ -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() + ) diff --git a/backend/app/models/issue.py b/backend/app/models/issue.py new file mode 100644 index 00000000..33136c80 --- /dev/null +++ b/backend/app/models/issue.py @@ -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() + ) diff --git a/backend/app/schemas/ae.py b/backend/app/schemas/ae.py new file mode 100644 index 00000000..3e963fd7 --- /dev/null +++ b/backend/app/schemas/ae.py @@ -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) diff --git a/backend/app/schemas/issue.py b/backend/app/schemas/issue.py new file mode 100644 index 00000000..bf798032 --- /dev/null +++ b/backend/app/schemas/issue.py @@ -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) diff --git a/pg_data/base/16384/1247 b/pg_data/base/16384/1247 index f0a36072..18e41d62 100644 Binary files a/pg_data/base/16384/1247 and b/pg_data/base/16384/1247 differ diff --git a/pg_data/base/16384/1249 b/pg_data/base/16384/1249 index 7dc0cb05..bd048358 100644 Binary files a/pg_data/base/16384/1249 and b/pg_data/base/16384/1249 differ diff --git a/pg_data/base/16384/1259 b/pg_data/base/16384/1259 index 1084bfa1..be12533c 100644 Binary files a/pg_data/base/16384/1259 and b/pg_data/base/16384/1259 differ diff --git a/pg_data/base/16384/24783 b/pg_data/base/16384/24783 new file mode 100644 index 00000000..6d17cf9d Binary files /dev/null and b/pg_data/base/16384/24783 differ diff --git a/pg_data/base/16384/24788 b/pg_data/base/16384/24788 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24789 b/pg_data/base/16384/24789 new file mode 100644 index 00000000..1eb035e8 Binary files /dev/null and b/pg_data/base/16384/24789 differ diff --git a/pg_data/base/16384/24790 b/pg_data/base/16384/24790 new file mode 100644 index 00000000..3ec11fce Binary files /dev/null and b/pg_data/base/16384/24790 differ diff --git a/pg_data/base/16384/24812 b/pg_data/base/16384/24812 new file mode 100644 index 00000000..5d7b658b Binary files /dev/null and b/pg_data/base/16384/24812 differ diff --git a/pg_data/base/16384/24813 b/pg_data/base/16384/24813 new file mode 100644 index 00000000..7c7ece35 Binary files /dev/null and b/pg_data/base/16384/24813 differ diff --git a/pg_data/base/16384/24814 b/pg_data/base/16384/24814 new file mode 100644 index 00000000..02aefabb Binary files /dev/null and b/pg_data/base/16384/24814 differ diff --git a/pg_data/base/16384/24815 b/pg_data/base/16384/24815 new file mode 100644 index 00000000..6d17cf9d Binary files /dev/null and b/pg_data/base/16384/24815 differ diff --git a/pg_data/base/16384/24820 b/pg_data/base/16384/24820 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24821 b/pg_data/base/16384/24821 new file mode 100644 index 00000000..90263e78 Binary files /dev/null and b/pg_data/base/16384/24821 differ diff --git a/pg_data/base/16384/24822 b/pg_data/base/16384/24822 new file mode 100644 index 00000000..78a5e2ea Binary files /dev/null and b/pg_data/base/16384/24822 differ diff --git a/pg_data/base/16384/24849 b/pg_data/base/16384/24849 new file mode 100644 index 00000000..667aa3e3 Binary files /dev/null and b/pg_data/base/16384/24849 differ diff --git a/pg_data/base/16384/24850 b/pg_data/base/16384/24850 new file mode 100644 index 00000000..268a236e Binary files /dev/null and b/pg_data/base/16384/24850 differ diff --git a/pg_data/base/16384/24851 b/pg_data/base/16384/24851 new file mode 100644 index 00000000..0242f469 Binary files /dev/null and b/pg_data/base/16384/24851 differ diff --git a/pg_data/base/16384/2579 b/pg_data/base/16384/2579 index 41b60260..78945c12 100644 Binary files a/pg_data/base/16384/2579 and b/pg_data/base/16384/2579 differ diff --git a/pg_data/base/16384/2604 b/pg_data/base/16384/2604 index 7fa50176..37007d57 100644 Binary files a/pg_data/base/16384/2604 and b/pg_data/base/16384/2604 differ diff --git a/pg_data/base/16384/2606 b/pg_data/base/16384/2606 index 12301266..d29ff6be 100644 Binary files a/pg_data/base/16384/2606 and b/pg_data/base/16384/2606 differ diff --git a/pg_data/base/16384/2608 b/pg_data/base/16384/2608 index b4891f9a..7e21be47 100644 Binary files a/pg_data/base/16384/2608 and b/pg_data/base/16384/2608 differ diff --git a/pg_data/base/16384/2610 b/pg_data/base/16384/2610 index b2dae6e0..c8bb0dbe 100644 Binary files a/pg_data/base/16384/2610 and b/pg_data/base/16384/2610 differ diff --git a/pg_data/base/16384/2619 b/pg_data/base/16384/2619 index 7699405c..3beffadf 100644 Binary files a/pg_data/base/16384/2619 and b/pg_data/base/16384/2619 differ diff --git a/pg_data/base/16384/2619_fsm b/pg_data/base/16384/2619_fsm index 311639de..591329e4 100644 Binary files a/pg_data/base/16384/2619_fsm and b/pg_data/base/16384/2619_fsm differ diff --git a/pg_data/base/16384/2620 b/pg_data/base/16384/2620 index 35bb7933..4ee72c3c 100644 Binary files a/pg_data/base/16384/2620 and b/pg_data/base/16384/2620 differ diff --git a/pg_data/base/16384/2620_fsm b/pg_data/base/16384/2620_fsm index 0bf0123b..315c412c 100644 Binary files a/pg_data/base/16384/2620_fsm and b/pg_data/base/16384/2620_fsm differ diff --git a/pg_data/base/16384/2656 b/pg_data/base/16384/2656 index ef92e5d0..041ef528 100644 Binary files a/pg_data/base/16384/2656 and b/pg_data/base/16384/2656 differ diff --git a/pg_data/base/16384/2657 b/pg_data/base/16384/2657 index 73f559f9..4e39aab0 100644 Binary files a/pg_data/base/16384/2657 and b/pg_data/base/16384/2657 differ diff --git a/pg_data/base/16384/2658 b/pg_data/base/16384/2658 index 3d556622..fd0de5fd 100644 Binary files a/pg_data/base/16384/2658 and b/pg_data/base/16384/2658 differ diff --git a/pg_data/base/16384/2659 b/pg_data/base/16384/2659 index 218fe35c..ab1edc3a 100644 Binary files a/pg_data/base/16384/2659 and b/pg_data/base/16384/2659 differ diff --git a/pg_data/base/16384/2662 b/pg_data/base/16384/2662 index bfb5ecc3..50b84533 100644 Binary files a/pg_data/base/16384/2662 and b/pg_data/base/16384/2662 differ diff --git a/pg_data/base/16384/2663 b/pg_data/base/16384/2663 index d47deaaa..99883c51 100644 Binary files a/pg_data/base/16384/2663 and b/pg_data/base/16384/2663 differ diff --git a/pg_data/base/16384/2664 b/pg_data/base/16384/2664 index b5eae30d..dbc9f7b8 100644 Binary files a/pg_data/base/16384/2664 and b/pg_data/base/16384/2664 differ diff --git a/pg_data/base/16384/2665 b/pg_data/base/16384/2665 index 72b32dc2..5fdfbeca 100644 Binary files a/pg_data/base/16384/2665 and b/pg_data/base/16384/2665 differ diff --git a/pg_data/base/16384/2666 b/pg_data/base/16384/2666 index 72c3e8c1..70245871 100644 Binary files a/pg_data/base/16384/2666 and b/pg_data/base/16384/2666 differ diff --git a/pg_data/base/16384/2667 b/pg_data/base/16384/2667 index c02850a5..dbc9471d 100644 Binary files a/pg_data/base/16384/2667 and b/pg_data/base/16384/2667 differ diff --git a/pg_data/base/16384/2673 b/pg_data/base/16384/2673 index 6776b597..9acd20b0 100644 Binary files a/pg_data/base/16384/2673 and b/pg_data/base/16384/2673 differ diff --git a/pg_data/base/16384/2674 b/pg_data/base/16384/2674 index d7ca0147..20156a53 100644 Binary files a/pg_data/base/16384/2674 and b/pg_data/base/16384/2674 differ diff --git a/pg_data/base/16384/2678 b/pg_data/base/16384/2678 index c80d1322..088b14aa 100644 Binary files a/pg_data/base/16384/2678 and b/pg_data/base/16384/2678 differ diff --git a/pg_data/base/16384/2679 b/pg_data/base/16384/2679 index deb9ea1b..63f37926 100644 Binary files a/pg_data/base/16384/2679 and b/pg_data/base/16384/2679 differ diff --git a/pg_data/base/16384/2696 b/pg_data/base/16384/2696 index 7cd38cf6..4add215b 100644 Binary files a/pg_data/base/16384/2696 and b/pg_data/base/16384/2696 differ diff --git a/pg_data/base/16384/2699 b/pg_data/base/16384/2699 index bf9ae6e5..7379b780 100644 Binary files a/pg_data/base/16384/2699 and b/pg_data/base/16384/2699 differ diff --git a/pg_data/base/16384/2701 b/pg_data/base/16384/2701 index 97fbd28d..205b63a4 100644 Binary files a/pg_data/base/16384/2701 and b/pg_data/base/16384/2701 differ diff --git a/pg_data/base/16384/2702 b/pg_data/base/16384/2702 index 211f248d..307551d3 100644 Binary files a/pg_data/base/16384/2702 and b/pg_data/base/16384/2702 differ diff --git a/pg_data/base/16384/2703 b/pg_data/base/16384/2703 index 5c27ff1b..0177d9e6 100644 Binary files a/pg_data/base/16384/2703 and b/pg_data/base/16384/2703 differ diff --git a/pg_data/base/16384/2704 b/pg_data/base/16384/2704 index 11dd0f11..c127a891 100644 Binary files a/pg_data/base/16384/2704 and b/pg_data/base/16384/2704 differ diff --git a/pg_data/base/16384/2840 b/pg_data/base/16384/2840 index 50474fd1..e53998a2 100644 Binary files a/pg_data/base/16384/2840 and b/pg_data/base/16384/2840 differ diff --git a/pg_data/base/16384/2841 b/pg_data/base/16384/2841 index 6a1684cc..77edaa89 100644 Binary files a/pg_data/base/16384/2841 and b/pg_data/base/16384/2841 differ diff --git a/pg_data/base/16384/3455 b/pg_data/base/16384/3455 index 1fbd5bf8..65eeebcd 100644 Binary files a/pg_data/base/16384/3455 and b/pg_data/base/16384/3455 differ diff --git a/pg_data/base/16384/pg_internal.init b/pg_data/base/16384/pg_internal.init index f56e51c7..28a0642b 100644 Binary files a/pg_data/base/16384/pg_internal.init and b/pg_data/base/16384/pg_internal.init differ diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control index 5a2e0b35..77df77c0 100644 Binary files a/pg_data/global/pg_control and b/pg_data/global/pg_control differ diff --git a/pg_data/pg_wal/000000010000000000000001 b/pg_data/pg_wal/000000010000000000000001 index e912bc97..c0f5cbd3 100644 Binary files a/pg_data/pg_wal/000000010000000000000001 and b/pg_data/pg_wal/000000010000000000000001 differ diff --git a/pg_data/pg_xact/0000 b/pg_data/pg_xact/0000 index 83f8c3bc..a4255cea 100644 Binary files a/pg_data/pg_xact/0000 and b/pg_data/pg_xact/0000 differ