Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions backend/app/access/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.access.models import CommitteeAssignment
from app.core.exceptions import AccessDeniedError

from .repository import get_committee_assignment, get_session_assignment


class AccessDenied(Exception): ...


async def resolve_committee_assignment(
session: AsyncSession,
user_id: UUID,
Expand All @@ -21,10 +19,10 @@ async def resolve_committee_assignment(
)

if assignment is None:
raise AccessDenied("User has no committee assignment")
raise AccessDeniedError("User has no committee assignment")

if assignment.role == "delegate" and assignment.representation_id is None:
raise AccessDenied("Delegate role has no delegation id")
raise AccessDeniedError("Delegate role has no delegation id")

return assignment

Expand All @@ -38,10 +36,10 @@ async def resolve_session_assignment(
assignment = await get_session_assignment(session, user_id, session_id)

if assignment is None:
raise AccessDenied("User has no assignment for this session")
raise AccessDeniedError("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")
raise AccessDeniedError("Delegate role has no delegation id")

return assignment

Expand All @@ -56,9 +54,11 @@ async def verify_user_role(
assignment = await get_committee_assignment(session, user_id, committee_id)

if assignment is None:
raise AccessDenied("User has no committee assignment")
raise AccessDeniedError("User has no committee assignment")

if assignment.role != required_role:
raise AccessDenied(f"User requires the {required_role} role for this committee")
raise AccessDeniedError(
f"User requires the {required_role} role for this committee"
)

return assignment
16 changes: 5 additions & 11 deletions backend/app/access/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from typing import Annotated

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
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
from .service import resolve_session_assignment

router = APIRouter()

Expand All @@ -20,15 +20,9 @@ async def get_my_session_access(
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
assignment = await resolve_session_assignment(
db_session, current_user.user_id, session_id
)

return SessionRepresentation(
role=assignment.role, representation_id=assignment.representation_id
Expand Down
28 changes: 28 additions & 0 deletions backend/app/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class AppException(Exception):
"""Base exception for expected application failures."""

status_code = 500

def __init__(self, message: str):
self.message = message
super().__init__(message)


class BadRequest(AppException):
status_code = 400


class AccessDeniedError(AppException):
status_code = 403


class NotFoundError(AppException):
status_code = 404


class ConflictError(AppException):
status_code = 409


class InternalServerError(AppException):
pass
10 changes: 9 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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 fastapi.responses import JSONResponse

from app.access.views import router as access_router
from app.core.config import get_settings
from app.core.database import create_db
from app.core.exceptions import AppException
from app.core.openapi import add_websocket_message_schemas
from app.session.engine import SessionEngine
from app.session.manager import ConnectionManager
Expand Down Expand Up @@ -37,6 +39,12 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)


@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})


# CORS config for Vite
app.add_middleware(
CORSMiddleware,
Expand Down
38 changes: 20 additions & 18 deletions backend/app/session/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
import app.session.repository as repository
import app.session.schemas as schemas
from app.access.models import CommitteeAssignment
from app.core.exceptions import (
BadRequest,
ConflictError,
InternalServerError,
NotFoundError,
)
from app.session.engine import EventRejectedError, SessionEngine

from .manager import ConnectionManager
Expand All @@ -28,18 +34,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,
Expand Down Expand Up @@ -91,34 +89,38 @@ 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):
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,
Expand All @@ -139,7 +141,7 @@ 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
raise InternalServerError("Could not update session info") from None

await session.commit()

Expand Down
90 changes: 26 additions & 64 deletions backend/app/session/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.session.service as service
from app.access.service import AccessDenied
from app.auth.dep import get_current_user
from app.auth.service import (
AuthUser,
Expand All @@ -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
Expand Down Expand Up @@ -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 access.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)
Expand All @@ -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 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
)


@router.websocket("/ws/{session_id}")
Expand Down Expand Up @@ -221,7 +183,7 @@ async def websocket_endpoint(
except (
TokenExpiredError,
TokenInvalidError,
AccessDenied,
AccessDeniedError,
service.ActorResolutionError,
service.SessionFetchError,
ValidationError,
Expand All @@ -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"
Expand Down
Loading
Loading