diff --git a/backend/app/access/enums.py b/backend/app/access/enums.py deleted file mode 100644 index ef6dd71..0000000 --- a/backend/app/access/enums.py +++ /dev/null @@ -1,6 +0,0 @@ -from enum import StrEnum - - -class SessionRoles(StrEnum): - CHAIR = "chair" - DELEGATION = "delegate" diff --git a/backend/app/access/models.py b/backend/app/access/models.py deleted file mode 100644 index 8881a43..0000000 --- a/backend/app/access/models.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from . import enums - - -@dataclass(frozen=True) -class CommitteeAssignment: - """Object that holds info about an UUID to a commitee and Delegation / Chair""" - - user_id: UUID - committee_id: int # TODO: remove this to map out to committees/conferences - role: enums.SessionRoles - representation_id: int | None diff --git a/backend/app/access/repository.py b/backend/app/access/repository.py deleted file mode 100644 index d37ee65..0000000 --- a/backend/app/access/repository.py +++ /dev/null @@ -1,71 +0,0 @@ -from uuid import UUID - -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from .models import CommitteeAssignment - - -# TODO: pass this to a conference/ domain -async def get_committee_assignment( - session: AsyncSession, user_id: UUID, committee_id: int -) -> CommitteeAssignment | None: - query = text(""" - SELECT - ca.user_id, - ca.committee_id, - ca.role, - ca.representation_id - FROM public.committees c - JOIN public.committee_assignments ca - ON c.id = ca.committee_id - WHERE c.id = :committee_id AND - ca.user_id = :user_id - """) - - result = await session.execute( - query, {"committee_id": committee_id, "user_id": user_id} - ) - - row = result.mappings().one_or_none() - if row is None: - return None - - return CommitteeAssignment( - user_id=row["user_id"], - committee_id=row["committee_id"], - role=row["role"], - representation_id=row["representation_id"], - ) - - -async def get_session_assignment( - session: AsyncSession, user_id: UUID, session_id: int -) -> CommitteeAssignment | None: - """Get a user's assignment for the committee that owns a session.""" - query = text(""" - SELECT - ca.user_id, - ca.committee_id, - ca.role, - ca.representation_id - FROM public.sessions s - JOIN public.committee_assignments ca - ON ca.committee_id = s.committee_id - WHERE s.id = :session_id - AND ca.user_id = :user_id - """) - - result = await session.execute( - query, {"session_id": session_id, "user_id": user_id} - ) - row = result.mappings().one_or_none() - if row is None: - return None - - return CommitteeAssignment( - user_id=row["user_id"], - committee_id=row["committee_id"], - role=row["role"], - representation_id=row["representation_id"], - ) diff --git a/backend/app/access/schemas.py b/backend/app/access/schemas.py deleted file mode 100644 index 864dfb0..0000000 --- a/backend/app/access/schemas.py +++ /dev/null @@ -1,8 +0,0 @@ -from pydantic import BaseModel - -from . import enums - - -class SessionRepresentation(BaseModel): - role: enums.SessionRoles - representation_id: int | None diff --git a/backend/app/access/service.py b/backend/app/access/service.py deleted file mode 100644 index 12e4fc4..0000000 --- a/backend/app/access/service.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Literal -from uuid import UUID - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.access.models import CommitteeAssignment - -from .repository import get_committee_assignment, get_session_assignment - - -class AccessDenied(Exception): ... - - -async def resolve_committee_assignment( - session: AsyncSession, - user_id: UUID, - committee_id: int, -) -> CommitteeAssignment: - assignment: CommitteeAssignment | None = await get_committee_assignment( - session, user_id, committee_id - ) - - if assignment is None: - raise AccessDenied("User has no committee assignment") - - if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDenied("Delegate role has no delegation id") - - return assignment - - -async def resolve_session_assignment( - session: AsyncSession, - user_id: UUID, - session_id: int, -) -> CommitteeAssignment: - """Resolve the assignment for a session without trusting client committee data.""" - assignment = await get_session_assignment(session, user_id, session_id) - - if assignment is None: - raise AccessDenied("User has no assignment for this session") - - if assignment.role == "delegate" and assignment.representation_id is None: - raise AccessDenied("Delegate role has no delegation id") - - return assignment - - -async def verify_user_role( - session: AsyncSession, - user_id: UUID, - committee_id: int, - required_role: Literal["chair", "delegate"], -) -> CommitteeAssignment: - """Require a user's role within one specific committee.""" - assignment = await get_committee_assignment(session, user_id, committee_id) - - if assignment is None: - raise AccessDenied("User has no committee assignment") - - if assignment.role != required_role: - raise AccessDenied(f"User requires the {required_role} role for this committee") - - return assignment diff --git a/backend/app/access/views.py b/backend/app/access/views.py deleted file mode 100644 index 91975ac..0000000 --- a/backend/app/access/views.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.ext.asyncio import AsyncSession - -from app.auth.dep import get_current_user -from app.auth.service import AuthUser -from app.core.database import get_db_session - -from .schemas import SessionRepresentation -from .service import AccessDenied, resolve_session_assignment - -router = APIRouter() - - -@router.get("/sessions/{session_id}/me") -async def get_my_session_access( - session_id: int, - db_session: Annotated[AsyncSession, Depends(get_db_session)], - current_user: Annotated[AuthUser, Depends(get_current_user)], -) -> SessionRepresentation: - """Return the authenticated user's actor context for a session.""" - try: - assignment = await resolve_session_assignment( - db_session, current_user.user_id, session_id - ) - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - - return SessionRepresentation( - role=assignment.role, representation_id=assignment.representation_id - ) diff --git a/backend/app/conference/models.py b/backend/app/conference/models.py new file mode 100644 index 0000000..75fe81f --- /dev/null +++ b/backend/app/conference/models.py @@ -0,0 +1,24 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ConferenceAssignment(BaseModel): + """Holds information about a user's role, enrollment, and representation.""" + + model_config = ConfigDict(from_attributes=True) + + id: int | None = None + conference_id: int | None = None + user_id: UUID | None = None + name: str | None = None + email: str | None = None + institution: str | None = None + role: str + committee_id: int | None = None + representation_id: int | None = None + created_at: datetime | None = None + + + diff --git a/backend/app/conference/repository.py b/backend/app/conference/repository.py new file mode 100644 index 0000000..f8e4a33 --- /dev/null +++ b/backend/app/conference/repository.py @@ -0,0 +1,316 @@ +from typing import Any +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.conference import schemas +from app.conference.models import ConferenceAssignment + + +async def create_conference( + session: AsyncSession, user_id: UUID, payload: schemas.ConferenceCreate +) -> int: + """Create a new conference and return its id.""" + query = text(""" + INSERT INTO public.conferences + (name, slug, owner_id, location, logo, color, start_date, end_date) + VALUES ( + :name, + :slug, + :owner_id, + :location, + :logo, + :color, + :start_date, + :end_date + ) + RETURNING id + """) + + result = await session.execute( + query, + { + "name": payload.name, + "slug": payload.slug, + "owner_id": user_id, + "location": payload.location, + "logo": payload.logo, + "color": payload.color, + "start_date": payload.start_date, + "end_date": payload.end_date, + }, + ) + row = result.mappings().one() + return int(row["id"]) + + +async def get_user_conferences( + session: AsyncSession, + user_id: UUID, +) -> list[dict[str, Any]]: + """Return summaries of conferences where the user is an owner or member.""" + query = text(""" + SELECT + c.id, + c.name, + c.logo, + c.color, + c.status, + CASE + WHEN c.owner_id = :user_id THEN 'owner' + ELSE ( + SELECT ca.role::text + FROM public.conference_assignments ca + WHERE ca.conference_id = c.id AND ca.user_id = :user_id + ORDER BY + (ca.committee_id IS NULL) DESC, + ca.created_at ASC + LIMIT 1 + ) + END AS caller_role + FROM public.conferences c + WHERE c.owner_id = :user_id + OR EXISTS ( + SELECT 1 + FROM public.conference_assignments ca + WHERE ca.conference_id = c.id AND ca.user_id = :user_id + ) + ORDER BY c.id ASC + """) + + result = await session.execute(query, {"user_id": user_id}) + return [dict(row) for row in result.mappings().all()] + + +async def get_conference_by_id( + session: AsyncSession, + conference_id: int, +) -> dict[str, Any] | None: + """Fetch basic conference data by ID.""" + query = text(""" + SELECT id, name, slug, status, owner_id, location, logo, color, start_date, end_date + FROM public.conferences + WHERE id = :conference_id + """) + result = await session.execute(query, {"conference_id": conference_id}) + row = result.mappings().one_or_none() + return dict(row) if row else None + + +async def list_committees_for_conference( + session: AsyncSession, + conference_id: int, +) -> list[dict[str, Any]]: + """Fetch all committees belonging to a conference.""" + query = text(""" + SELECT id, conference_id, name, code, logo, topic, status, created_at + FROM public.committees + WHERE conference_id = :conference_id + ORDER BY id ASC + """) + result = await session.execute(query, {"conference_id": conference_id}) + return [dict(r) for r in result.mappings().all()] + + +async def get_user_conference_role( + session: AsyncSession, + user_id: UUID, + conference_id: int, +) -> str | None: + """Return the user's role in the conference ('owner', 'admin', 'chair', etc.) or None if unauthorized.""" + query = text(""" + SELECT + CASE + WHEN c.owner_id = :user_id THEN 'owner' + ELSE ca.role::text + END AS role + FROM public.conferences c + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = c.id AND ca.user_id = :user_id + WHERE c.id = :conference_id + AND (c.owner_id = :user_id OR ca.user_id = :user_id) + LIMIT 1 + """) + result = await session.execute( + query, {"conference_id": conference_id, "user_id": user_id} + ) + row = result.mappings().one_or_none() + return row["role"] if row else None + + +async def create_committee( + session: AsyncSession, + conference_id: int, + payload: schemas.CommitteeCreate, +) -> dict[str, Any]: + """Create a new committee inside a conference.""" + query = text(""" + INSERT INTO public.committees + (conference_id, name, code, logo, topic, status) + VALUES + (:conference_id, :name, :code, :logo, :topic, :status) + RETURNING id, conference_id, name, code, logo, topic, status, created_at + """) + + result = await session.execute( + query, + { + "conference_id": conference_id, + "name": payload.name, + "code": payload.code, + "logo": payload.logo, + "topic": payload.topic, + "status": payload.status, + }, + ) + return dict(result.mappings().one()) + + +async def enroll_member( + session: AsyncSession, + conference_id: int, + payload: schemas.EnrollMember, +) -> dict[str, Any]: + """Enroll or assign a member to a conference and auto-link user_id if matching email exists.""" + query = text(""" + INSERT INTO public.conference_assignments + (conference_id, user_id, name, email, institution, role, committee_id, representation_id) + VALUES ( + :conference_id, + (SELECT id FROM auth.users WHERE email = :email LIMIT 1), + :name, + :email, + :institution, + :role::conference_role, + :committee_id, + :representation_id + ) + RETURNING id, conference_id, user_id, name, email, institution, role, committee_id, representation_id, created_at + """) + + result = await session.execute( + query, + { + "conference_id": conference_id, + "name": payload.name, + "email": payload.email, + "institution": payload.institution, + "role": payload.role, + "committee_id": payload.committee_id, + "representation_id": payload.representation_id, + }, + ) + return dict(result.mappings().one()) + + +async def list_conference_members( + session: AsyncSession, + conference_id: int, +) -> list[dict[str, Any]]: + """List all members/assignments enrolled in a conference.""" + query = text(""" + SELECT + id, conference_id, user_id, name, email, institution, role, + committee_id, representation_id, created_at + FROM public.conference_assignments + WHERE conference_id = :conference_id + ORDER BY created_at ASC + """) + result = await session.execute(query, {"conference_id": conference_id}) + return [dict(r) for r in result.mappings().all()] + + +def _assignment_from_row(row: Any) -> ConferenceAssignment: + return ConferenceAssignment( + user_id=row["user_id"], + conference_id=row["conference_id"], + committee_id=row["committee_id"], + role=row["role"], + representation_id=row["representation_id"], + ) + + +async def get_committee_assignment( + session: AsyncSession, + user_id: UUID, + committee_id: int, +) -> ConferenceAssignment | None: + """Fetch a user's assignment for one committee.""" + query = text(""" + SELECT + :user_id AS user_id, + c.id AS committee_id, + conf.id AS conference_id, + CASE + WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' + WHEN ca.role = 'chair' THEN 'chair' + ELSE 'delegate' + END AS role, + ca.representation_id + FROM public.committees c + JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id + AND (ca.committee_id = c.id OR ca.committee_id IS NULL) + WHERE c.id = :committee_id + ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC + LIMIT 1 + """) + + result = await session.execute( + query, + { + "committee_id": committee_id, + "user_id": user_id, + }, + ) + row = result.mappings().one_or_none() + if row is None: + return None + + return _assignment_from_row(row) + + +async def get_session_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> ConferenceAssignment | None: + """Fetch a user's assignment for one committee session.""" + query = text(""" + SELECT + :user_id AS user_id, + c.id AS committee_id, + conf.id AS conference_id, + CASE + WHEN conf.owner_id = :user_id OR ca.role = 'admin' THEN 'chair' + WHEN ca.role = 'chair' THEN 'chair' + ELSE 'delegate' + END AS role, + ca.representation_id + FROM public.sessions s + JOIN public.committees c ON c.id = s.committee_id + JOIN public.conferences conf ON conf.id = c.conference_id + LEFT JOIN public.conference_assignments ca + ON ca.conference_id = conf.id + AND ca.user_id = :user_id + AND (ca.committee_id = c.id OR ca.committee_id IS NULL) + WHERE s.id = :session_id + ORDER BY (ca.committee_id = c.id) DESC, (ca.role = 'admin') DESC + LIMIT 1 + """) + + result = await session.execute( + query, + { + "session_id": session_id, + "user_id": user_id, + }, + ) + row = result.mappings().one_or_none() + if row is None: + return None + + return _assignment_from_row(row) diff --git a/backend/app/conference/schemas.py b/backend/app/conference/schemas.py new file mode 100644 index 0000000..3a3d1ba --- /dev/null +++ b/backend/app/conference/schemas.py @@ -0,0 +1,82 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ConferenceCreate(BaseModel): + """Schema for conference creation""" + + name: str + slug: str | None = None + location: str | None = None + logo: str | None = None + color: str = "#0f172a" + start_date: datetime + end_date: datetime + + +class CommitteeCreate(BaseModel): + """Schema for creating a committee""" + + name: str + code: str + logo: str | None = None + topic: str | None = None + status: str = "planned" + + +class CommitteeResponse(BaseModel): + """Schema for returning committee details""" + + model_config = ConfigDict(from_attributes=True) + + id: int + conference_id: int + name: str + code: str + logo: str | None = None + topic: str | None = None + status: str + created_at: datetime | None = None + + +class ConferenceSummary(BaseModel): + """Conference data needed to select a conference in the dashboard.""" + + id: int + name: str + logo: str | None = None + color: str + status: str + caller_role: str + + +class ConferenceDetail(BaseModel): + """Detailed conference information including its committees""" + + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + slug: str | None = None + status: str + owner_id: UUID + location: str | None = None + logo: str | None = None + color: str + start_date: datetime + end_date: datetime + caller_role: str | None = None + committees: list[CommitteeResponse] = [] + + +class EnrollMember(BaseModel): + """Schema for enrolling/assigning a user into a conference""" + + name: str + email: str + institution: str | None = None + role: str = "participant" + committee_id: int | None = None + representation_id: int | None = None diff --git a/backend/app/conference/service.py b/backend/app/conference/service.py new file mode 100644 index 0000000..51421cf --- /dev/null +++ b/backend/app/conference/service.py @@ -0,0 +1,176 @@ +from typing import Any, Literal +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +import app.conference.repository as repository +import app.conference.schemas as schemas +from app.conference.models import ConferenceAssignment +from app.core.exceptions import AccessDeniedError, NotFoundError + + +async def create_conference( + session: AsyncSession, user_id: UUID, payload: schemas.ConferenceCreate +) -> int: + """Create a new conference.""" + conference_id = await repository.create_conference( + session=session, user_id=user_id, payload=payload + ) + await session.commit() + return conference_id + + +async def get_user_conferences( + session: AsyncSession, user_id: UUID +) -> list[dict[str, Any]]: + """Get dashboard summaries for all conferences available to a user.""" + return await repository.get_user_conferences(session=session, user_id=user_id) + + +async def get_conference_info( + session: AsyncSession, user_id: UUID, conference_id: int +) -> dict[str, Any]: + """Get conference detail using split verification and fetch queries.""" + # Query 1: Verification + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role is None: + raise NotFoundError(f"Conference with id {conference_id} not found") + + # Query 2: Fetch Conference & Committees + conf = await repository.get_conference_by_id( + session=session, conference_id=conference_id + ) + if conf is None: + raise NotFoundError(f"Conference with id {conference_id} not found") + + committees = await repository.list_committees_for_conference( + session=session, conference_id=conference_id + ) + + conf["caller_role"] = user_role + conf["committees"] = committees + return conf + + +async def create_committee( + session: AsyncSession, + user_id: UUID, + conference_id: int, + payload: schemas.CommitteeCreate, +) -> dict[str, Any]: + """Create a committee within a conference (requires owner/admin role).""" + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role not in ("owner", "admin"): + raise AccessDeniedError( + "Only conference owners and admins can create committees" + ) + + committee = await repository.create_committee( + session=session, conference_id=conference_id, payload=payload + ) + await session.commit() + return committee + + +async def enroll_member( + session: AsyncSession, + user_id: UUID, + conference_id: int, + payload: schemas.EnrollMember, +) -> dict[str, Any]: + """Enroll a member or delegate into a conference (requires owner/admin role).""" + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role not in ("owner", "admin"): + raise AccessDeniedError( + "Only conference owners and admins can enroll members" + ) + + assignment = await repository.enroll_member( + session=session, conference_id=conference_id, payload=payload + ) + await session.commit() + return assignment + + +async def list_conference_members( + session: AsyncSession, + user_id: UUID, + conference_id: int, +) -> list[dict[str, Any]]: + """List all enrolled members in a conference for an authorized user.""" + user_role = await repository.get_user_conference_role( + session=session, user_id=user_id, conference_id=conference_id + ) + if user_role is None: + raise NotFoundError(f"Conference with id {conference_id} not found") + + return await repository.list_conference_members( + session=session, conference_id=conference_id + ) + + +def _require_valid_assignment( + assignment: ConferenceAssignment | None, +) -> ConferenceAssignment: + """Validate that an assignment grants a usable committee identity.""" + if assignment is None: + raise AccessDeniedError("User has no assignment for this context") + + if assignment.role == "delegate" and assignment.representation_id is None: + raise AccessDeniedError("Delegate role has no delegation id") + + return assignment + + +async def resolve_committee_assignment( + session: AsyncSession, + user_id: UUID, + committee_id: int, +) -> ConferenceAssignment: + """Resolve a user's assignment for one committee.""" + assignment = await repository.get_committee_assignment( + session=session, + user_id=user_id, + committee_id=committee_id, + ) + return _require_valid_assignment(assignment) + + +async def resolve_session_assignment( + session: AsyncSession, + user_id: UUID, + session_id: int, +) -> ConferenceAssignment: + """Resolve a user's assignment for one committee session.""" + assignment = await repository.get_session_assignment( + session=session, + user_id=user_id, + session_id=session_id, + ) + return _require_valid_assignment(assignment) + + +async def verify_user_role( + session: AsyncSession, + user_id: UUID, + committee_id: int, + required_role: Literal["chair", "delegate"], +) -> ConferenceAssignment: + """Verify and require that a user has a specific role for a committee.""" + assignment = await resolve_committee_assignment( + session=session, user_id=user_id, committee_id=committee_id + ) + + if assignment.role != required_role: + raise AccessDeniedError( + f"User requires the {required_role} role for this committee" + ) + + return assignment + diff --git a/backend/app/conference/views.py b/backend/app/conference/views.py new file mode 100644 index 0000000..9847d6b --- /dev/null +++ b/backend/app/conference/views.py @@ -0,0 +1,118 @@ +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +import app.conference.schemas as schemas +import app.conference.service as service +import app.session.schemas as session_schemas +from app.auth.dep import get_current_user +from app.auth.service import AuthUser +from app.conference.models import ConferenceAssignment +from app.core.database import get_db_session + +router = APIRouter() + + +@router.post("/", status_code=status.HTTP_201_CREATED) +async def create_conference( + payload: schemas.ConferenceCreate, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> dict[str, Any]: + """Endpoint to create a new conference""" + conference_id = await service.create_conference( + session=db_session, user_id=current_user.user_id, payload=payload + ) + return {"id": conference_id, "status": "Created"} + + +@router.get("/") +async def get_user_conferences( + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> list[schemas.ConferenceSummary]: + """Return dashboard summaries for the authenticated user's conferences.""" + return await service.get_user_conferences( + session=db_session, user_id=current_user.user_id + ) + + +@router.get("/{id}") +async def get_conference_info( + id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> schemas.ConferenceDetail: + """Endpoint to get detailed conference information and committees""" + return await service.get_conference_info( + session=db_session, user_id=current_user.user_id, conference_id=id + ) + + +@router.post( + "/{id}/committees", + status_code=status.HTTP_201_CREATED, +) +async def create_committee( + id: int, + committee: schemas.CommitteeCreate, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> schemas.CommitteeResponse: + """Endpoint to create a new committee in a conference""" + return await service.create_committee( + session=db_session, + user_id=current_user.user_id, + conference_id=id, + payload=committee, + ) + + +@router.post( + "/{conference_id}/members", + status_code=status.HTTP_201_CREATED, +) +async def enroll_member( + conference_id: int, + member: schemas.EnrollMember, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> ConferenceAssignment: + """Endpoint to enroll/assign a member to a conference""" + return await service.enroll_member( + session=db_session, + user_id=current_user.user_id, + conference_id=conference_id, + payload=member, + ) + + +@router.get("/{conference_id}/members") +async def list_conference_members( + conference_id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> list[ConferenceAssignment]: + """Endpoint to list all members assigned to a conference""" + return await service.list_conference_members( + session=db_session, + user_id=current_user.user_id, + conference_id=conference_id, + ) + + +@router.get("/sessions/{session_id}/me") +async def get_my_session_access( + session_id: int, + db_session: Annotated[AsyncSession, Depends(get_db_session)], + current_user: Annotated[AuthUser, Depends(get_current_user)], +) -> session_schemas.SessionRepresentation: + """Return the authenticated user's assignment context for a session.""" + assignment = await service.resolve_session_assignment( + session=db_session, user_id=current_user.user_id, session_id=session_id + ) + return session_schemas.SessionRepresentation( + role=assignment.role, + representation_id=assignment.representation_id, + ) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index dea867f..78c9cf8 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -11,6 +11,12 @@ from app.core.config import Settings +class RepositoryError(Exception): + """Base exception for all repository issues""" + + pass + + def create_db( settings: Settings, ) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..5f8b39d --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,46 @@ +class AppException(Exception): + """Base exception""" + + status_code = 500 + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class NotFoundError(AppException): + """Resource does not exist""" + + status_code = 404 + + pass + + +class AccessDeniedError(AppException): + """User does not have permission""" + + status_code = 403 + + pass + + +class ConflictError(AppException): + """Duplicate entity or invalid state transition""" + + status_code = 409 + + pass + + +class BadRequest(AppException): + """Request payload or requested operation is invalid.""" + + status_code = 400 + + pass + + +class InternalServerError(AppException): + """An application operation failed unexpectedly.""" + + pass diff --git a/backend/app/main.py b/backend/app/main.py index 191882c..c4b7973 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,13 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi -from app.access.views import router as access_router +from fastapi.responses import JSONResponse + +import app.core.exceptions as exceptions +from app.conference.views import router as conference_router from app.core.config import get_settings from app.core.database import create_db from app.core.openapi import add_websocket_message_schemas @@ -37,7 +40,19 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# CORS config for Vite +# --- Exception Handlers + + +@app.exception_handler(exceptions.AppException) +async def app_exception_handler(request: Request, exc: exceptions.AppException): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.message}, + ) + + +# --- Middlewares + app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], @@ -45,9 +60,10 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -# include commitees here? +# --- Routes + +app.include_router(conference_router, prefix="/conferences", tags=["conferences"]) app.include_router(session_router, prefix="/committees", tags=["committees"]) -app.include_router(access_router, prefix="/access", tags=["access"]) def custom_openapi(): diff --git a/backend/app/session/repository.py b/backend/app/session/repository.py index c69648d..c3800cb 100644 --- a/backend/app/session/repository.py +++ b/backend/app/session/repository.py @@ -4,16 +4,10 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.access.models import CommitteeAssignment +from app.core.database import RepositoryError from app.session.models import DelegationContext, StoredSession -class RepositoryError(Exception): - """Base exception for all repository issues""" - - pass - - async def create_session( session: AsyncSession, committee_id: int, @@ -98,31 +92,6 @@ async def update_session_info( raise RepositoryError("Session update failed") from exc -async def bulk_insert_assignments( - session: AsyncSession, delegations: list[CommitteeAssignment] -): - """Inserts via bulk all (uuid, delegation, session_id) rows""" - params = [ - { - "user_id": d.user_id, - "session_id": d.committee_id, - "role": d.role, - "delegation_id": d.representation_id, - } - for d in delegations - ] - - query = text(""" - INSERT INTO public.committee_assignments (user_id, committee_id, role, representation_id) - VALUES (:user_id, :committee_id, :role, :representation_id) - """) - - await session.execute( - query, - params, - ) - - # TODO: pass this out to conferences/ domain async def bulk_get_delegation_context( session: AsyncSession, diff --git a/backend/app/session/schemas.py b/backend/app/session/schemas.py index e4ee663..04fe0a9 100644 --- a/backend/app/session/schemas.py +++ b/backend/app/session/schemas.py @@ -1,3 +1,4 @@ +from enum import StrEnum from typing import Annotated, Literal from uuid import UUID @@ -15,6 +16,20 @@ class SessionCreationSchema(BaseModel): name: str | None = None +class SessionRoles(StrEnum): + """Roles available to a participant in an active committee session.""" + + CHAIR = "chair" + DELEGATE = "delegate" + + +class SessionRepresentation(BaseModel): + """Session-specific access context for the authenticated user.""" + + role: SessionRoles + representation_id: int | None = None + + class MotionPayload(BaseModel): """General motion payload. Used on Delegate and Chair payloads""" diff --git a/backend/app/session/service.py b/backend/app/session/service.py index cc2e997..c0804b3 100644 --- a/backend/app/session/service.py +++ b/backend/app/session/service.py @@ -12,7 +12,14 @@ import app.session.enums as enums import app.session.repository as repository import app.session.schemas as schemas -from app.access.models import CommitteeAssignment +from app.conference.models import ConferenceAssignment +from app.core.database import RepositoryError +from app.core.exceptions import ( + BadRequest, + ConflictError, + InternalServerError, + NotFoundError, +) from app.session.engine import EventRejectedError, SessionEngine from .manager import ConnectionManager @@ -28,18 +35,10 @@ class ActorResolutionError(Exception): pass -class SessionCreationError(Exception): - pass - - class SessionFetchError(Exception): pass -class SessionUpdateError(Exception): - pass - - def build_actor( user_id: UUID, manager: ConnectionManager, @@ -91,34 +90,41 @@ async def create_session_service( ) if session_id is None: - raise SessionCreationError("Could not create session with given schema") + raise BadRequest("Could not create session with given schema") await session.commit() return session_id +async def get_session_for_activation( + session: AsyncSession, committee_session_id: int +): + """Fetch a session to authorize and activate, or report that it is absent.""" + stored = await repository.get_session_info( + session=session, committee_session_id=committee_session_id + ) + if stored is None: + raise NotFoundError("Session not found") + return stored + + async def activate_session( session: AsyncSession, manager: ConnectionManager, committee_session_id: int, ): """Activate a planned session""" - stored = await repository.get_session_info( - session=session, committee_session_id=committee_session_id - ) - - if stored is None: - raise SessionFetchError("Could not fetch session info") + stored = await get_session_for_activation(session, committee_session_id) if stored.status != "planned": - raise SessionFetchError("Session already started") + raise ConflictError("Session already started") delegations = await repository.bulk_get_delegation_context( session=session, committee_id=stored.committee_id ) if delegations is None: - raise SessionFetchError("Could not fetch session delegations info") + raise ConflictError("Session delegations are unavailable") live_state = SessionLiveState( session_id=stored.id, @@ -138,8 +144,8 @@ async def activate_session( try: await repository.update_session_info(session=session, session_info=updated) - except repository.RepositoryError: - raise SessionUpdateError("Could not update session info") from None + except RepositoryError: + raise InternalServerError("Could not update session info") from None await session.commit() @@ -167,7 +173,7 @@ async def prepare_session_connect( session: AsyncSession, manager: ConnectionManager, committee_session_id: int, - assignment: CommitteeAssignment, + assignment: ConferenceAssignment, ) -> SessionActor: """Service that prepares for session connect. Primarily used as a fallback in case the SessionLiveState is not in manager diff --git a/backend/app/session/views.py b/backend/app/session/views.py index cbe7f1b..a2b4341 100644 --- a/backend/app/session/views.py +++ b/backend/app/session/views.py @@ -12,14 +12,11 @@ WebSocketDisconnect, status, ) -from fastapi.exceptions import HTTPException from pydantic import ValidationError from sqlalchemy.ext.asyncio import AsyncSession -import app.access.service as access -import app.session.repository as repository +import app.conference.service as conference_service import app.session.service as service -from app.access.service import AccessDenied from app.auth.dep import get_current_user from app.auth.service import ( AuthUser, @@ -30,6 +27,7 @@ from app.core.config import Settings, get_settings from app.core.database import get_db_session from app.core.dep import get_connection_manager, get_logger, get_session_engine +from app.core.exceptions import AccessDeniedError from app.session.engine import EventRejectedError, SessionEngine from app.session.enums import EventErrorCode from app.session.manager import ConnectionManager @@ -58,30 +56,17 @@ async def create_session_endpoint( current_user: Annotated[AuthUser, Depends(get_current_user)], ): """POST endpoint to create a new session""" - try: - await access.verify_user_role( - session=session, - user_id=current_user.user_id, - committee_id=session_schema.committee_id, - required_role="chair", - ) - - res = await service.create_session_service( - session=session, - session_schema=session_schema, - ) - return {"id": res, "status": "Created"} - - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - except service.SessionCreationError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc + await conference_service.verify_user_role( + session=session, + user_id=current_user.user_id, + committee_id=session_schema.committee_id, + required_role="chair", + ) + session_id = await service.create_session_service( + session=session, + session_schema=session_schema, + ) + return {"id": session_id, "status": "Created"} @router.post("/{session_id}/activate", status_code=status.HTTP_204_NO_CONTENT) @@ -92,41 +77,18 @@ async def activate_session_endpoint( current_user: Annotated[AuthUser, Depends(get_current_user)], ): """Endpoint to activate a planned session""" - try: - stored = await repository.get_session_info( - session=db_session, - committee_session_id=session_id, - ) - if stored is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Session not found", - ) - - await access.verify_user_role( - session=db_session, - user_id=current_user.user_id, - committee_id=stored.committee_id, - required_role="chair", - ) - - await service.activate_session( - session=db_session, manager=manager, committee_session_id=session_id - ) - except AccessDenied as exc: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=str(exc), - ) from exc - except service.SessionFetchError as exc: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(exc), - ) from exc - except service.SessionUpdateError as exc: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc) - ) from exc + stored = await service.get_session_for_activation( + session=db_session, committee_session_id=session_id + ) + await conference_service.verify_user_role( + session=db_session, + user_id=current_user.user_id, + committee_id=stored.committee_id, + required_role="chair", + ) + await service.activate_session( + session=db_session, manager=manager, committee_session_id=session_id + ) @router.websocket("/ws/{session_id}") @@ -156,10 +118,9 @@ async def websocket_endpoint( settings=settings, token=validated_auth_data.access_token ) - # The session determines its committee; never accept it from the client. session_factory = websocket.app.state.db_session_factory async with session_factory() as db: - assignment = await access.resolve_session_assignment( + assignment = await conference_service.resolve_session_assignment( session=db, user_id=auth_user.user_id, session_id=session_id ) @@ -176,9 +137,10 @@ async def websocket_endpoint( data = await websocket.receive_json() try: validated_event = EventMessage.model_validate(data) - except ValidationError as exc: + except ValidationError: message = EventRejectedMessage( - code=EventErrorCode.INVALID_MESSAGE, message=str(exc) + code=EventErrorCode.INVALID_MESSAGE, + message="Invalid event message", ) await manager.send_message( session_id=session_id, message=message, websocket=websocket @@ -221,7 +183,7 @@ async def websocket_endpoint( except ( TokenExpiredError, TokenInvalidError, - AccessDenied, + AccessDeniedError, service.ActorResolutionError, service.SessionFetchError, ValidationError, @@ -230,7 +192,7 @@ async def websocket_endpoint( reason = "token_expired" elif isinstance(exc, TokenInvalidError): reason = "token_invalid" - elif isinstance(exc, AccessDenied): + elif isinstance(exc, AccessDeniedError): reason = "access_denied" elif isinstance(exc, service.SessionFetchError): reason = "session_unavailable" diff --git a/backend/app/tests/access/test_service.py b/backend/app/tests/access/test_service.py deleted file mode 100644 index e3ab09d..0000000 --- a/backend/app/tests/access/test_service.py +++ /dev/null @@ -1,97 +0,0 @@ -from uuid import uuid4 - -import pytest - -from app.access.models import CommitteeAssignment -from app.access.service import ( - AccessDenied, - resolve_committee_assignment, - verify_user_role, -) - - -@pytest.mark.anyio -async def test_denies_user_without_assignment(monkeypatch): - async def no_assignment(*_args, **_kwargs): - return None - - monkeypatch.setattr("app.access.service.get_committee_assignment", no_assignment) - - with pytest.raises(AccessDenied, match="no committee assignment"): - await resolve_committee_assignment(object(), uuid4(), 1) - - -@pytest.mark.anyio -async def test_denies_delegate_without_delegation(monkeypatch): - async def invalid_assignment(*_args, **_kwargs): - return CommitteeAssignment( - user_id=uuid4(), - committee_id=1, - role="delegate", - representation_id=None, - ) - - monkeypatch.setattr( - "app.access.service.get_committee_assignment", invalid_assignment - ) - - with pytest.raises(AccessDenied, match="no delegation id"): - await resolve_committee_assignment(object(), uuid4(), 1) - - -@pytest.mark.anyio -async def test_returns_valid_assignment(monkeypatch): - assignment = CommitteeAssignment( - user_id=uuid4(), - committee_id=1, - role="chair", - representation_id=None, - ) - - async def valid_assignment(*_args, **_kwargs): - return assignment - - monkeypatch.setattr("app.access.service.get_committee_assignment", valid_assignment) - - result = await resolve_committee_assignment(object(), assignment.user_id, 1) - - assert result is assignment - - -@pytest.mark.anyio -async def test_role_check_denies_a_delegate_when_a_chair_is_required(monkeypatch): - assignment = CommitteeAssignment( - user_id=uuid4(), - committee_id=1, - role="delegate", - representation_id=3, - ) - - async def delegate_assignment(*_args, **_kwargs): - return assignment - - monkeypatch.setattr( - "app.access.service.get_committee_assignment", delegate_assignment - ) - - with pytest.raises(AccessDenied, match="requires the chair role"): - await verify_user_role(object(), assignment.user_id, 1, "chair") - - -@pytest.mark.anyio -async def test_role_check_returns_matching_assignment(monkeypatch): - assignment = CommitteeAssignment( - user_id=uuid4(), - committee_id=1, - role="chair", - representation_id=None, - ) - - async def chair_assignment(*_args, **_kwargs): - return assignment - - monkeypatch.setattr("app.access.service.get_committee_assignment", chair_assignment) - - assert ( - await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment - ) diff --git a/backend/app/tests/access/__init__.py b/backend/app/tests/conference/__init__.py similarity index 100% rename from backend/app/tests/access/__init__.py rename to backend/app/tests/conference/__init__.py diff --git a/backend/app/tests/conference/test_service.py b/backend/app/tests/conference/test_service.py new file mode 100644 index 0000000..d50f12e --- /dev/null +++ b/backend/app/tests/conference/test_service.py @@ -0,0 +1,306 @@ +from unittest.mock import AsyncMock +from uuid import uuid4 +from datetime import datetime + +import pytest + +import app.conference.service as conference_service +from app.conference.models import ConferenceAssignment +from app.conference.schemas import CommitteeCreate, ConferenceCreate, EnrollMember +from app.conference.service import ( + resolve_committee_assignment, + resolve_session_assignment, + verify_user_role, +) +from app.core.exceptions import AccessDeniedError, NotFoundError + + +@pytest.mark.anyio +async def test_denies_user_without_assignment(monkeypatch): + async def no_assignment(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "app.conference.service.repository.get_committee_assignment", no_assignment + ) + + with pytest.raises(AccessDeniedError, match="no assignment"): + await resolve_committee_assignment(object(), uuid4(), committee_id=1) + + +@pytest.mark.anyio +async def test_denies_delegate_without_delegation(monkeypatch): + async def invalid_assignment(*_args, **_kwargs): + return ConferenceAssignment( + user_id=uuid4(), + committee_id=1, + role="delegate", + representation_id=None, + ) + + monkeypatch.setattr( + "app.conference.service.repository.get_committee_assignment", invalid_assignment + ) + + with pytest.raises(AccessDeniedError, match="no delegation id"): + await resolve_committee_assignment(object(), uuid4(), committee_id=1) + + +@pytest.mark.anyio +async def test_returns_valid_assignment(monkeypatch): + assignment = ConferenceAssignment( + user_id=uuid4(), + committee_id=1, + role="chair", + representation_id=None, + ) + + async def valid_assignment(*_args, **_kwargs): + return assignment + + monkeypatch.setattr( + "app.conference.service.repository.get_committee_assignment", valid_assignment + ) + + result = await resolve_committee_assignment( + object(), assignment.user_id, committee_id=1 + ) + + assert result is assignment + + +@pytest.mark.anyio +async def test_resolves_assignment_for_a_session(monkeypatch): + assignment = ConferenceAssignment( + user_id=uuid4(), + committee_id=1, + role="chair", + representation_id=None, + ) + get_session_assignment = AsyncMock(return_value=assignment) + monkeypatch.setattr( + "app.conference.service.repository.get_session_assignment", + get_session_assignment, + ) + session = object() + + result = await resolve_session_assignment( + session, assignment.user_id, session_id=9 + ) + + assert result is assignment + get_session_assignment.assert_awaited_once_with( + session=session, user_id=assignment.user_id, session_id=9 + ) + + +@pytest.mark.anyio +async def test_role_check_denies_a_delegate_when_a_chair_is_required(monkeypatch): + assignment = ConferenceAssignment( + user_id=uuid4(), + committee_id=1, + role="delegate", + representation_id=3, + ) + + async def delegate_assignment(*_args, **_kwargs): + return assignment + + monkeypatch.setattr( + "app.conference.service.repository.get_committee_assignment", delegate_assignment + ) + + with pytest.raises(AccessDeniedError, match="requires the chair role"): + await verify_user_role(object(), assignment.user_id, 1, "chair") + + +@pytest.mark.anyio +async def test_role_check_returns_matching_assignment(monkeypatch): + assignment = ConferenceAssignment( + user_id=uuid4(), + committee_id=1, + role="chair", + representation_id=None, + ) + + async def chair_assignment(*_args, **_kwargs): + return assignment + + monkeypatch.setattr( + "app.conference.service.repository.get_committee_assignment", chair_assignment + ) + + assert ( + await verify_user_role(object(), assignment.user_id, 1, "chair") is assignment + ) + + +@pytest.mark.anyio +async def test_get_conference_info_denies_an_unassigned_user(monkeypatch): + get_role = AsyncMock(return_value=None) + get_conference = AsyncMock() + monkeypatch.setattr( + conference_service.repository, "get_user_conference_role", get_role + ) + monkeypatch.setattr( + conference_service.repository, "get_conference_by_id", get_conference + ) + + with pytest.raises(NotFoundError, match="Conference with id 7 not found"): + await conference_service.get_conference_info(object(), uuid4(), 7) + + get_conference.assert_not_awaited() + + +@pytest.mark.anyio +async def test_get_conference_info_combines_role_and_committees(monkeypatch): + user_id = uuid4() + conference = {"id": 7, "name": "WebMUN", "status": "planned"} + committees = [{"id": 3, "name": "Security Council"}] + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="chair"), + ) + monkeypatch.setattr( + conference_service.repository, + "get_conference_by_id", + AsyncMock(return_value=conference), + ) + monkeypatch.setattr( + conference_service.repository, + "list_committees_for_conference", + AsyncMock(return_value=committees), + ) + + result = await conference_service.get_conference_info(object(), user_id, 7) + + assert result == { + "id": 7, + "name": "WebMUN", + "status": "planned", + "caller_role": "chair", + "committees": committees, + } + + +@pytest.mark.anyio +async def test_create_committee_requires_a_conference_manager(monkeypatch): + create_committee = AsyncMock() + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="delegate"), + ) + monkeypatch.setattr( + conference_service.repository, "create_committee", create_committee + ) + + with pytest.raises(AccessDeniedError, match="owners and admins"): + await conference_service.create_committee( + object(), uuid4(), 7, CommitteeCreate(name="Security Council", code="SC") + ) + + create_committee.assert_not_awaited() + + +@pytest.mark.anyio +async def test_create_committee_forwards_an_authorized_request(monkeypatch): + session = AsyncMock() + user_id = uuid4() + payload = CommitteeCreate(name="Security Council", code="SC") + created = {"id": 3, "conference_id": 7, "name": payload.name} + create_committee = AsyncMock(return_value=created) + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="admin"), + ) + monkeypatch.setattr( + conference_service.repository, "create_committee", create_committee + ) + + result = await conference_service.create_committee(session, user_id, 7, payload) + + assert result == created + create_committee.assert_awaited_once_with( + session=session, conference_id=7, payload=payload + ) + session.commit.assert_awaited_once() + + +@pytest.mark.anyio +async def test_enroll_member_forwards_an_authorized_request(monkeypatch): + session = AsyncMock() + user_id = uuid4() + payload = EnrollMember( + name="Ada Lovelace", email="ada@example.com", role="delegate" + ) + enrolled = {"id": 4, "email": payload.email, "role": payload.role} + enroll_member = AsyncMock(return_value=enrolled) + monkeypatch.setattr( + conference_service.repository, + "get_user_conference_role", + AsyncMock(return_value="owner"), + ) + monkeypatch.setattr( + conference_service.repository, "enroll_member", enroll_member + ) + + result = await conference_service.enroll_member(session, user_id, 7, payload) + + assert result == enrolled + enroll_member.assert_awaited_once_with( + session=session, conference_id=7, payload=payload + ) + session.commit.assert_awaited_once() + + +@pytest.mark.anyio +async def test_create_conference_commits_after_creating_it(monkeypatch): + session = AsyncMock() + user_id = uuid4() + payload = ConferenceCreate( + name="WebMUN", + start_date=datetime.now(), + end_date=datetime.now(), + ) + create_conference = AsyncMock(return_value=7) + monkeypatch.setattr( + conference_service.repository, "create_conference", create_conference + ) + + conference_id = await conference_service.create_conference( + session, user_id, payload + ) + + assert conference_id == 7 + create_conference.assert_awaited_once_with( + session=session, user_id=user_id, payload=payload + ) + session.commit.assert_awaited_once() + + +@pytest.mark.anyio +async def test_get_user_conferences_returns_dashboard_summaries(monkeypatch): + session = object() + user_id = uuid4() + summaries = [ + { + "id": 7, + "name": "WebMUN", + "logo": None, + "color": "#0f172a", + "status": "planned", + "caller_role": "owner", + } + ] + get_user_conferences = AsyncMock(return_value=summaries) + monkeypatch.setattr( + conference_service.repository, "get_user_conferences", get_user_conferences + ) + + result = await conference_service.get_user_conferences(session, user_id) + + assert result == summaries + get_user_conferences.assert_awaited_once_with(session=session, user_id=user_id) diff --git a/backend/app/tests/session/test_service.py b/backend/app/tests/session/test_service.py index 2dd9711..2e7c7ac 100644 --- a/backend/app/tests/session/test_service.py +++ b/backend/app/tests/session/test_service.py @@ -4,8 +4,7 @@ import pytest -from app.access.enums import SessionRoles -from app.access.models import CommitteeAssignment +from app.conference.models import ConferenceAssignment from app.session import enums from app.session.engine import EventRejectedError from app.session.enums import SessionRole @@ -23,10 +22,10 @@ @pytest.fixture def brazil_assignment(): - return CommitteeAssignment( + return ConferenceAssignment( user_id=UUID("44444444-4444-4444-4444-444444444444"), committee_id=0, - role=SessionRoles.DELEGATION, + role="delegate", representation_id=0, ) @@ -100,7 +99,7 @@ def test_cannot_build_actor_with_nonexistent_delegation( async def test_prepare_connect_without_db( connection_manager: ConnectionManager, session_state: SessionLiveState, - brazil_assignment: CommitteeAssignment, + brazil_assignment: ConferenceAssignment, ) -> None: connection_manager.room_states[0] = session_state mock_session = None @@ -120,7 +119,7 @@ async def test_prepare_connect_without_db( @pytest.mark.anyio async def test_prepare_connect_fetches_db( connection_manager: ConnectionManager, - brazil_assignment: CommitteeAssignment, + brazil_assignment: ConferenceAssignment, monkeypatch, ) -> None: mock_session = MagicMock @@ -156,7 +155,7 @@ async def test_prepare_connect_fetches_db( @pytest.mark.anyio async def test_cant_prepare_connect_storedlive_missing( connection_manager: ConnectionManager, - brazil_assignment: CommitteeAssignment, + brazil_assignment: ConferenceAssignment, monkeypatch, ) -> None: with pytest.raises(SessionFetchError, match="Could not fetch session info"): diff --git a/backend/app/tests/session/test_views.py b/backend/app/tests/session/test_views.py index 0893b85..15ceabf 100644 --- a/backend/app/tests/session/test_views.py +++ b/backend/app/tests/session/test_views.py @@ -67,7 +67,7 @@ def authenticated_websocket_dependencies(monkeypatch, chair_actor: SessionActor) ), ) monkeypatch.setattr( - views.access, + views.conference_service, "resolve_session_assignment", AsyncMock(return_value=MagicMock()), ) diff --git a/frontend/src/context/SessionContext.tsx b/frontend/src/context/SessionContext.tsx index 2e37bd1..1103f5e 100644 --- a/frontend/src/context/SessionContext.tsx +++ b/frontend/src/context/SessionContext.tsx @@ -84,7 +84,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { const [representation_id, setRepresentation_id] = useState(null) useEffect(() => { - fetch(`${import.meta.env.VITE_API_URL}/access/sessions/${parsedSessionId}/me`, + fetch(`${import.meta.env.VITE_API_URL}/conferences/sessions/${parsedSessionId}/me`, { method: "GET", headers: { @@ -95,7 +95,10 @@ export function SessionProvider({ children }: { children: ReactNode }) { ).then((response) => { if (!response.ok) throw new Error("Error when getting role") return response.json() - }).then((data: SessionRepresentation) => { setRole(data.role); setRepresentation_id(data.representation_id); }) + }).then((data: SessionRepresentation) => { + setRole(data.role); + setRepresentation_id(data.representation_id ?? null); + }) }, [parsedSessionId, token]) const value = { diff --git a/frontend/src/schemas/types.gen.ts b/frontend/src/schemas/types.gen.ts index 571a06b..194c8c0 100644 --- a/frontend/src/schemas/types.gen.ts +++ b/frontend/src/schemas/types.gen.ts @@ -4,6 +4,278 @@ export type ClientOptions = { baseUrl: 'http://localhost:8000' | (string & {}); }; +/** + * CommitteeCreate + * + * Schema for creating a committee + */ +export type CommitteeCreate = { + /** + * Name + */ + name: string; + /** + * Code + */ + code: string; + /** + * Logo + */ + logo?: string | null; + /** + * Topic + */ + topic?: string | null; + /** + * Status + */ + status?: string; +}; + +/** + * CommitteeResponse + * + * Schema for returning committee details + */ +export type CommitteeResponse = { + /** + * Id + */ + id: number; + /** + * Conference Id + */ + conference_id: number; + /** + * Name + */ + name: string; + /** + * Code + */ + code: string; + /** + * Logo + */ + logo?: string | null; + /** + * Topic + */ + topic?: string | null; + /** + * Status + */ + status: string; + /** + * Created At + */ + created_at?: string | null; +}; + +/** + * ConferenceAssignment + * + * Holds information about a user's role, enrollment, and representation. + */ +export type ConferenceAssignment = { + /** + * Id + */ + id?: number | null; + /** + * Conference Id + */ + conference_id?: number | null; + /** + * User Id + */ + user_id?: string | null; + /** + * Name + */ + name?: string | null; + /** + * Email + */ + email?: string | null; + /** + * Institution + */ + institution?: string | null; + /** + * Role + */ + role: string; + /** + * Committee Id + */ + committee_id?: number | null; + /** + * Representation Id + */ + representation_id?: number | null; + /** + * Created At + */ + created_at?: string | null; +}; + +/** + * ConferenceCreate + * + * Schema for conference creation + */ +export type ConferenceCreate = { + /** + * Name + */ + name: string; + /** + * Slug + */ + slug?: string | null; + /** + * Location + */ + location?: string | null; + /** + * Logo + */ + logo?: string | null; + /** + * Color + */ + color?: string; + /** + * Start Date + */ + start_date: string; + /** + * End Date + */ + end_date: string; +}; + +/** + * ConferenceDetail + * + * Detailed conference information including its committees + */ +export type ConferenceDetail = { + /** + * Id + */ + id: number; + /** + * Name + */ + name: string; + /** + * Slug + */ + slug?: string | null; + /** + * Status + */ + status: string; + /** + * Owner Id + */ + owner_id: string; + /** + * Location + */ + location?: string | null; + /** + * Logo + */ + logo?: string | null; + /** + * Color + */ + color: string; + /** + * Start Date + */ + start_date: string; + /** + * End Date + */ + end_date: string; + /** + * Caller Role + */ + caller_role?: string | null; + /** + * Committees + */ + committees?: Array; +}; + +/** + * ConferenceSummary + * + * Conference data needed to select a conference in the dashboard. + */ +export type ConferenceSummary = { + /** + * Id + */ + id: number; + /** + * Name + */ + name: string; + /** + * Logo + */ + logo?: string | null; + /** + * Color + */ + color: string; + /** + * Status + */ + status: string; + /** + * Caller Role + */ + caller_role: string; +}; + +/** + * EnrollMember + * + * Schema for enrolling/assigning a user into a conference + */ +export type EnrollMember = { + /** + * Name + */ + name: string; + /** + * Email + */ + email: string; + /** + * Institution + */ + institution?: string | null; + /** + * Role + */ + role?: string; + /** + * Committee Id + */ + committee_id?: number | null; + /** + * Representation Id + */ + representation_id?: number | null; +}; + /** * HTTPValidationError */ @@ -32,22 +304,28 @@ export type SessionCreationSchema = { /** * SessionRepresentation + * + * Session-specific access context for the authenticated user. */ export type SessionRepresentation = { role: SessionRoles; /** * Representation Id */ - representation_id: number | null; + representation_id?: number | null; }; /** * SessionRoles + * + * Roles available to a participant in an active committee session. */ export const SessionRoles = { CHAIR: 'chair', DELEGATE: 'delegate' } as const; /** * SessionRoles + * + * Roles available to a participant in an active committee session. */ export type SessionRoles = typeof SessionRoles[keyof typeof SessionRoles]; @@ -1320,44 +1598,176 @@ export type ServerSessionMessage = ({ type: 'dispatch_result'; } & DispatchResultMessage); -export type HealthCommitteesHealthGetData = { +export type GetUserConferencesConferencesGetData = { body?: never; path?: never; query?: never; - url: '/committees/health'; + url: '/conferences/'; }; -export type HealthCommitteesHealthGetResponses = { +export type GetUserConferencesConferencesGetResponses = { /** + * Response Get User Conferences Conferences Get + * * Successful Response */ - 200: unknown; + 200: Array; }; -export type CreateSessionEndpointCommitteesPostData = { - body: SessionCreationSchema; +export type GetUserConferencesConferencesGetResponse = GetUserConferencesConferencesGetResponses[keyof GetUserConferencesConferencesGetResponses]; + +export type CreateConferenceConferencesPostData = { + body: ConferenceCreate; path?: never; query?: never; - url: '/committees/'; + url: '/conferences/'; }; -export type CreateSessionEndpointCommitteesPostErrors = { +export type CreateConferenceConferencesPostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type CreateSessionEndpointCommitteesPostError = CreateSessionEndpointCommitteesPostErrors[keyof CreateSessionEndpointCommitteesPostErrors]; +export type CreateConferenceConferencesPostError = CreateConferenceConferencesPostErrors[keyof CreateConferenceConferencesPostErrors]; -export type CreateSessionEndpointCommitteesPostResponses = { +export type CreateConferenceConferencesPostResponses = { /** + * Response Create Conference Conferences Post + * * Successful Response */ - 200: unknown; + 201: { + [key: string]: unknown; + }; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { +export type CreateConferenceConferencesPostResponse = CreateConferenceConferencesPostResponses[keyof CreateConferenceConferencesPostResponses]; + +export type GetConferenceInfoConferencesIdGetData = { + body?: never; + path: { + /** + * Id + */ + id: number; + }; + query?: never; + url: '/conferences/{id}'; +}; + +export type GetConferenceInfoConferencesIdGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetConferenceInfoConferencesIdGetError = GetConferenceInfoConferencesIdGetErrors[keyof GetConferenceInfoConferencesIdGetErrors]; + +export type GetConferenceInfoConferencesIdGetResponses = { + /** + * Successful Response + */ + 200: ConferenceDetail; +}; + +export type GetConferenceInfoConferencesIdGetResponse = GetConferenceInfoConferencesIdGetResponses[keyof GetConferenceInfoConferencesIdGetResponses]; + +export type CreateCommitteeConferencesIdCommitteesPostData = { + body: CommitteeCreate; + path: { + /** + * Id + */ + id: number; + }; + query?: never; + url: '/conferences/{id}/committees'; +}; + +export type CreateCommitteeConferencesIdCommitteesPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateCommitteeConferencesIdCommitteesPostError = CreateCommitteeConferencesIdCommitteesPostErrors[keyof CreateCommitteeConferencesIdCommitteesPostErrors]; + +export type CreateCommitteeConferencesIdCommitteesPostResponses = { + /** + * Successful Response + */ + 201: CommitteeResponse; +}; + +export type CreateCommitteeConferencesIdCommitteesPostResponse = CreateCommitteeConferencesIdCommitteesPostResponses[keyof CreateCommitteeConferencesIdCommitteesPostResponses]; + +export type ListConferenceMembersConferencesConferenceIdMembersGetData = { + body?: never; + path: { + /** + * Conference Id + */ + conference_id: number; + }; + query?: never; + url: '/conferences/{conference_id}/members'; +}; + +export type ListConferenceMembersConferencesConferenceIdMembersGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListConferenceMembersConferencesConferenceIdMembersGetError = ListConferenceMembersConferencesConferenceIdMembersGetErrors[keyof ListConferenceMembersConferencesConferenceIdMembersGetErrors]; + +export type ListConferenceMembersConferencesConferenceIdMembersGetResponses = { + /** + * Response List Conference Members Conferences Conference Id Members Get + * + * Successful Response + */ + 200: Array; +}; + +export type ListConferenceMembersConferencesConferenceIdMembersGetResponse = ListConferenceMembersConferencesConferenceIdMembersGetResponses[keyof ListConferenceMembersConferencesConferenceIdMembersGetResponses]; + +export type EnrollMemberConferencesConferenceIdMembersPostData = { + body: EnrollMember; + path: { + /** + * Conference Id + */ + conference_id: number; + }; + query?: never; + url: '/conferences/{conference_id}/members'; +}; + +export type EnrollMemberConferencesConferenceIdMembersPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type EnrollMemberConferencesConferenceIdMembersPostError = EnrollMemberConferencesConferenceIdMembersPostErrors[keyof EnrollMemberConferencesConferenceIdMembersPostErrors]; + +export type EnrollMemberConferencesConferenceIdMembersPostResponses = { + /** + * Successful Response + */ + 201: ConferenceAssignment; +}; + +export type EnrollMemberConferencesConferenceIdMembersPostResponse = EnrollMemberConferencesConferenceIdMembersPostResponses[keyof EnrollMemberConferencesConferenceIdMembersPostResponses]; + +export type GetMySessionAccessConferencesSessionsSessionIdMeGetData = { body?: never; path: { /** @@ -1366,28 +1776,65 @@ export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { session_id: number; }; query?: never; - url: '/committees/{session_id}/activate'; + url: '/conferences/sessions/{session_id}/me'; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostErrors = { +export type GetMySessionAccessConferencesSessionsSessionIdMeGetErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostError = ActivateSessionEndpointCommitteesSessionIdActivatePostErrors[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostErrors]; +export type GetMySessionAccessConferencesSessionsSessionIdMeGetError = GetMySessionAccessConferencesSessionsSessionIdMeGetErrors[keyof GetMySessionAccessConferencesSessionsSessionIdMeGetErrors]; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponses = { +export type GetMySessionAccessConferencesSessionsSessionIdMeGetResponses = { /** * Successful Response */ - 204: void; + 200: SessionRepresentation; }; -export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponse = ActivateSessionEndpointCommitteesSessionIdActivatePostResponses[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostResponses]; +export type GetMySessionAccessConferencesSessionsSessionIdMeGetResponse = GetMySessionAccessConferencesSessionsSessionIdMeGetResponses[keyof GetMySessionAccessConferencesSessionsSessionIdMeGetResponses]; -export type GetMySessionAccessAccessSessionsSessionIdMeGetData = { +export type HealthCommitteesHealthGetData = { + body?: never; + path?: never; + query?: never; + url: '/committees/health'; +}; + +export type HealthCommitteesHealthGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type CreateSessionEndpointCommitteesPostData = { + body: SessionCreationSchema; + path?: never; + query?: never; + url: '/committees/'; +}; + +export type CreateSessionEndpointCommitteesPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateSessionEndpointCommitteesPostError = CreateSessionEndpointCommitteesPostErrors[keyof CreateSessionEndpointCommitteesPostErrors]; + +export type CreateSessionEndpointCommitteesPostResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type ActivateSessionEndpointCommitteesSessionIdActivatePostData = { body?: never; path: { /** @@ -1396,23 +1843,23 @@ export type GetMySessionAccessAccessSessionsSessionIdMeGetData = { session_id: number; }; query?: never; - url: '/access/sessions/{session_id}/me'; + url: '/committees/{session_id}/activate'; }; -export type GetMySessionAccessAccessSessionsSessionIdMeGetErrors = { +export type ActivateSessionEndpointCommitteesSessionIdActivatePostErrors = { /** * Validation Error */ 422: HttpValidationError; }; -export type GetMySessionAccessAccessSessionsSessionIdMeGetError = GetMySessionAccessAccessSessionsSessionIdMeGetErrors[keyof GetMySessionAccessAccessSessionsSessionIdMeGetErrors]; +export type ActivateSessionEndpointCommitteesSessionIdActivatePostError = ActivateSessionEndpointCommitteesSessionIdActivatePostErrors[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostErrors]; -export type GetMySessionAccessAccessSessionsSessionIdMeGetResponses = { +export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponses = { /** * Successful Response */ - 200: SessionRepresentation; + 204: void; }; -export type GetMySessionAccessAccessSessionsSessionIdMeGetResponse = GetMySessionAccessAccessSessionsSessionIdMeGetResponses[keyof GetMySessionAccessAccessSessionsSessionIdMeGetResponses]; +export type ActivateSessionEndpointCommitteesSessionIdActivatePostResponse = ActivateSessionEndpointCommitteesSessionIdActivatePostResponses[keyof ActivateSessionEndpointCommitteesSessionIdActivatePostResponses]; diff --git a/supabase/migrations/20260719224327_create_initial_tables.sql b/supabase/migrations/20260719224327_create_initial_tables.sql index ce34ead..9ed359b 100644 --- a/supabase/migrations/20260719224327_create_initial_tables.sql +++ b/supabase/migrations/20260719224327_create_initial_tables.sql @@ -1,21 +1,64 @@ +------------------------------------------------------ +-- ENUMS AND TYPES +------------------------------------------------------ create type activity_status as enum ('active', 'closed', 'planned', 'cancelled'); -create type committee_role as enum ('chair', 'delegate'); create type asset_type as enum ('preset', 'custom'); +create type conference_role as enum ( + 'admin', -- Secretary general / Organizer + 'chair', -- Director / Moderator + 'press', -- Press / Media team + 'staff', -- General logistics / Crisis backroom + 'participant' -- Delegate / Delegation +); +------------------------------------------------------ +-- CORE CONFERENCE HIERARCHY +------------------------------------------------------ create table conferences ( id bigint primary key generated always as identity, name varchar(255) not null, + slug varchar(100), status activity_status not null default 'planned', - owner_id uuid not null references auth.users on delete cascade + owner_id uuid not null references auth.users on delete cascade, + + location varchar(255), + logo varchar(1000), + color varchar(7) not null default '#0f172a', + start_date timestamptz not null, + end_date timestamptz not null, + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() ); create table committees ( id bigint primary key generated always as identity, - conference_id bigint not null references conferences(id), - name varchar(64) not null, - status activity_status not null default 'planned' + conference_id bigint not null references conferences(id) on delete cascade, + + name varchar(128) not null, -- full name + code varchar(64) not null, -- code name + logo varchar(1000), + topic TEXT, + status activity_status not null default 'planned', + + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() ); +create table sessions ( + id bigint primary key generated always as identity, + committee_id bigint not null references committees(id) on delete cascade, + name varchar(64), + status activity_status not null default 'planned', + started_at timestamptz, + ended_at timestamptz, + state_snapshot JSONB +); + +------------------------------------------------------ +-- REPRESENTATIONS & LAYOUTS +------------------------------------------------------ + -- This table references preset/custom representations that we might work with. We can perhaps separate the two of them later create table representations ( id bigint primary key generated always as identity, @@ -23,8 +66,7 @@ create table representations ( rep_type asset_type not null default 'preset', code varchar(10), identifier varchar(255) not null, -- this can be either an url if the rep_type is custom, or a code (like 'br') - conference_id bigint null -- if set references a specific conference. if we delete that conference entry, we might setup a custom handler on the - -- backend + conference_id bigint null references conferences(id) on delete cascade ); -- preset layouts for conferences @@ -49,27 +91,37 @@ create table layout_seats ( create table committee_seats ( committee_id bigint not null references committees(id) on delete cascade, representation_id bigint not null references representations(id) on delete cascade, - seat_label varchar(3), + seat_label varchar(3), primary key (committee_id, representation_id) ); -create table committee_assignments ( - user_id uuid not null references auth.users(id) on delete cascade, - committee_id bigint not null references committees(id) on delete cascade, - role committee_role not null, - representation_id bigint null references representations(id), - - primary key (user_id, committee_id) -); +------------------------------------------------------ +--- CONFERENCE MEMBERS & ASSIGNMENTS +------------------------------------------------------ -create table sessions ( +create table conference_assignments ( id bigint primary key generated always as identity, - committee_id bigint not null references committees(id) on delete cascade, - name varchar(64), - status activity_status not null default 'planned', - started_at timestamptz, - ended_at timestamptz, - state_snapshot JSONB + conference_id bigint not null references conferences(id) on delete cascade, + + user_id uuid references auth.users(id) on delete set null, + name varchar(255) not null, + email varchar(255) not null, + institution varchar(255), + + -- Assign role and permissions + role conference_role not null default 'participant', + + -- null if conference_role is admin/press/staff + committee_id bigint references committees(id) on delete set null, + + -- null if not participant/delegation + representation_id bigint null references representations(id) on delete set null, + + created_at timestamptz not null default now(), + unique (conference_id, email, committee_id) ); +create index idx_conf_assignments_user on conference_assignments(user_id); +create index idx_conf_assignments_comm on conference_assignments(committee_id); +create index idx_conf_assignments_conf on conference_assignments(conference_id); diff --git a/supabase/seed.sql b/supabase/seed.sql index 9b27847..56667c8 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,16 +1,26 @@ --- default conference +-- Reset-only local development seed. +-- This file assumes `supabase db reset`: generated public-table IDs start at 1. +-- Do not run it against an existing database. + +begin; insert into public.conferences - (name, status, owner_id) -values - ('I WebMUN', 'active', '11111111-1111-1111-1111-111111111111'); + (name, status, owner_id, start_date, end_date) +values + ( + 'I WebMUN', + 'active', + '11111111-1111-1111-1111-111111111111', + timestamptz '2026-08-01 09:00:00+00', + timestamptz '2026-08-03 18:00:00+00' + ); -insert into public.committees - (conference_id, name, status) -values - ((select id from conferences where name = 'I WebMUN'), 'CSNU', 'planned'); +-- Conference ID 1 +insert into public.committees (conference_id, name, code, status) +values (1, 'CSNU', 'CSNU', 'planned'); -insert into public.representations (name, code, identifier) values +-- Representation IDs 1–21, in the same order as the seat maps below. +insert into public.representations (name, code, identifier) values ('Albânia', 'al', 'al'), ('Alemanha', 'de', 'de'), ('Austrália', 'au', 'au'), @@ -33,71 +43,38 @@ insert into public.representations (name, code, identifier) values ('Taiwan', 'tw', 'tw'), ('Turquia', 'tr', 'tr'); -insert into layouts - (name, conference_id, committee_id) -values - ('Standard 21 Room', null, null); - -insert into layout_seats - (layout_id, representation_id, seat_label) -values - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'al'), '3-4'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'de'), '2-5'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'au'), '2-4'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'br'), '3-2'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'cn'), '1-6'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'kr'), '3-5'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ae'), '3-9'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'us'), '1-2'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ph'), '2-2'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'fr'), '1-4'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'gt'), '3-7'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'hk'), '3-1'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'in'), '3-6'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'id'), '3-3'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'jp'), '2-1'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'my'), '2-3'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'gb'), '1-3'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ru'), '1-5'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'ch'), '3-8'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'tw'), '1-1'), - ((select id from layouts where name = 'Standard 21 Room'), (select id from representations where code = 'tr'), '2-6'); +-- Layout ID 1 +insert into public.layouts (name, conference_id, committee_id) +values ('Standard 21 Room', null, null); -insert into committee_seats - (committee_id, representation_id, seat_label) -values - (1, (select id from representations where code = 'al'), '3-4'), - (1, (select id from representations where code = 'de'), '2-5'), - (1, (select id from representations where code = 'au'), '2-4'), - (1, (select id from representations where code = 'br'), '3-2'), - (1, (select id from representations where code = 'cn'), '1-6'), - (1, (select id from representations where code = 'kr'), '3-5'), - (1, (select id from representations where code = 'ae'), '3-9'), - (1, (select id from representations where code = 'us'), '1-2'), - (1, (select id from representations where code = 'ph'), '2-2'), - (1, (select id from representations where code = 'fr'), '1-4'), - (1, (select id from representations where code = 'gt'), '3-7'), - (1, (select id from representations where code = 'hk'), '3-1'), - (1, (select id from representations where code = 'in'), '3-6'), - (1, (select id from representations where code = 'id'), '3-3'), - (1, (select id from representations where code = 'jp'), '2-1'), - (1, (select id from representations where code = 'my'), '2-3'), - (1, (select id from representations where code = 'gb'), '1-3'), - (1, (select id from representations where code = 'ru'), '1-5'), - (1, (select id from representations where code = 'ch'), '3-8'), - (1, (select id from representations where code = 'tw'), '1-1'), - (1, (select id from representations where code = 'tr'), '2-6'); +insert into public.layout_seats (layout_id, representation_id, seat_label) values + (1, 1, '3-4'), (1, 2, '2-5'), (1, 3, '2-4'), (1, 4, '3-2'), + (1, 5, '1-6'), (1, 6, '3-5'), (1, 7, '3-9'), (1, 8, '1-2'), + (1, 9, '2-2'), (1, 10, '1-4'), (1, 11, '3-7'), (1, 12, '3-1'), + (1, 13, '3-6'), (1, 14, '3-3'), (1, 15, '2-1'), (1, 16, '2-3'), + (1, 17, '1-3'), (1, 18, '1-5'), (1, 19, '3-8'), (1, 20, '1-1'), + (1, 21, '2-6'); -insert into public.committee_assignments - (user_id, committee_id, role, representation_id) +-- Committee ID 1 +insert into public.committee_seats + (committee_id, representation_id, seat_label) values - ('11111111-1111-1111-1111-111111111111', 1, 'chair', null), - ('22222222-2222-2222-2222-222222222222', 1, 'delegate', (select id from representations where code = 'al')), - ('33333333-3333-3333-3333-333333333333', 1, 'delegate', (select id from representations where code = 'de')), - ('44444444-4444-4444-4444-444444444444', 1, 'delegate', (select id from representations where code = 'br')); + (1, 1, '3-4'), (1, 2, '2-5'), (1, 3, '2-4'), (1, 4, '3-2'), + (1, 5, '1-6'), (1, 6, '3-5'), (1, 7, '3-9'), (1, 8, '1-2'), + (1, 9, '2-2'), (1, 10, '1-4'), (1, 11, '3-7'), (1, 12, '3-1'), + (1, 13, '3-6'), (1, 14, '3-3'), (1, 15, '2-1'), (1, 16, '2-3'), + (1, 17, '1-3'), (1, 18, '1-5'), (1, 19, '3-8'), (1, 20, '1-1'), + (1, 21, '2-6'); -insert into public.sessions - (committee_id) +insert into public.conference_assignments + (conference_id, user_id, name, email, role, committee_id, representation_id) values - (1); + (1, '11111111-1111-1111-1111-111111111111', 'Chair Person', 'chair@codelab.usp.br', 'chair', 1, null), + (1, '22222222-2222-2222-2222-222222222222', 'Delegate Albania', 'albania@codelab.usp.br', 'participant', 1, 1), + (1, '33333333-3333-3333-3333-333333333333', 'Delegate Germany', 'alemanha@codelab.usp.br', 'participant', 1, 2), + (1, '44444444-4444-4444-4444-444444444444', 'Delegate Brazil', 'brazil@codelab.usp.br', 'participant', 1, 4); + +-- Session ID 1 +insert into public.sessions (committee_id) values (1); +commit;