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