From ca05a4f26b94c1006804757eee670b00f80e144c Mon Sep 17 00:00:00 2001 From: Cliff Hill Date: Thu, 28 Aug 2025 08:47:38 -0400 Subject: [PATCH] Getting the frontend and backend wired up correctly. Signed-off-by: Cliff Hill --- backend/src/backend/main.py | 15 ++++++ backend/src/backend/models.py | 1 + backend/src/backend/routers/bookings.py | 40 ++++++++++----- backend/src/backend/services/bookings.py | 63 +++++++++++++++++++----- backend/tests/routers/test_bookings.py | 6 +-- backend/tests/services/test_bookings.py | 51 +++++++++++++++++-- compose.dev.yml | 2 + compose.yml | 4 +- frontend/src/index.tsx | 3 +- 9 files changed, 151 insertions(+), 34 deletions(-) diff --git a/backend/src/backend/main.py b/backend/src/backend/main.py index 9c9099a1..a4f87355 100644 --- a/backend/src/backend/main.py +++ b/backend/src/backend/main.py @@ -9,6 +9,7 @@ from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from backend import config from backend.logging import setup_logging @@ -45,8 +46,22 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app_configs["lifespan"] = lifespan + app = FastAPI(**app_configs) +# Allow CORS for frontend (adjust origins as needed for production) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", # React dev server + "http://localhost:8000", # Docker Compose frontend (if served on 8000) + "http://frontend:3000", # Docker Compose service name + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + app.include_router(users.router) app.include_router(rooms.router) app.include_router(bookings.router) diff --git a/backend/src/backend/models.py b/backend/src/backend/models.py index 6adaad01..f1437853 100644 --- a/backend/src/backend/models.py +++ b/backend/src/backend/models.py @@ -134,6 +134,7 @@ async def create_db_and_tables() -> None: # pragma: no cover await conn.run_sync(Base.metadata.create_all) +# Python type aliases for lists type UserList = list[User] type RoomList = list[Room] type BookingList = list[Booking] diff --git a/backend/src/backend/routers/bookings.py b/backend/src/backend/routers/bookings.py index 9d8be42d..f6bdc3fd 100644 --- a/backend/src/backend/routers/bookings.py +++ b/backend/src/backend/routers/bookings.py @@ -1,10 +1,10 @@ """Routes for booking-related operations in the backend.""" import logging -from typing import List from fastapi import APIRouter from fastapi import HTTPException +from fastapi import Query from fastapi import status from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import SQLAlchemyError @@ -30,25 +30,39 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/bookings", tags=["bookings"]) -@router.get("/room/{room_id}", response_model=List[BookingResponse]) -async def read_bookings_for_room(room_id: int, session: DBSession) -> BookingList: - """Retrieve all bookings for a specific room. +_default_date = Query(None, description="Filter bookings by date (YYYY-MM-DD)") + + +@router.get("/room/{room_id}", response_model=list[BookingResponse]) +async def read_bookings_for_room( + room_id: int, + session: DBSession, + date: str = _default_date, +) -> BookingList: + """Retrieve bookings for a specific room, optionally filtered by date. Args: - room_id: ID of the room to retrieve bookings for. - session: Database session. - - Returns: - List of bookings for the room. + room_id (int): The ID of the room to retrieve bookings for. + session (DBSession): The database session. + date (str): Optional date string (YYYY-MM-DD) to filter bookings by start_time. Raises: - HTTPException: If a database error or unexpected error occurs. + HTTPException: If the date format is invalid. + + Returns: + BookingList: List of bookings for the room, filtered by date if provided. """ - logger.debug(f"Received request to fetch bookings for room_id: {room_id}") + logger.debug( + f"Received request to fetch bookings for room_id: {room_id} and date: {date}" + ) try: - bookings = await get_bookings_for_room(session, room_id) + try: + bookings = await get_bookings_for_room(session, room_id, date) + except ValueError as ve: + raise HTTPException(status_code=422, detail=str(ve)) from ve logger.info( - f"Successfully retrieved {len(bookings)} bookings for room_id: {room_id}" + f"Successfully retrieved {len(bookings)} bookings for room_id: {room_id} " + f"and date: {date}" ) return bookings except SQLAlchemyError as e: diff --git a/backend/src/backend/services/bookings.py b/backend/src/backend/services/bookings.py index 7e6278f8..10df92a3 100644 --- a/backend/src/backend/services/bookings.py +++ b/backend/src/backend/services/bookings.py @@ -11,11 +11,13 @@ from datetime import timezone from typing import Any from typing import Awaitable from typing import Callable +from typing import List from typing import Sequence from typing import TypedDict from typing import Unpack from typing import cast +from sqlalchemy import and_ from sqlalchemy import delete from sqlalchemy import func from sqlalchemy import select @@ -23,6 +25,15 @@ from sqlalchemy import update from sqlalchemy.exc import NoResultFound from sqlalchemy.ext.asyncio import AsyncSession +from backend.models import Booking + + +# BookingList is a type alias for List[Booking] +try: + from backend.models import BookingList +except ImportError: + BookingList = List[Booking] + from backend import config from backend.models import Booking from backend.models import BookingList @@ -43,30 +54,58 @@ class BookingParams(TypedDict): """ -async def get_bookings_for_room(session: AsyncSession, room_id: int) -> BookingList: - """Retrieve all bookings for a specific room. +async def get_bookings_for_room( + session: AsyncSession, room_id: int, date: str | None = None +) -> BookingList: + """Retrieve bookings for a specific room, optionally filtered by date. + + If a date string (YYYY-MM-DD) is provided, only bookings whose start_time falls on that date + are returned. If no date is provided, all bookings for the room are returned. Args: - session: Database session. - room_id: ID of the room to retrieve bookings for. + session: The asynchronous database session. + room_id: The ID of the room to retrieve bookings for. + date: Optional date string (YYYY-MM-DD) to filter bookings by start_time. Returns: - List of bookings associated with the room. + List of bookings for the room, filtered by date if provided. Raises: - Exception: If any database error occurs. + ValueError: If the date string is not in YYYY-MM-DD format. """ - logger.debug(f"Entering get_bookings_for_room with room_id: {room_id}") + logger.debug( + f"Entering get_bookings_for_room with room_id: {room_id} and date: {date}" + ) try: - stmt = select(Booking).where(Booking.room_id == room_id) - result = await session.scalars(stmt) - bookings = cast(BookingList, result.all()) + if date: + try: + date_obj = datetime.strptime(date, "%Y-%m-%d") + except ValueError as e: + raise ValueError("Invalid date format. Use YYYY-MM-DD.") from e + start_of_day = date_obj + end_of_day = date_obj + timedelta(days=1) + stmt = select(Booking).where( + Booking.room_id == room_id, + and_( + Booking.start_time >= start_of_day, + Booking.start_time < end_of_day, + ), + ) + result = await session.scalars(stmt) + bookings = list(result.all()) + else: + stmt = select(Booking).where(Booking.room_id == room_id) + result = await session.scalars(stmt) + bookings = cast(BookingList, result.all()) logger.info( - f"Successfully retrieved {len(bookings)} bookings for room_id: {room_id}" + f"Successfully retrieved {len(bookings)} bookings for room_id: {room_id} " + f"and date: {date}" ) return bookings except Exception as e: - logger.error(f"Failed to retrieve bookings for room_id {room_id}: {str(e)}") + logger.error( + f"Failed to retrieve bookings for room_id {room_id} and date {date}: {str(e)}" + ) raise finally: logger.debug("Exiting get_bookings_for_room") diff --git a/backend/tests/routers/test_bookings.py b/backend/tests/routers/test_bookings.py index 58cf2141..5f680cae 100644 --- a/backend/tests/routers/test_bookings.py +++ b/backend/tests/routers/test_bookings.py @@ -50,7 +50,7 @@ async def test_get_bookings_for_room_success( } for booking in sample_bookings ] - mock_get_bookings.assert_called_once_with(ANY, room_id) + mock_get_bookings.assert_called_once_with(ANY, room_id, None) @pytest.mark.asyncio @@ -76,7 +76,7 @@ async def test_get_bookings_for_room_empty( assert response.status_code == 200 assert response.json() == [] - mock_get_bookings.assert_called_once_with(ANY, room_id) + mock_get_bookings.assert_called_once_with(ANY, room_id, None) @pytest.mark.asyncio @@ -102,7 +102,7 @@ async def test_get_bookings_for_room_database_error( assert response.status_code == 500 assert "detail" in response.json() - mock_get_bookings.assert_called_once_with(ANY, room_id) + mock_get_bookings.assert_called_once_with(ANY, room_id, None) @pytest.mark.asyncio diff --git a/backend/tests/services/test_bookings.py b/backend/tests/services/test_bookings.py index fb631ec5..3506d743 100644 --- a/backend/tests/services/test_bookings.py +++ b/backend/tests/services/test_bookings.py @@ -33,10 +33,7 @@ from backend.services.bookings import update_booking async def test_get_bookings_for_room_success( async_session: AsyncSession, sample_bookings: BookingList, mock_logger: MagicMock ) -> None: - """Test successful retrieval of all bookings for a room. - - Verifies that get_bookings_for_room returns the expected list of bookings and constructs - the correct SQLAlchemy query. + """Test retrieval of all bookings for a room with no date filter. Args: async_session: The asynchronous database session. @@ -58,6 +55,52 @@ async def test_get_bookings_for_room_success( ) +@pytest.mark.asyncio +@pytest.mark.parametrize("mock_logger", ["backend.services.bookings"], indirect=True) +async def test_get_bookings_for_room_with_date( + async_session: AsyncSession, sample_bookings: BookingList, mock_logger: MagicMock +) -> None: + """Test retrieval of bookings for a room filtered by date. + + Args: + async_session: The asynchronous database session. + sample_bookings: The mocked list of bookings to return. + mock_logger: The mocked logger instance. + """ + room_id = 1 + date = "2025-08-28" + mock_scalars_result = AsyncMock() + mock_scalars_result.all = MagicMock(return_value=sample_bookings) + mock_scalars = AsyncMock(return_value=mock_scalars_result) + with patch.object(async_session, "scalars", mock_scalars) as scalars_mock: + result: BookingList = await get_bookings_for_room(async_session, room_id, date) + assert isinstance(result, list) + assert result == sample_bookings + scalars_mock.assert_called_once() + # Check the query includes the date filter + stmt = scalars_mock.call_args.args[0] + assert stmt.whereclause is not None + assert str(stmt.whereclause).find("start_time >=") != -1 + assert str(stmt.whereclause).find("start_time <") != -1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mock_logger", ["backend.services.bookings"], indirect=True) +async def test_get_bookings_for_room_invalid_date( + async_session: AsyncSession, mock_logger: MagicMock +) -> None: + """Test that an invalid date string raises ValueError. + + Args: + async_session: The asynchronous database session. + mock_logger: The mocked logger instance. + """ + room_id = 1 + invalid_date = "not-a-date" + with pytest.raises(ValueError): + await get_bookings_for_room(async_session, room_id, invalid_date) + + @pytest.mark.asyncio @pytest.mark.parametrize("mock_logger", ["backend.services.bookings"], indirect=True) async def test_get_bookings_for_room_empty( diff --git a/compose.dev.yml b/compose.dev.yml index 55c79e0f..8381d3f6 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -33,6 +33,8 @@ services: extends: file: compose.yml service: frontend + environment: + REACT_APP_API_URL: http://localhost:8000 volumes: - ./frontend/src:/app/src - ./frontend/public:/app/public diff --git a/compose.yml b/compose.yml index 76dbaa3e..2176d53a 100644 --- a/compose.yml +++ b/compose.yml @@ -5,7 +5,7 @@ services: build: context: ./backend dockerfile: Dockerfile - expose: + ports: - "8000:8000" environment: ENVIRONMENT: production @@ -22,6 +22,8 @@ services: build: context: ./frontend dockerfile: Dockerfile + environment: + REACT_APP_API_URL: http://localhost:8000 ports: - "3000:3000" depends_on: diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 54c82cab..1e73ba9b 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -7,7 +7,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import axios from "axios"; const queryClient = new QueryClient(); -axios.defaults.baseURL = "http://backend:8000"; +axios.defaults.baseURL = + process.env.REACT_APP_API_URL || "http://localhost:8000"; const root = ReactDOM.createRoot( document.getElementById("root") as HTMLElement