From 270bfda1f8a8bcfe875161c2cec0deae4847fedd Mon Sep 17 00:00:00 2001 From: Felipe Cardoso Date: Sat, 15 Mar 2025 02:09:17 +0100 Subject: [PATCH] Add guests API with CRUD operations and tests Introduce a new API for managing event guests, including endpoints for creating, reading, updating, deleting, and changing guest status. Added corresponding Pydantic schemas, database CRUD logic, and extensive test coverage to validate functionality. Integrated the guests API under the events router. --- backend/app/api/routes/events/guests.py | 57 ++++++++++ backend/app/api/routes/events/router.py | 3 + backend/app/crud/guest.py | 46 ++++++++ backend/app/schemas/guests.py | 48 ++++++++ .../tests/api/routes/events/test_guests.py | 104 ++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 backend/app/api/routes/events/guests.py create mode 100644 backend/app/crud/guest.py create mode 100644 backend/app/schemas/guests.py create mode 100644 backend/tests/api/routes/events/test_guests.py diff --git a/backend/app/api/routes/events/guests.py b/backend/app/api/routes/events/guests.py new file mode 100644 index 0000000..07fa513 --- /dev/null +++ b/backend/app/api/routes/events/guests.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from app.schemas.guests import GuestCreate, GuestUpdate, GuestRead +from app.crud.guest import guest_crud +from typing import List +import uuid + +from app.core.database import get_db +from app.models import GuestStatus + +router = APIRouter() + + +@router.post("/", response_model=GuestRead, operation_id="create_guest") +def create_guest(guest_in: GuestCreate, db: Session = Depends(get_db)): + guest = guest_crud.create(db, obj_in=guest_in) + return guest + + +@router.get("/{guest_id}", response_model=GuestRead, operation_id="get_guest") +def read_guest(guest_id: uuid.UUID, db: Session = Depends(get_db)): + guest = guest_crud.get(db, guest_id) + if not guest: + raise HTTPException(status_code=404, detail="Guest not found") + return guest + + +@router.get("/", response_model=List[GuestRead], operation_id="get_guests") +def read_guests(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + guests = guest_crud.get_multi(db, skip=skip, limit=limit) + return guests + + +@router.put("/{guest_id}", response_model=GuestRead, operation_id="update_guest") +def update_guest(guest_id: uuid.UUID, guest_in: GuestUpdate, db: Session = Depends(get_db)): + guest = guest_crud.get(db, guest_id) + if not guest: + raise HTTPException(status_code=404, detail="Guest not found") + guest = guest_crud.update(db, db_obj=guest, obj_in=guest_in) + return guest + + +@router.delete("/{guest_id}", response_model=GuestRead, operation_id="delete_guest") +def delete_guest(guest_id: uuid.UUID, db: Session = Depends(get_db)): + guest = guest_crud.get(db, guest_id) + if not guest: + raise HTTPException(status_code=404, detail="Guest not found") + guest = guest_crud.remove(db, guest_id=str(guest_id)) + return guest + + +@router.patch("/{guest_id}/status", response_model=GuestRead) +def set_guest_status(guest_id: uuid.UUID, status: GuestStatus, db: Session = Depends(get_db)): + guest = guest_crud.update_status(db, guest_id=guest_id, status=status) + if not guest: + raise HTTPException(status_code=404, detail="Guest not found") + return guest \ No newline at end of file diff --git a/backend/app/api/routes/events/router.py b/backend/app/api/routes/events/router.py index 3b12aef..f6a7c62 100644 --- a/backend/app/api/routes/events/router.py +++ b/backend/app/api/routes/events/router.py @@ -21,9 +21,12 @@ from app.schemas.events import ( EventResponse, Event, ) +from app.api.routes.events import guests + logger = logging.getLogger(__name__) events_router = APIRouter() +events_router.include_router(guests.router, prefix="/guests", tags=["guests"]) def validate_event_access( *, diff --git a/backend/app/crud/guest.py b/backend/app/crud/guest.py new file mode 100644 index 0000000..73a6533 --- /dev/null +++ b/backend/app/crud/guest.py @@ -0,0 +1,46 @@ +from app.crud.base import CRUDBase +from app.models.guest import Guest +from app.schemas.guests import GuestCreate, GuestUpdate +from sqlalchemy.orm import Session +from typing import Optional +from app.models.guest import GuestStatus +from datetime import datetime, timezone +import uuid + + +class CRUDGuest(CRUDBase[Guest, GuestCreate, GuestUpdate]): + + def create(self, db, obj_in: GuestCreate): + db_guest = Guest( + event_id=uuid.UUID(obj_in.event_id) if isinstance(obj_in.event_id, str) else obj_in.event_id, # explicit casting + invited_by=uuid.UUID(obj_in.invited_by) if isinstance(obj_in.invited_by, str) else obj_in.invited_by, + full_name=obj_in.full_name, + email=obj_in.email, + invitation_code=obj_in.invitation_code + ) + db.add(db_guest) + db.commit() + db.refresh(db_guest) + return db_guest + + + def get_by_invitation_code(self, db: Session, invitation_code: str) -> Optional[Guest]: + return db.query(Guest).filter(Guest.invitation_code == invitation_code).first() + + def update_status(self, db: Session, guest_id: uuid.UUID, status: GuestStatus): + guest = self.get(db, guest_id) + if guest: + guest.status = status + guest.response_date = datetime.now(timezone.utc) + db.commit() + db.refresh(guest) + return guest + + def remove(self, db: Session, *, guest_id: str) -> Guest: + guest_id = uuid.UUID(guest_id) if isinstance(guest_id, str) else guest_id + obj = db.query(self.model).get(guest_id) + db.delete(obj) + db.commit() + return obj + +guest_crud = CRUDGuest(Guest) \ No newline at end of file diff --git a/backend/app/schemas/guests.py b/backend/app/schemas/guests.py new file mode 100644 index 0000000..0985dfc --- /dev/null +++ b/backend/app/schemas/guests.py @@ -0,0 +1,48 @@ +import uuid + +from pydantic import BaseModel, EmailStr, ConfigDict +from datetime import datetime +from typing import Optional, Any, Dict +from app.models.guest import GuestStatus +from uuid import UUID + +class GuestBase(BaseModel): + event_id: UUID + invited_by: UUID + user_id: Optional[UUID] = None + full_name: str + email: Optional[EmailStr] = None + phone: Optional[str] = None + max_additional_guests: Optional[int] = 0 + dietary_restrictions: Optional[str] = None + notes: Optional[str] = None + custom_fields: Optional[Dict[str, Any]] = None + can_bring_guests: Optional[bool] = False + + +class GuestCreate(GuestBase): + invitation_code: str + + +class GuestUpdate(BaseModel): + full_name: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + status: Optional[GuestStatus] = None + max_additional_guests: Optional[int] = None + actual_additional_guests: Optional[int] = None + dietary_restrictions: Optional[str] = None + notes: Optional[str] = None + custom_fields: Optional[Dict[str, Any]] = None + is_blocked: Optional[bool] = None + can_bring_guests: Optional[bool] = None + + +class GuestRead(GuestBase): + id: UUID + status: GuestStatus + invitation_sent_at: Optional[datetime] = None + response_date: Optional[datetime] = None + actual_additional_guests: int + is_blocked: bool + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/backend/tests/api/routes/events/test_guests.py b/backend/tests/api/routes/events/test_guests.py new file mode 100644 index 0000000..d4ef96a --- /dev/null +++ b/backend/tests/api/routes/events/test_guests.py @@ -0,0 +1,104 @@ +import uuid + +import pytest + +from app.api.routes.events.guests import router as guests_router +from app.models import GuestStatus + + +@pytest.fixture +def guest_data(): + return { + "event_id": str(uuid.uuid4()), # Correctly generates actual UUID objects + "invited_by": str(uuid.uuid4()), + "full_name": "John Doe", + "email": "john.doe@example.com", + "invitation_code": "INVITE12345", + "can_bring_guests": False + } + + +class TestGuestsRouter: + + @pytest.fixture(autouse=True) + def setup_method(self, create_test_client, db_session, mock_user, guest_fixture): + self.client = create_test_client( + router=guests_router, + prefix="/guests", + db_session=db_session, + user=mock_user + ) + self.db_session = db_session + self.mock_user = mock_user + self.endpoint = "/guests" + self.guest = guest_fixture + def test_create_guest_success(self, guest_data): + response = self.client.post(self.endpoint, json=guest_data) + assert response.status_code == 200 + data = response.json() + assert data["full_name"] == guest_data["full_name"] + assert data["email"] == guest_data["email"] + + def test_create_guest_missing_required_fields_fails(self): + incomplete_payload = { + "email": "john.doe@example.com" + } + response = self.client.post(self.endpoint, json=incomplete_payload) + assert response.status_code == 422 + + def test_read_guest_success(self, guest_fixture): + response = self.client.get(f"{self.endpoint}/{guest_fixture.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(guest_fixture.id) + assert data["full_name"] == guest_fixture.full_name + + def test_read_guest_not_found(self): + random_uuid = str(uuid.uuid4()) + response = self.client.get(f"{self.endpoint}/{random_uuid}") + assert response.status_code == 404 + assert response.json()["detail"] == "Guest not found" + + def test_update_guest_success(self): + update_data = {"full_name": "Jane Doe"} + response = self.client.put(f"{self.endpoint}/{self.guest.id}", json=update_data) + assert response.status_code == 200 + assert response.json()["full_name"] == "Jane Doe" + + def test_update_guest_not_found_fails(self): + fake_uuid = str(uuid.uuid4()) + update_data = {"full_name": "Nobody"} + response = self.client.put(f"{self.endpoint}/{fake_uuid}", json=update_data) + assert response.status_code == 404 + + def test_delete_guest_success(self): + response = self.client.delete(f"{self.endpoint}/{self.guest.id}") + assert response.status_code == 200 + assert response.json()["id"] == str(self.guest.id) + + def test_delete_guest_not_found_fails(self): + fake_uuid = str(uuid.uuid4()) + response = self.client.delete(f"{self.endpoint}/{fake_uuid}") + assert response.status_code == 404 + assert response.json()["detail"] == "Guest not found" + + def test_list_guests_success(self): + response = self.client.get(self.endpoint) + assert response.status_code == 200 + assert isinstance(response.json(), list) + assert any(g["id"] == str(self.guest.id) for g in response.json()) + + def test_guest_update_status_success(self): + response = self.client.patch( + f"{self.endpoint}/{self.guest.id}/status", + params={"status": "confirmed"} + ) + assert response.status_code == 200 + assert response.json()["status"] == GuestStatus.CONFIRMED.value + + def test_guest_status_update_invalid_status_fails(self): + response = self.client.patch( + f"{self.endpoint}/{self.guest.id}/status", + params={"status": "invalid_status"} + ) + assert response.status_code == 422