diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..a477a4dc5a 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import items, login, meetings, private, users, utils from app.core.config import settings api_router = APIRouter() @@ -8,6 +8,7 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(meetings.router) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/meetings.py b/backend/app/api/routes/meetings.py new file mode 100644 index 0000000000..2c4a14e41e --- /dev/null +++ b/backend/app/api/routes/meetings.py @@ -0,0 +1,95 @@ +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from app import crud +from app.api import deps +from app.models import ( + Meeting, + MeetingCreate, + MeetingUpdate, + MeetingPublic, + MeetingsPublic, +) + +router = APIRouter(prefix="/meetings", tags=["meetings"]) + + +@router.get("", response_model=MeetingsPublic) +def read_meetings( + *, + session: Session = Depends(deps.get_session), + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve meetings. + """ + meetings, total = crud.meeting.get_meetings( + session=session, skip=skip, limit=limit + ) + return MeetingsPublic(data=meetings, count=total) + + +@router.post("", response_model=MeetingPublic) +def create_meeting( + *, + session: Session = Depends(deps.get_session), + meeting_in: MeetingCreate, +) -> Any: + """ + Create new meeting. + """ + meeting = crud.meeting.create_meeting(session=session, meeting_in=meeting_in) + return meeting + + +@router.get("/{meeting_id}", response_model=MeetingPublic) +def read_meeting( + *, + session: Session = Depends(deps.get_session), + meeting_id: UUID, +) -> Any: + """ + Get meeting by ID. + """ + meeting = crud.meeting.get_meeting(session=session, meeting_id=meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + return meeting + + +@router.put("/{meeting_id}", response_model=MeetingPublic) +def update_meeting( + *, + session: Session = Depends(deps.get_session), + meeting_id: UUID, + meeting_in: MeetingUpdate, +) -> Any: + """ + Update a meeting. + """ + meeting = crud.meeting.get_meeting(session=session, meeting_id=meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + meeting = crud.meeting.update_meeting( + session=session, db_obj=meeting, obj_in=meeting_in + ) + return meeting + + +@router.delete("/{meeting_id}", response_model=MeetingPublic) +def delete_meeting( + *, + session: Session = Depends(deps.get_session), + meeting_id: UUID, +) -> Any: + """ + Delete a meeting. + """ + meeting = crud.meeting.delete_meeting(session=session, meeting_id=meeting_id) + if not meeting: + raise HTTPException(status_code=404, detail="Meeting not found") + return meeting \ No newline at end of file diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..3839c71bd2 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,11 @@ import uuid from typing import Any +from uuid import UUID from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import Item, ItemCreate, User, UserCreate, UserUpdate, Meeting, MeetingCreate, MeetingUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -52,3 +53,43 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(db_item) return db_item + + +def create_meeting(*, session: Session, meeting_in: MeetingCreate) -> Meeting: + db_obj = Meeting.model_validate(meeting_in) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_meeting(*, session: Session, meeting_id: UUID) -> Meeting | None: + return session.get(Meeting, meeting_id) + + +def get_meetings( + *, session: Session, skip: int = 0, limit: int = 100 +) -> tuple[list[Meeting], int]: + statement = select(Meeting).offset(skip).limit(limit) + meetings = session.exec(statement).all() + total = session.query(Meeting).count() + return meetings, total + + +def update_meeting( + *, session: Session, db_obj: Meeting, obj_in: MeetingUpdate +) -> Meeting: + update_data = obj_in.model_dump(exclude_unset=True) + db_obj.sqlmodel_update(update_data) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def delete_meeting(*, session: Session, meeting_id: UUID) -> Meeting | None: + meeting = session.get(Meeting, meeting_id) + if meeting: + session.delete(meeting) + session.commit() + return meeting diff --git a/backend/app/models.py b/backend/app/models.py index 90ef5559e3..a61576c39a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -112,3 +112,36 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=40) + + +# Shared properties +class MeetingBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + agenda: str = Field(min_length=1) + summary: str | None = Field(default=None) + + +# Properties to receive on meeting creation +class MeetingCreate(MeetingBase): + pass + + +# Properties to receive on meeting update +class MeetingUpdate(MeetingBase): + title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore + agenda: str | None = Field(default=None, min_length=1) # type: ignore + + +# Database model +class Meeting(MeetingBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + + +# Properties to return via API +class MeetingPublic(MeetingBase): + id: uuid.UUID + + +class MeetingsPublic(SQLModel): + data: list[MeetingPublic] + count: int diff --git a/backend/app/tests/api/routes/test_meetings.py b/backend/app/tests/api/routes/test_meetings.py new file mode 100644 index 0000000000..8da65be4bb --- /dev/null +++ b/backend/app/tests/api/routes/test_meetings.py @@ -0,0 +1,120 @@ +import uuid + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from app.tests.utils.meeting import create_random_meeting + + +def test_create_meeting( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"title": "Test Meeting", "agenda": "Discuss testing"} + response = client.post( + f"{settings.API_V1_STR}/meetings/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert content["agenda"] == data["agenda"] + assert "id" in content + + +def test_read_meeting( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + meeting = create_random_meeting(db) + response = client.get( + f"{settings.API_V1_STR}/meetings/{meeting.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == meeting.title + assert content["agenda"] == meeting.agenda + assert content["id"] == str(meeting.id) + + +def test_read_meeting_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/meetings/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Meeting not found" + + +def test_read_meetings( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + create_random_meeting(db) + create_random_meeting(db) + response = client.get( + f"{settings.API_V1_STR}/meetings/", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert len(content["data"]) >= 2 + + +def test_update_meeting( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + meeting = create_random_meeting(db) + data = {"title": "Updated Meeting", "agenda": "Updated agenda"} + response = client.put( + f"{settings.API_V1_STR}/meetings/{meeting.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert content["agenda"] == data["agenda"] + assert content["id"] == str(meeting.id) + + +def test_update_meeting_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"title": "Updated Meeting", "agenda": "Updated agenda"} + response = client.put( + f"{settings.API_V1_STR}/meetings/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Meeting not found" + + +def test_delete_meeting( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + meeting = create_random_meeting(db) + response = client.delete( + f"{settings.API_V1_STR}/meetings/{meeting.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["id"] == str(meeting.id) + + +def test_delete_meeting_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.delete( + f"{settings.API_V1_STR}/meetings/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Meeting not found" \ No newline at end of file diff --git a/backend/app/tests/utils/meeting.py b/backend/app/tests/utils/meeting.py new file mode 100644 index 0000000000..8d2ae29e60 --- /dev/null +++ b/backend/app/tests/utils/meeting.py @@ -0,0 +1,18 @@ +import random +import string + +from sqlmodel import Session + +from app import crud +from app.models import MeetingCreate + + +def random_lower_string() -> str: + return "".join(random.choices(string.ascii_lowercase, k=32)) + + +def create_random_meeting(db: Session) -> None: + title = random_lower_string() + agenda = random_lower_string() + meeting_in = MeetingCreate(title=title, agenda=agenda) + return crud.meeting.create_meeting(session=db, meeting_in=meeting_in) \ No newline at end of file