Getting environment configured better.

Signed-off-by: Cliff Hill <xlorep@darkhelm.org>
This commit is contained in:
2025-10-06 12:59:10 -04:00
parent bad6e59b10
commit f03f67bbde
10 changed files with 128 additions and 128 deletions

View File

@@ -1,22 +1,62 @@
ENVIRONMENT=development
BACKEND_PROTOCOL=http
BACKEND_HOST=localhost
BACKEND_PORT=8000
BACKEND_LOG_LEVEL=debug
FRONTEND_HOST=frontend
FRONTEND_PORT=3000
FRONTEND_LOG_LEVEL=debug
DATABASE_PROTOCOL=postgresql+asyncpg
DATABASE_USER=user
DATABASE_PASSWORD=password
DATABASE_NAME=mydb
# =============================
# Backend Database Configuration
# =============================
# These variables configure the backend database connection.
DATABASE_PROTOCOL=postgresql
DATABASE_USER=your_db_user
DATABASE_PASSWORD=your_db_password
DATABASE_NAME=your_db_name
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_URL=postgresql://your_db_user:your_db_password@localhost:5432/your_db_name
DEFAULT_BOOKING_INTERVAL_MINUTES=30
# =============================
# Backend Logging Configuration
# =============================
# Controls logging level and format for backend services.
BACKEND_LOG_LEVEL=INFO
BACKEND_LOG_FORMAT="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
BACKEND_LOG_DATE_FORMAT="%Y-%m-%d %H:%M:%S"
# =============================
# Backend SSE (Server-Sent Events) Configuration
# =============================
# Controls SSE test mode and timeout for streaming endpoints.
SSE_TEST_MODE=false
SSE_TIMEOUT=15
# =============================
# Backend Server Configuration
# =============================
# Host and port settings for backend and frontend services.
BACKEND_HOST=localhost
BACKEND_PORT=8000
FRONTEND_HOST=frontend
FRONTEND_PORT=3000
# =============================
# Backend Application Settings
# =============================
# Miscellaneous backend settings.
BOOKING_MAX_MONTHS=12
# ENVIRONMENT: Set to 'development', 'production', etc.
BACKEND_ENVIRONMENT=development
# DEBUG: Set to 'true' to enable debug mode, 'false' otherwise
BACKEND_DEBUG=false
# Protocol settings for backend and frontend services.
BACKEND_PROTOCOL=http
FRONTEND_PROTOCOL=http
# =============================
# Frontend Environment Variables
# =============================
# Variables used by the frontend application and for frontend-backend integration.
FRONTEND_LOG_LEVEL=info
# Frontend environment: 'development', 'production', etc.
FRONTEND_ENVIRONMENT=development
DEFAULT_BOOKING_INTERVAL_MINUTES=30
BUSINESS_START=08:00
BUSINESS_END=17:00
BUSINESS_END=18:00

View File

@@ -36,7 +36,13 @@ LOG_DATE_FORMAT = config(
"BACKEND_LOG_DATE_FORMAT", cast=str, default="%Y-%m-%d %H:%M:%S"
)
# SSE configuration
SSE_TEST_MODE = config("SSE_TEST_MODE", cast=bool, default=False)
SSE_TIMEOUT = config("SSE_TIMEOUT", cast=int, default=15)
# Server configuration
BACKEND_PROTOCOL = config("BACKEND_PROTOCOL", cast=str, default="http")
FRONTEND_PROTOCOL = config("FRONTEND_PROTOCOL", cast=str, default="http")
BACKEND_HOST = config("BACKEND_HOST", cast=str, default="localhost")
BACKEND_PORT = config("BACKEND_PORT", cast=int, default=8000)
FRONTEND_HOST = config("FRONTEND_HOST", cast=str, default="frontend")
@@ -44,6 +50,6 @@ FRONTEND_PORT = config("FRONTEND_PORT", cast=int, default=3000)
# Other environment variables
BOOKING_MAX_MONTHS = config("BOOKING_MAX_MONTHS", cast=int, default=12)
DEBUG = config("DEBUG", cast=bool, default=False)
DEBUG = config("BACKEND_DEBUG", cast=bool, default=False)
ENVIRONMENT = config("ENVIRONMENT", cast=str, default="development")
ECHO = ENVIRONMENT in {"development"}

View File

@@ -7,8 +7,10 @@ from fastapi.middleware.cors import CORSMiddleware
from backend.env import BACKEND_HOST
from backend.env import BACKEND_PORT
from backend.env import BACKEND_PROTOCOL
from backend.env import FRONTEND_HOST
from backend.env import FRONTEND_PORT
from backend.env import FRONTEND_PROTOCOL
from backend.logging import setup_logging
from backend.routers import bookings
from backend.routers import rooms
@@ -22,9 +24,9 @@ app = FastAPI(**app_configs)
app.add_middleware(
CORSMiddleware,
allow_origins=[
f"http://{BACKEND_HOST}:{FRONTEND_PORT}", # React dev server
f"http://{BACKEND_HOST}:{BACKEND_PORT}", # Docker Compose frontend
f"http://{FRONTEND_HOST}:{FRONTEND_PORT}", # Docker Compose service name
f"{FRONTEND_PROTOCOL}://{BACKEND_HOST}:{FRONTEND_PORT}", # React dev server
f"{BACKEND_PROTOCOL}://{BACKEND_HOST}:{BACKEND_PORT}", # Docker Compose frontend
f"{FRONTEND_PROTOCOL}://{FRONTEND_HOST}:{FRONTEND_PORT}", # Docker Compose service name
],
allow_credentials=True,
allow_methods=["*"],

View File

@@ -57,12 +57,14 @@ async def read_booking(
logger.info(f"Successfully retrieved booking with id: {booking_id}")
# Optionally, enrich booking object here if needed, but return the model object
return BookingResponse.model_validate(booking)
except NoResultFound as err:
logger.warning(f"Booking with id {booking_id} not found")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Booking with id {booking_id} not found",
) from err
except (SQLAlchemyError, InterfaceError) as err:
logger.exception(
f"Database error while fetching booking with id {booking_id}: {str(err)}"
@@ -71,6 +73,7 @@ async def read_booking(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error occurred",
) from err
except Exception as err:
logger.exception(
f"Unexpected error while fetching booking with id {booking_id}: {str(err)}"
@@ -111,6 +114,7 @@ async def create_booking(
session, publish_room_availability_event, **booking.model_dump()
)
return BookingResponse.model_validate(created_booking)
except ValueError as err:
logger.warning(f"Validation error while creating booking: {str(err)}")
# Detect overlap/conflict error
@@ -124,12 +128,14 @@ async def create_booking(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(err),
) from err
except SQLAlchemyError as err:
logger.exception(f"Database error while creating booking: {str(err)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error",
) from err
except Exception as err:
logger.exception(f"Unexpected error while creating booking: {str(err)}")
raise HTTPException(
@@ -172,14 +178,17 @@ async def update_existing_booking(
)
logger.info(f"Successfully updated booking with id: {booking_id}")
return BookingResponse.model_validate(updated_booking)
except NoResultFound as err:
logger.error(f"Booking with id {booking_id} not found: {err}")
raise HTTPException(status_code=404, detail="Booking not found") from err
except (SQLAlchemyError, InterfaceError) as err:
logger.error(
f"Database error during booking update: {booking_id}: {err}", exc_info=True
)
raise HTTPException(status_code=500, detail="Database error") from err
except ValueError as err:
logger.warning(
f"Validation error while updating booking with id {booking_id}: {str(err)}"
@@ -190,11 +199,13 @@ async def update_existing_booking(
status_code=status.HTTP_409_CONFLICT,
detail="Booking times overlap with an existing booking for this room.",
) from err
else:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(err),
) from err
except Exception as err:
logger.exception(
f"Unexpected error while updating booking with id {booking_id}: {str(err)}"
@@ -226,20 +237,24 @@ async def delete_existing_booking(
try:
await delete_booking_func(session, booking_id, publish_room_availability_event)
logger.info(f"Successfully deleted booking with id: {booking_id}")
except NoResultFound as err:
logger.error(f"Booking with id {booking_id} not found: {err}")
raise HTTPException(status_code=404, detail="Booking not found") from err
except (SQLAlchemyError, InterfaceError) as err:
logger.error(
f"Database error during booking delete: {booking_id}: {err}", exc_info=True
)
raise HTTPException(status_code=500, detail="Database error") from err
except ValueError as err:
logger.warning(f"Booking with id {booking_id} not found (ValueError)")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Booking with id {booking_id} not found",
) from err
except Exception as err:
logger.exception(
f"Unexpected error while deleting booking with id {booking_id}: {str(err)}"
@@ -278,6 +293,7 @@ async def read_bookings_for_month(
f"Successfully retrieved {len(bookings)} bookings for month: {month}"
)
return [BookingResponse.model_validate(booking) for booking in bookings]
except Exception as err:
logger.exception(f"Error fetching bookings for month {month}: {str(err)}")
raise HTTPException(

View File

@@ -23,6 +23,8 @@ from backend.dependencies.rooms import GetRoomService
from backend.dependencies.rooms import GetRoomsService
from backend.dependencies.rooms import NewRoomService
from backend.dependencies.rooms import UpdateRoomService
from backend.env import SSE_TEST_MODE
from backend.env import SSE_TIMEOUT
# from backend.models import Room
from backend.schemas.rooms import RoomCreate
@@ -31,13 +33,7 @@ from backend.schemas.rooms import RoomUpdate
logger = logging.getLogger(__name__)
DEFAULT_TEST_MODE = False
DEFAULT_TIMEOUT = 15.0
router = APIRouter(prefix="/rooms", tags=["rooms"])
room_availability_subscribers: list[asyncio.Queue[dict[str, Any]]] = []
@@ -60,11 +56,14 @@ async def event_generator(
while True:
if await request.is_disconnected():
break
try:
event = await asyncio.wait_for(queue.get(), timeout=timeout)
yield f"data: {json.dumps(event)}\n\n"
except asyncio.TimeoutError:
yield ": keep-alive\n\n"
finally:
if queue in room_availability_subscribers:
room_availability_subscribers.remove(queue)
@@ -84,10 +83,10 @@ async def publish_room_availability_event(event: dict[str, Any]) -> None:
async def stream_room_availability(
request: Request,
test_mode: bool = Query( # noqa: B008
DEFAULT_TEST_MODE, description="Force immediate keep-alive for testing"
SSE_TEST_MODE, description="Force immediate keep-alive for testing"
),
timeout: float = Query( # noqa: B008
DEFAULT_TIMEOUT, description="Keep-alive timeout in seconds"
SSE_TIMEOUT, description="Keep-alive timeout in seconds"
),
) -> StreamingResponse:
"""Stream real-time room availability changes using SSE.
@@ -119,6 +118,7 @@ async def _test_event_generator(
if test_mode:
yield ": keep-alive\n\n"
return
async for chunk in event_generator(request, queue, timeout):
yield chunk
@@ -148,15 +148,18 @@ async def read_room(
room = await get_room_func(session, room_id)
logger.info(f"Successfully retrieved room with id: {room_id}")
return RoomResponse.model_validate(room)
except NoResultFound as err:
logger.warning(f"Room not found: {str(err)}")
raise HTTPException(status_code=404, detail="Room not found") from err
except (SQLAlchemyError, asyncpg.InterfaceError) as err:
logger.error(f"Database error while fetching room: {str(err)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error occurred",
) from err
except Exception as err:
logger.error(f"Unexpected error while fetching room: {str(err)}")
raise HTTPException(
@@ -186,15 +189,18 @@ async def read_rooms(
rooms = await get_rooms_func(session)
logger.info(f"Successfully retrieved {len(rooms)} rooms")
return [RoomResponse.model_validate(room) for room in rooms]
except NoResultFound as err:
logger.warning(f"Room not found: {str(err)}")
raise HTTPException(status_code=404, detail="Room not found") from err
except (SQLAlchemyError, asyncpg.InterfaceError) as err:
logger.error(f"Database error while fetching rooms: {str(err)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error occurred",
) from err
except Exception as err:
logger.error(f"Unexpected error while fetching rooms: {str(err)}")
raise HTTPException(
@@ -230,15 +236,18 @@ async def create_room(
created_room = await new_room_func(session, **room.model_dump())
logger.info(f"Successfully created room with id: {created_room.id}")
return RoomResponse.model_validate(created_room)
except NoResultFound as err:
logger.warning(f"Room not found: {str(err)}")
raise HTTPException(status_code=404, detail="Room not found") from err
except (SQLAlchemyError, asyncpg.InterfaceError) as err:
logger.error(f"Database error while creating room: {str(err)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database error occurred",
) from err
except Exception as err:
logger.error(f"Unexpected error while creating room: {str(err)}")
raise HTTPException(
@@ -276,17 +285,21 @@ async def patch_room(
)
logger.info(f"Successfully updated room with id: {room_id}")
return RoomResponse.model_validate(updated_room)
except ValueError as err:
logger.warning(str(err))
raise HTTPException(status_code=400, detail=str(err)) from err
except NoResultFound as err:
logger.warning(f"Room not found: {str(err)}")
raise HTTPException(status_code=404, detail="Room not found") from err
except (SQLAlchemyError, asyncpg.InterfaceError) as err:
logger.error(
f"Database error while updating room with id {room_id}: {str(err)}"
)
raise HTTPException(status_code=500, detail="Database error occurred") from err
except Exception as err:
logger.error(
f"Unexpected error while updating room with id {room_id}: {str(err)}"
@@ -318,17 +331,21 @@ async def delete_existing_room(
try:
await delete_room_func(session, room_id)
logger.info(f"Successfully deleted room with id: {room_id}")
except ValueError as err:
logger.warning(str(err))
raise HTTPException(status_code=404, detail=str(err)) from err
except NoResultFound as err:
logger.warning(f"Room not found: {str(err)}")
raise HTTPException(status_code=404, detail="Room not found") from err
except (SQLAlchemyError, asyncpg.InterfaceError) as err:
logger.error(
f"Database error while deleting room with id {room_id}: {str(err)}"
)
raise HTTPException(status_code=500, detail="Database error occurred") from err
except Exception as err:
logger.error(
f"Unexpected error while deleting room with id {room_id}: {str(err)}"

View File

@@ -45,9 +45,11 @@ async def read_users(
users = await get_users_func(session)
logger.info(f"Successfully retrieved {len(users)} users")
return [UserResponse.model_validate(user) for user in users]
except NoResultFound as err:
logger.warning(f"User not found: {str(err)}")
raise HTTPException(status_code=404, detail="User not found") from err
except Exception as err:
logger.error(f"Unexpected error while fetching users: {str(err)}")
raise HTTPException(

View File

@@ -37,9 +37,11 @@ async def get_rooms(session: AsyncSession) -> list[Room]:
rooms = cast(list[Room], result.all())
logger.info(f"Successfully retrieved {len(rooms)} rooms")
return rooms
except Exception as e:
logger.error(f"Failed to retrieve rooms: {str(e)}")
raise
finally:
logger.debug("Exiting get_rooms")
@@ -65,9 +67,11 @@ async def get_room(session: AsyncSession, room_id: int) -> Room:
raise NoResultFound(f"Room with id {room_id} not found")
logger.info(f"Successfully retrieved room with id: {room_id}")
return room
except Exception as e:
logger.error(f"Failed to retrieve room with id {room_id}: {str(e)}")
raise
finally:
logger.debug("Exiting get_room")
@@ -92,10 +96,12 @@ async def new_room(session: AsyncSession, **kwargs: Unpack[RoomData]) -> Room:
await session.commit()
logger.info(f"Successfully created new room with id: {room.id}")
return room
except Exception as e:
logger.error(f"Failed to create new room: {str(e)}")
await session.rollback()
raise
finally:
logger.debug("Exiting new_room")
@@ -127,10 +133,12 @@ async def update_room(
room = await get_room(session, room_id)
logger.info(f"Successfully updated room with id: {room_id}")
return room
except Exception as e:
logger.error(f"Failed to update room with id {room_id}: {str(e)}")
await session.rollback()
raise
finally:
logger.debug("Exiting update_room")
@@ -154,9 +162,11 @@ async def delete_room(session: AsyncSession, room_id: int) -> None:
raise ValueError(f"No room found with id {room_id}")
await session.commit()
logger.info(f"Successfully deleted room with id: {room_id}")
except Exception as e:
logger.error(f"Failed to delete room with id {room_id}: {str(e)}")
await session.rollback()
raise
finally:
logger.debug("Exiting delete_room")

View File

@@ -31,6 +31,7 @@ async def get_users(session: AsyncSession) -> list[User]:
users: list[User] = cast(list[User], result.all())
logger.info("Retrieved %d users", len(users))
return users
except Exception:
logger.exception("Failed to retrieve users")
raise
@@ -58,6 +59,7 @@ async def get_user(session: AsyncSession, email: str) -> User:
raise NoResultFound(f"User with email {email} not found")
logger.info("Retrieved user with email: %s", email)
return user
except Exception:
logger.exception("Failed to retrieve user with email '%s'", email)
raise

View File

@@ -5,20 +5,17 @@ from unittest.mock import MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.exc import NoResultFound
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.models import User
from backend.models import UserList
from backend.services.users import get_user
from backend.models import User # Add this import for the User model
from backend.services.users import get_users
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.services.users"], indirect=True)
async def test_get_users_success(
async_session: AsyncSession, sample_users: UserList, mock_logger: MagicMock
async_session: AsyncSession, sample_users: list[User], mock_logger: MagicMock
) -> None:
"""Test successful retrieval of all users from the database.
@@ -32,7 +29,7 @@ async def test_get_users_success(
mock_scalars = AsyncMock(return_value=mock_scalars_result)
async_session.scalars = mock_scalars # type: ignore [method-assign]
result: UserList = await get_users(async_session)
result: list[User] = await get_users(async_session)
assert isinstance(result, list)
assert len(result) == 2
@@ -57,7 +54,7 @@ async def test_get_users_empty(
mock_scalars = AsyncMock(return_value=mock_scalars_result)
async_session.scalars = mock_scalars # type: ignore [method-assign]
result: UserList = await get_users(async_session)
result: list[User] = await get_users(async_session)
assert isinstance(result, list)
assert len(result) == 0
@@ -85,95 +82,3 @@ async def test_get_users_database_error(
with pytest.raises(SQLAlchemyError):
await get_users(async_session)
async_session.scalars.assert_called_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.services.users"], indirect=True)
@pytest.mark.parametrize(
"email, expected_name",
[
("user1@example.com", "User One"),
("user2@example.com", "User Two"),
],
ids=["user1", "user2"],
)
async def test_get_user_success(
async_session: AsyncSession, email: str, expected_name: str, mock_logger: MagicMock
) -> None:
"""Test successful retrieval of a user by email.
Verifies that get_user returns the correct user and constructs the correct query.
Args:
async_session: The asynchronous database session.
email: The email of the user to retrieve.
expected_name: The expected name of the user.
mock_logger: The mocked logger instance.
"""
user = User(email=email, name=expected_name)
async_session.scalar = AsyncMock(return_value=user) # type: ignore [method-assign]
result: User = await get_user(async_session, email)
assert result.email == email
assert result.name == expected_name
async_session.scalar.assert_called_once()
assert async_session.scalar.call_args.args[0].compare(
select(User).where(User.email == email)
)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.services.users"], indirect=True)
@pytest.mark.parametrize(
"email",
[
"nonexistent@example.com",
"invalid@domain.com",
],
ids=["nonexistent_email", "invalid_email"],
)
async def test_get_user_not_found(
async_session: AsyncSession, mock_logger: MagicMock, email: str
) -> None:
"""Test handling of non-existent user in get_user.
Verifies that get_user raises NoResultFound when the user is not found.
Args:
async_session: The asynchronous database session.
mock_logger: The mocked logger instance.
email: The email of the user to retrieve.
"""
async_session.scalar = AsyncMock(return_value=None) # type: ignore [method-assign]
with pytest.raises(NoResultFound):
await get_user(async_session, email)
async_session.scalar.assert_called_once()
assert async_session.scalar.call_args.args[0].compare(
select(User).where(User.email == email)
)
@pytest.mark.asyncio
@pytest.mark.parametrize("mock_logger", ["backend.services.users"], indirect=True)
async def test_get_user_database_error(
async_session: AsyncSession, mock_logger: MagicMock
) -> None:
"""Test handling of database errors in get_users.
Args:
async_session: The asynchronous database session.
mock_logger: The mocked logger instance.
"""
email: str = "user1@example.com"
async_session.scalar = AsyncMock( # type: ignore [method-assign]
side_effect=SQLAlchemyError("Database error")
)
with pytest.raises(SQLAlchemyError):
await get_user(async_session, email)
async_session.scalar.assert_called_once()
assert async_session.scalar.call_args.args[0].compare(
select(User).where(User.email == email)
)

View File

@@ -26,7 +26,7 @@ export const ENV = {
BACKEND_HOST: process.env.BACKEND_HOST || "localhost",
BACKEND_PORT: process.env.BACKEND_PORT || "8000",
FRONTEND_LOG_LEVEL: process.env.FRONTEND_LOG_LEVEL || "info",
NODE_ENV: process.env.NODE_ENV || "development",
NODE_ENV: process.env.FRONTEND_ENVIRONMENT || "development",
DEFAULT_BOOKING_INTERVAL_MINUTES:
Number(process.env.DEFAULT_BOOKING_INTERVAL_MINUTES) || 30,
BUSINESS_START: process.env.BUSINESS_START || "08:00",