Step 3:Study / Site / StudyMember + 项目权限

This commit is contained in:
Cheng Zhou
2025-12-16 16:42:42 +08:00
parent 65df698570
commit f3692e1145
66 changed files with 639 additions and 6 deletions
+90
View File
@@ -0,0 +1,90 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member, require_study_roles
from app.crud import member as member_crud
from app.crud import study as study_crud
from app.schemas.member import StudyMemberCreate, StudyMemberRead, StudyMemberUpdate
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=StudyMemberRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def add_member(
study_id: uuid.UUID,
member_in: StudyMemberCreate,
db: AsyncSession = Depends(get_db_session),
) -> StudyMemberRead:
await _ensure_study_exists(db, study_id)
existing = await member_crud.get_member(db, study_id, member_in.user_id)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Member already exists")
member = await member_crud.add_member(db, study_id, member_in)
return member
@router.get(
"/",
response_model=list[StudyMemberRead],
dependencies=[Depends(require_study_member())],
)
async def list_members(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[StudyMemberRead]:
await _ensure_study_exists(db, study_id)
members = await member_crud.list_members(db, study_id, skip=skip, limit=limit)
return list(members)
@router.patch(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_member(
study_id: uuid.UUID,
member_id: uuid.UUID,
member_in: StudyMemberUpdate,
db: AsyncSession = Depends(get_db_session),
) -> StudyMemberRead:
await _ensure_study_exists(db, study_id)
member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
updated = await member_crud.update_member(db, member, member_in)
return updated
@router.delete(
"/{member_id}",
response_model=StudyMemberRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def remove_member(
study_id: uuid.UUID,
member_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> StudyMemberRead:
await _ensure_study_exists(db, study_id)
member = await member_crud.get_member_by_id(db, member_id)
if not member or member.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
removed = await member_crud.remove_member(db, member)
return removed
+4 -1
View File
@@ -1,7 +1,10 @@
from fastapi import APIRouter
from app.api.v1 import auth, users
from app.api.v1 import auth, users, studies, sites, members
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
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"])
+69
View File
@@ -0,0 +1,69 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member, require_study_roles
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.site import SiteCreate, SiteRead, SiteUpdate
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=SiteRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def create_site(
study_id: uuid.UUID,
site_in: SiteCreate,
db: AsyncSession = Depends(get_db_session),
) -> SiteRead:
await _ensure_study_exists(db, study_id)
site = await site_crud.create_site(db, study_id, site_in)
return site
@router.get(
"/",
response_model=list[SiteRead],
dependencies=[Depends(require_study_member())],
)
async def list_sites(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[SiteRead]:
await _ensure_study_exists(db, study_id)
sites = await site_crud.list_by_study(db, study_id, skip=skip, limit=limit)
return list(sites)
@router.patch(
"/{site_id}",
response_model=SiteRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_site(
study_id: uuid.UUID,
site_id: uuid.UUID,
site_in: SiteUpdate,
db: AsyncSession = Depends(get_db_session),
) -> SiteRead:
await _ensure_study_exists(db, study_id)
site = await site_crud.get_site(db, site_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found")
updated = await site_crud.update_site(db, site, site_in)
return updated
+65
View File
@@ -0,0 +1,65 @@
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_roles, require_study_member, require_study_roles
from app.crud import study as study_crud
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
router = APIRouter()
@router.post("/", response_model=StudyRead, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_roles(["ADMIN"]))])
async def create_study(
study_in: StudyCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> StudyRead:
existing = await study_crud.get_by_code(db, study_in.code)
if existing:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Study code already exists")
study = await study_crud.create(db, study_in, created_by=current_user.id)
return study
@router.get("/", response_model=list[StudyRead])
async def list_studies(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StudyRead]:
if current_user.role == "ADMIN":
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
else:
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
return list(studies)
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
async def get_study(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> StudyRead:
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.patch(
"/{study_id}",
response_model=StudyRead,
dependencies=[Depends(require_study_roles(["PM"]))],
)
async def update_study(
study_id: uuid.UUID,
study_in: StudyUpdate,
db: AsyncSession = Depends(get_db_session),
) -> StudyRead:
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Study not found")
updated = await study_crud.update(db, study, study_in)
return updated
+51
View File
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token, oauth2_scheme
from app.crud import user as user_crud
from app.crud import member as member_crud
from app.db.session import SessionLocal
from app.schemas.user import TokenPayload
@@ -52,3 +53,53 @@ def require_roles(roles: Iterable[str]) -> Callable:
return current_user
return dependency
async def get_study_member(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if current_user.role == "ADMIN":
return None
return await member_crud.get_member(db, study_id, current_user.id)
def require_study_member():
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if current_user.role == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this study",
)
return current_user
return dependency
def require_study_roles(roles: Iterable[str]):
roles_set = set(roles)
async def dependency(
study_id: uuid.UUID,
current_user=Depends(get_current_user),
db: AsyncSession = Depends(get_db_session),
):
if current_user.role == "ADMIN":
return current_user
membership = await member_crud.get_member(db, study_id, current_user.id)
if not membership or not membership.is_active or membership.role_in_study not in roles_set:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient study permissions",
)
return current_user
return dependency
+64
View File
@@ -0,0 +1,64 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study_member import StudyMember
from app.schemas.member import StudyMemberCreate, StudyMemberUpdate
async def get_member(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID) -> StudyMember | None:
result = await db.execute(
select(StudyMember).where(StudyMember.study_id == study_id, StudyMember.user_id == user_id)
)
return result.scalar_one_or_none()
async def get_member_by_id(db: AsyncSession, member_id: uuid.UUID) -> StudyMember | None:
result = await db.execute(select(StudyMember).where(StudyMember.id == member_id))
return result.scalar_one_or_none()
async def add_member(db: AsyncSession, study_id: uuid.UUID, member_in: StudyMemberCreate) -> StudyMember:
member = StudyMember(
study_id=study_id,
user_id=member_in.user_id,
role_in_study=member_in.role_in_study,
is_active=member_in.is_active,
)
db.add(member)
await db.commit()
await db.refresh(member)
return member
async def list_members(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[StudyMember]:
result = await db.execute(
select(StudyMember).where(StudyMember.study_id == study_id).offset(skip).limit(limit)
)
return result.scalars().all()
async def update_member(db: AsyncSession, member: StudyMember, member_in: StudyMemberUpdate) -> StudyMember:
update_data = {k: v for k, v in member_in.model_dump(exclude_unset=True).items() if v is not None}
if update_data:
await db.execute(
update(StudyMember)
.where(StudyMember.id == member.id)
.values(**update_data)
)
await db.commit()
await db.refresh(member)
return member
async def remove_member(db: AsyncSession, member: StudyMember) -> StudyMember:
await db.execute(
update(StudyMember)
.where(StudyMember.id == member.id)
.values(is_active=False)
)
await db.commit()
await db.refresh(member)
return member
+48
View File
@@ -0,0 +1,48 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.site import Site
from app.schemas.site import SiteCreate, SiteUpdate
async def create_site(db: AsyncSession, study_id: uuid.UUID, site_in: SiteCreate) -> Site:
site = Site(
study_id=study_id,
name=site_in.name,
city=site_in.city,
pi_name=site_in.pi_name,
contact=site_in.contact,
is_active=site_in.is_active,
)
db.add(site)
await db.commit()
await db.refresh(site)
return site
async def get_site(db: AsyncSession, site_id: uuid.UUID) -> Site | None:
result = await db.execute(select(Site).where(Site.id == site_id))
return result.scalar_one_or_none()
async def update_site(db: AsyncSession, site: Site, site_in: SiteUpdate) -> Site:
update_data = site_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
update(Site)
.where(Site.id == site.id)
.values(**update_data)
)
await db.commit()
await db.refresh(site)
return site
async def list_by_study(db: AsyncSession, study_id: uuid.UUID, skip: int = 0, limit: int = 100) -> Sequence[Site]:
result = await db.execute(
select(Site).where(Site.study_id == study_id).offset(skip).limit(limit)
)
return result.scalars().all()
+71
View File
@@ -0,0 +1,71 @@
import uuid
from typing import Sequence
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.study import Study
from app.schemas.study import StudyCreate, StudyUpdate
async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UUID | None = None) -> Study:
study = Study(
code=study_in.code,
name=study_in.name,
sponsor=study_in.sponsor,
protocol_no=study_in.protocol_no,
phase=study_in.phase,
status=study_in.status,
created_by=created_by,
)
db.add(study)
await db.commit()
await db.refresh(study)
return study
async def get(db: AsyncSession, study_id: uuid.UUID) -> Study | None:
result = await db.execute(select(Study).where(Study.id == study_id))
return result.scalar_one_or_none()
async def get_by_code(db: AsyncSession, code: str) -> Study | None:
result = await db.execute(select(Study).where(Study.code == code))
return result.scalar_one_or_none()
async def update(db: AsyncSession, study: Study, study_in: StudyUpdate) -> Study:
update_data = study_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
update(Study)
.where(Study.id == study.id)
.values(**update_data)
)
await db.commit()
await db.refresh(study)
return study
async def list_studies(db: AsyncSession, skip: int = 0, limit: int = 100) -> Sequence[Study]:
result = await db.execute(select(Study).offset(skip).limit(limit))
return result.scalars().all()
async def list_studies_for_user(
db: AsyncSession,
user_id: uuid.UUID,
skip: int = 0,
limit: int = 100,
) -> Sequence[Study]:
from app.models.study_member import StudyMember # local import to avoid cycles
stmt = (
select(Study)
.join(StudyMember, StudyMember.study_id == Study.id)
.where(StudyMember.user_id == user_id, StudyMember.is_active.is_(True))
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
return result.scalars().all()
+6 -4
View File
@@ -1,5 +1,7 @@
from sqlalchemy.orm import DeclarativeBase
from app.db.base_class import Base
class Base(DeclarativeBase):
pass
# Import models to ensure metadata is loaded for create_all
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
+5
View File
@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
+21
View File
@@ -0,0 +1,21 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, 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 Site(Base):
__tablename__ = "sites"
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(200), nullable=False)
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
pi_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import 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 Study(Base):
__tablename__ = "studies"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
sponsor: Mapped[str | None] = mapped_column(String(200), nullable=True)
protocol_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
phase: Mapped[str | None] = mapped_column(String(50), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
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())
+20
View File
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, 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 StudyMember(Base):
__tablename__ = "study_members"
__table_args__ = (UniqueConstraint("study_id", "user_id", name="uq_study_member"),)
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"), nullable=False, index=True)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
role_in_study: Mapped[str] = mapped_column(String(20), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+1 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
from app.db.base_class import Base
class User(Base):
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
StudyRole = Literal["PM", "CRA", "PV", "IMP"]
class StudyMemberCreate(BaseModel):
user_id: uuid.UUID
role_in_study: StudyRole
is_active: bool = True
class StudyMemberUpdate(BaseModel):
role_in_study: Optional[StudyRole] = None
is_active: Optional[bool] = None
class StudyMemberRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
user_id: uuid.UUID
role_in_study: StudyRole
is_active: bool
added_at: datetime
model_config = ConfigDict(from_attributes=True)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class SiteCreate(BaseModel):
name: str = Field(min_length=1)
city: Optional[str] = None
pi_name: Optional[str] = None
contact: Optional[str] = None
is_active: bool = True
class SiteUpdate(BaseModel):
name: Optional[str] = None
city: Optional[str] = None
pi_name: Optional[str] = None
contact: Optional[str] = None
is_active: Optional[bool] = None
class SiteRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
name: str
city: Optional[str]
pi_name: Optional[str]
contact: Optional[str]
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+39
View File
@@ -0,0 +1,39 @@
import uuid
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"]
class StudyCreate(BaseModel):
code: str = Field(min_length=1)
name: str = Field(min_length=1)
sponsor: Optional[str] = None
protocol_no: Optional[str] = None
phase: Optional[str] = None
status: StudyStatus = "DRAFT"
class StudyUpdate(BaseModel):
code: Optional[str] = None
name: Optional[str] = None
sponsor: Optional[str] = None
protocol_no: Optional[str] = None
phase: Optional[str] = None
status: Optional[StudyStatus] = None
class StudyRead(BaseModel):
id: uuid.UUID
code: str
name: str
sponsor: Optional[str]
protocol_no: Optional[str]
phase: Optional[str]
status: StudyStatus
created_by: Optional[uuid.UUID]
created_at: datetime
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.
View File
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
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.