diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py new file mode 100644 index 00000000..31e1e3f1 --- /dev/null +++ b/backend/app/api/v1/members.py @@ -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 diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index a30bc091..7b77b492 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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"]) diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py new file mode 100644 index 00000000..c6bdb4e8 --- /dev/null +++ b/backend/app/api/v1/sites.py @@ -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 diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py new file mode 100644 index 00000000..029246a0 --- /dev/null +++ b/backend/app/api/v1/studies.py @@ -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 diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index d2ceca90..69c64aa6 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -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 diff --git a/backend/app/crud/member.py b/backend/app/crud/member.py new file mode 100644 index 00000000..24db02a6 --- /dev/null +++ b/backend/app/crud/member.py @@ -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 diff --git a/backend/app/crud/site.py b/backend/app/crud/site.py new file mode 100644 index 00000000..69b79ed4 --- /dev/null +++ b/backend/app/crud/site.py @@ -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() diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py new file mode 100644 index 00000000..f3e4a130 --- /dev/null +++ b/backend/app/crud/study.py @@ -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() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index fa2b68a5..0b1a63b7 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -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 diff --git a/backend/app/db/base_class.py b/backend/app/db/base_class.py new file mode 100644 index 00000000..fa2b68a5 --- /dev/null +++ b/backend/app/db/base_class.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/models/site.py b/backend/app/models/site.py new file mode 100644 index 00000000..c92035cd --- /dev/null +++ b/backend/app/models/site.py @@ -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()) diff --git a/backend/app/models/study.py b/backend/app/models/study.py new file mode 100644 index 00000000..4748004d --- /dev/null +++ b/backend/app/models/study.py @@ -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()) diff --git a/backend/app/models/study_member.py b/backend/app/models/study_member.py new file mode 100644 index 00000000..5ed2f2fd --- /dev/null +++ b/backend/app/models/study_member.py @@ -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()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 50e5e1b5..96851aa2 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -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): diff --git a/backend/app/schemas/member.py b/backend/app/schemas/member.py new file mode 100644 index 00000000..0fa7935c --- /dev/null +++ b/backend/app/schemas/member.py @@ -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) diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py new file mode 100644 index 00000000..d66a7c2d --- /dev/null +++ b/backend/app/schemas/site.py @@ -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) diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py new file mode 100644 index 00000000..1bda9bbf --- /dev/null +++ b/backend/app/schemas/study.py @@ -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) diff --git a/pg_data/base/16384/1247 b/pg_data/base/16384/1247 index 37c318b1..ada60ecd 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 2d790ada..2dc327b5 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 804ed807..8d8b67a2 100644 Binary files a/pg_data/base/16384/1259 and b/pg_data/base/16384/1259 differ diff --git a/pg_data/base/16384/24576 b/pg_data/base/16384/24576 index 6d17cf9d..60f2f391 100644 Binary files a/pg_data/base/16384/24576 and b/pg_data/base/16384/24576 differ diff --git a/pg_data/base/16384/24581 b/pg_data/base/16384/24581 index 2bf92b71..8d1c65e9 100644 Binary files a/pg_data/base/16384/24581 and b/pg_data/base/16384/24581 differ diff --git a/pg_data/base/16384/24583 b/pg_data/base/16384/24583 index e95ae803..6de5b9c6 100644 Binary files a/pg_data/base/16384/24583 and b/pg_data/base/16384/24583 differ diff --git a/pg_data/base/16384/24584 b/pg_data/base/16384/24584 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24588 b/pg_data/base/16384/24588 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24589 b/pg_data/base/16384/24589 new file mode 100644 index 00000000..6faf6828 Binary files /dev/null and b/pg_data/base/16384/24589 differ diff --git a/pg_data/base/16384/24590 b/pg_data/base/16384/24590 new file mode 100644 index 00000000..4546d145 Binary files /dev/null and b/pg_data/base/16384/24590 differ diff --git a/pg_data/base/16384/24597 b/pg_data/base/16384/24597 new file mode 100644 index 00000000..1cac0492 Binary files /dev/null and b/pg_data/base/16384/24597 differ diff --git a/pg_data/base/16384/24598 b/pg_data/base/16384/24598 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24603 b/pg_data/base/16384/24603 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24604 b/pg_data/base/16384/24604 new file mode 100644 index 00000000..a7891a46 Binary files /dev/null and b/pg_data/base/16384/24604 differ diff --git a/pg_data/base/16384/24605 b/pg_data/base/16384/24605 new file mode 100644 index 00000000..c12f902c Binary files /dev/null and b/pg_data/base/16384/24605 differ diff --git a/pg_data/base/16384/24612 b/pg_data/base/16384/24612 new file mode 100644 index 00000000..be5a1061 Binary files /dev/null and b/pg_data/base/16384/24612 differ diff --git a/pg_data/base/16384/24613 b/pg_data/base/16384/24613 new file mode 100644 index 00000000..e69de29b diff --git a/pg_data/base/16384/24618 b/pg_data/base/16384/24618 new file mode 100644 index 00000000..314573e5 Binary files /dev/null and b/pg_data/base/16384/24618 differ diff --git a/pg_data/base/16384/24620 b/pg_data/base/16384/24620 new file mode 100644 index 00000000..3c8df0fc Binary files /dev/null and b/pg_data/base/16384/24620 differ diff --git a/pg_data/base/16384/24632 b/pg_data/base/16384/24632 new file mode 100644 index 00000000..ef32a783 Binary files /dev/null and b/pg_data/base/16384/24632 differ diff --git a/pg_data/base/16384/2579 b/pg_data/base/16384/2579 index 1f883b0e..23bc6c36 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 ee1089e8..8b39db42 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 acd8e820..9d2d69e5 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 581c0e0c..915ae595 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 bafbd017..7af313f0 100644 Binary files a/pg_data/base/16384/2610 and b/pg_data/base/16384/2610 differ diff --git a/pg_data/base/16384/2620 b/pg_data/base/16384/2620 index e69de29b..c7494c5c 100644 Binary files a/pg_data/base/16384/2620 and b/pg_data/base/16384/2620 differ diff --git a/pg_data/base/16384/2656 b/pg_data/base/16384/2656 index 6f6218b6..804b9c52 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 e042a0b1..35d0ffe3 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 2e1ddb6e..0379b3f1 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 a9637f87..060726cd 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 3b68991b..e2e5499a 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 aa470382..5fa7557c 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 ea4f7f6c..7c35e2e6 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 6c7039dd..3c6c216d 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 c8863b97..96fb2fa9 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 5ff452d1..5b3e5eb1 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 bd48d8b5..4939f76e 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 37648bc4..cd8532fe 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 b884e88d..542e419c 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 982b9bc7..1146f20c 100644 Binary files a/pg_data/base/16384/2679 and b/pg_data/base/16384/2679 differ diff --git a/pg_data/base/16384/2699 b/pg_data/base/16384/2699 index b7ed4da9..10bcbc67 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 b59394ad..1ca69452 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 b01a284a..b2d761d4 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 fd58ac03..64b80d5f 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 2516a3a6..bfbde192 100644 Binary files a/pg_data/base/16384/2704 and b/pg_data/base/16384/2704 differ diff --git a/pg_data/base/16384/3455 b/pg_data/base/16384/3455 index d8b4e01a..382a2635 100644 Binary files a/pg_data/base/16384/3455 and b/pg_data/base/16384/3455 differ diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control index 037eaa80..82c5ae15 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 674239a5..b505c9a2 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 b4898632..3616a9bd 100644 Binary files a/pg_data/pg_xact/0000 and b/pg_data/pg_xact/0000 differ