From 28305eb36fadf02f9c54ef14da8a7766dd2c4664 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Thu, 6 Nov 2025 21:32:56 +0100 Subject: [PATCH] added repositories with dependency injection --- src/baby_monitor/repositories/__init__.py | 13 +++ .../credentials/env_credentials.py | 37 +++++++++ .../repositories/dependencies/__init__.py | 11 +++ .../get_credentials_repository.py | 44 +++++++++++ .../repositories/dependencies/get_database.py | 46 +++++++++++ .../dependencies/get_token_repository.py | 79 +++++++++++++++++++ .../dependencies/get_user_repository.py | 33 ++++++++ .../repositories/interfaces/__init__.py | 17 ++++ .../credentials_repository_interface.py | 15 ++++ .../interfaces/token_repository_interface.py | 23 ++++++ .../interfaces/user_repository_interface.py | 19 +++++ .../repositories/token/memory_token.py | 50 ++++++++++++ .../repositories/token/redis_token.py | 45 +++++++++++ .../repositories/user/postgresql_user.py | 64 +++++++++++++++ .../repositories/user/sqlite_user.py | 50 ++++++++++++ 15 files changed, 546 insertions(+) create mode 100644 src/baby_monitor/repositories/__init__.py create mode 100644 src/baby_monitor/repositories/credentials/env_credentials.py create mode 100644 src/baby_monitor/repositories/dependencies/__init__.py create mode 100644 src/baby_monitor/repositories/dependencies/get_credentials_repository.py create mode 100644 src/baby_monitor/repositories/dependencies/get_database.py create mode 100644 src/baby_monitor/repositories/dependencies/get_token_repository.py create mode 100644 src/baby_monitor/repositories/dependencies/get_user_repository.py create mode 100644 src/baby_monitor/repositories/interfaces/__init__.py create mode 100644 src/baby_monitor/repositories/interfaces/credentials_repository_interface.py create mode 100644 src/baby_monitor/repositories/interfaces/token_repository_interface.py create mode 100644 src/baby_monitor/repositories/interfaces/user_repository_interface.py create mode 100644 src/baby_monitor/repositories/token/memory_token.py create mode 100644 src/baby_monitor/repositories/token/redis_token.py create mode 100644 src/baby_monitor/repositories/user/postgresql_user.py create mode 100644 src/baby_monitor/repositories/user/sqlite_user.py diff --git a/src/baby_monitor/repositories/__init__.py b/src/baby_monitor/repositories/__init__.py new file mode 100644 index 0000000..b909222 --- /dev/null +++ b/src/baby_monitor/repositories/__init__.py @@ -0,0 +1,13 @@ +"""Repository implementations package.""" + +from baby_monitor.repositories.dependencies import ( + get_credentials_repository, + get_token_repository, + get_user_repository, +) + +__all__ = [ + "get_credentials_repository", + "get_token_repository", + "get_user_repository", +] diff --git a/src/baby_monitor/repositories/credentials/env_credentials.py b/src/baby_monitor/repositories/credentials/env_credentials.py new file mode 100644 index 0000000..bdcf269 --- /dev/null +++ b/src/baby_monitor/repositories/credentials/env_credentials.py @@ -0,0 +1,37 @@ +"""Environment-based credentials repository.""" + +import os +from baby_monitor.repositories.interfaces import CredentialsRepositoryInterface + + +class EnvCredentialsRepository(CredentialsRepositoryInterface): + """ + Credentials repository that reads from environment variables. + + Environment variables: + - ADMIN_USERNAME: Admin username (default: "admin") + - ADMIN_PASSWORD: Admin password (required in production) + """ + + def __init__(self) -> None: + self.admin_username = os.getenv("ADMIN_USERNAME", "admin") + self.admin_password = os.getenv("ADMIN_PASSWORD") + + # Warn if using default password in production + env = os.getenv("ENVIRONMENT", "production") + if env == "production" and not self.admin_password: + raise ValueError( + "ADMIN_PASSWORD environment variable must be set in production" + ) + + # Use default for development + if not self.admin_password: + self.admin_password = "password" + + def verify_admin_credentials(self, username: str, password: str) -> bool: + """Verify admin username and password.""" + return username == self.admin_username and password == self.admin_password + + def get_admin_username(self) -> str: + """Get the admin username.""" + return self.admin_username diff --git a/src/baby_monitor/repositories/dependencies/__init__.py b/src/baby_monitor/repositories/dependencies/__init__.py new file mode 100644 index 0000000..4d81ee4 --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/__init__.py @@ -0,0 +1,11 @@ +"""Dependency injection for repositories.""" + +from .get_credentials_repository import get_credentials_repository +from .get_token_repository import get_token_repository +from .get_user_repository import get_user_repository + +__all__ = [ + "get_user_repository", + "get_token_repository", + "get_credentials_repository", +] diff --git a/src/baby_monitor/repositories/dependencies/get_credentials_repository.py b/src/baby_monitor/repositories/dependencies/get_credentials_repository.py new file mode 100644 index 0000000..daa0d3f --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_credentials_repository.py @@ -0,0 +1,44 @@ +"""Definition of get_credentials_repository dependency.""" + +import logging + +from baby_monitor.repositories.interfaces import ( + CredentialsRepositoryInterface, +) +from baby_monitor.repositories.credentials.env_credentials import ( + EnvCredentialsRepository, +) + +# Singleton instance +_credentials_repository: CredentialsRepositoryInterface | None = None + + +def _initialize_credentials_repository() -> CredentialsRepositoryInterface: + """ + Initialize credentials repository based on environment. + + Currently uses environment variables. + Can be extended to support: + - Database-backed credentials + - External secret managers (AWS Secrets Manager, HashiCorp Vault) + - LDAP/Active Directory + """ + + # Setup logger + logger = logging.getLogger(__name__) + + logger.info("Using environment variable based credentials repository") + return EnvCredentialsRepository() + + +def get_credentials_repository() -> CredentialsRepositoryInterface: + """ + Get credentials repository instance. + """ + + global _credentials_repository + + if _credentials_repository is None: + _credentials_repository = _initialize_credentials_repository() + + return _credentials_repository diff --git a/src/baby_monitor/repositories/dependencies/get_database.py b/src/baby_monitor/repositories/dependencies/get_database.py new file mode 100644 index 0000000..6c9e0b8 --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_database.py @@ -0,0 +1,46 @@ +"""Database configuration and session management.""" + +import os +from pathlib import Path +from collections.abc import Generator +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker, Session + +# Database directory +DATA_DIR = Path(os.getenv("DATA_DIR", "/data")) + +# SQLite database URL +DATABASE_URL = f"sqlite:///{DATA_DIR}/baby_monitor.db" + +# Create engine with check_same_thread=False for SQLite +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False}, + echo=False, # Set to True for SQL query logging +) + +# Session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for declarative models +Base = declarative_base() + + +def init_db() -> None: + """Initialize the database by creating all tables.""" + # Ensure data directory exists + DATA_DIR.mkdir(parents=True, exist_ok=True) + Base.metadata.create_all(bind=engine) + + +def get_database() -> Generator[Session, None, None]: + """ + Dependency that provides a database session. + + Yields a database session and ensures it's closed after use. + """ + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/src/baby_monitor/repositories/dependencies/get_token_repository.py b/src/baby_monitor/repositories/dependencies/get_token_repository.py new file mode 100644 index 0000000..23f126d --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_token_repository.py @@ -0,0 +1,79 @@ +"""Definition of get_token_repository dependency.""" + +import os +import logging + +from baby_monitor.repositories.interfaces import ( + TokenRepositoryInterface, +) +from baby_monitor.repositories.token.memory_token import ( + InMemoryTokenRepository, +) + +# Singleton instance +_token_repository: TokenRepositoryInterface | None = None + + +def _initialize_token_repository() -> TokenRepositoryInterface: + """ + Initialize token repository based on environment. + + Uses Redis if REDIS_URI is set, otherwise uses in-memory storage. + Raises exception if REDIS_URI is set but connection fails. + """ + + # Setup logger + logger = logging.getLogger(__name__) + + # Check for REDIS_URI environment variable + redis_uri = os.getenv("REDIS_URI") + + # Use in-memory token repository if REDIS_URI is not set + if not redis_uri: + logger.info("Using in-memory token storage") + return InMemoryTokenRepository() + + # Attempt to use RedisTokenRepository + try: + import redis + except ImportError as e: + raise RuntimeError( + "REDIS_URI is set but redis package is not installed. " + "Install with: uv sync --extra redis" + ) from e + + try: + from baby_monitor.repositories.token.redis_token import ( + RedisTokenRepository, + ) + + redis_client = redis.from_url(redis_uri) + # Test connection + redis_client.ping() + logger.info(f"Connected to Redis at {redis_uri}") + return RedisTokenRepository(redis_client) + except redis.ConnectionError as e: + raise RuntimeError( + f"Failed to connect to Redis at {redis_uri}. " + f"Ensure Redis is running and accessible. Error: {e}" + ) from e + except Exception as e: + raise RuntimeError( + f"Failed to initialize Redis token repository: {e}" + ) from e + + +def get_token_repository() -> TokenRepositoryInterface: + """ + Get token repository instance. + + Automatically uses Redis if REDIS_URI environment variable is set, + otherwise falls back to in-memory storage. + """ + + global _token_repository + + if _token_repository is None: + _token_repository = _initialize_token_repository() + + return _token_repository diff --git a/src/baby_monitor/repositories/dependencies/get_user_repository.py b/src/baby_monitor/repositories/dependencies/get_user_repository.py new file mode 100644 index 0000000..8d6fda4 --- /dev/null +++ b/src/baby_monitor/repositories/dependencies/get_user_repository.py @@ -0,0 +1,33 @@ +"""Definition of get_user_repository dependency.""" + +from typing import Annotated +from fastapi import Depends +from sqlalchemy.orm import Session + +from baby_monitor.repositories.dependencies.get_database import ( + get_database, +) +from baby_monitor.repositories.interfaces import ( + UserRepositoryInterface, +) +from baby_monitor.repositories.user.sqlite_user import ( + SQLiteUserRepository, +) + +# Singleton instance +_user_repository: UserRepositoryInterface | None = None + + +def get_user_repository( + db: Annotated[Session, Depends(get_database)], +) -> UserRepositoryInterface: + """ + Get user repository instance. + """ + + global _user_repository + + if _user_repository is None: + _user_repository = SQLiteUserRepository(db) + + return _user_repository diff --git a/src/baby_monitor/repositories/interfaces/__init__.py b/src/baby_monitor/repositories/interfaces/__init__.py new file mode 100644 index 0000000..0f318da --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/__init__.py @@ -0,0 +1,17 @@ +"""Repository interfaces package.""" + +from .credentials_repository_interface import ( + CredentialsRepositoryInterface, +) +from .token_repository_interface import ( + TokenRepositoryInterface, +) +from .user_repository_interface import ( + UserRepositoryInterface, +) + +__all__ = [ + "UserRepositoryInterface", + "TokenRepositoryInterface", + "CredentialsRepositoryInterface", +] diff --git a/src/baby_monitor/repositories/interfaces/credentials_repository_interface.py b/src/baby_monitor/repositories/interfaces/credentials_repository_interface.py new file mode 100644 index 0000000..4656aae --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/credentials_repository_interface.py @@ -0,0 +1,15 @@ +"""Definition of CredentialsRepositoryInterface.""" + +from abc import ABC, abstractmethod + + +class CredentialsRepositoryInterface(ABC): + """Interface for credentials/secrets management.""" + + @abstractmethod + def verify_admin_credentials(self, username: str, password: str) -> bool: + """Verify admin username and password.""" + + @abstractmethod + def get_admin_username(self) -> str: + """Get the admin username.""" diff --git a/src/baby_monitor/repositories/interfaces/token_repository_interface.py b/src/baby_monitor/repositories/interfaces/token_repository_interface.py new file mode 100644 index 0000000..6cb24cb --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/token_repository_interface.py @@ -0,0 +1,23 @@ +"""Definition of the TokenRepositoryInterface.""" + +from abc import ABC, abstractmethod + + +class TokenRepositoryInterface(ABC): + """Interface for token storage operations.""" + + @abstractmethod + def store(self, token: str, user_id: int, ttl: int = 3600) -> None: + """Store a token with optional TTL (time to live in seconds).""" + + @abstractmethod + def verify(self, token: str) -> int | None: + """Verify token and return user_id if valid, None otherwise.""" + + @abstractmethod + def invalidate(self, token: str) -> None: + """Invalidate/delete a token.""" + + @abstractmethod + def cleanup_expired(self) -> None: + """Remove expired tokens (for implementations without auto-expiry).""" diff --git a/src/baby_monitor/repositories/interfaces/user_repository_interface.py b/src/baby_monitor/repositories/interfaces/user_repository_interface.py new file mode 100644 index 0000000..3b49b48 --- /dev/null +++ b/src/baby_monitor/repositories/interfaces/user_repository_interface.py @@ -0,0 +1,19 @@ +"""Definition of UserRepositoryInterface.""" + +from abc import ABC, abstractmethod + + +class UserRepositoryInterface(ABC): + """Interface for user data operations.""" + + @abstractmethod + def get_by_username(self, username: str) -> dict | None: + """Get user by username.""" + + @abstractmethod + def create(self, username: str, hashed_password: str) -> dict: + """Create a new user.""" + + @abstractmethod + def get_by_id(self, user_id: int) -> dict | None: + """Get user by ID.""" diff --git a/src/baby_monitor/repositories/token/memory_token.py b/src/baby_monitor/repositories/token/memory_token.py new file mode 100644 index 0000000..6d67d7b --- /dev/null +++ b/src/baby_monitor/repositories/token/memory_token.py @@ -0,0 +1,50 @@ +"""In-memory token repository (can be replaced with Redis).""" + +from datetime import datetime, timedelta, UTC + +from baby_monitor.repositories.interfaces import TokenRepositoryInterface + + +class InMemoryTokenRepository(TokenRepositoryInterface): + """ + In-memory token storage. + + This is a simple implementation that stores tokens in memory. + Replace with RedisTokenRepository for production use. + """ + + def __init__(self) -> None: + # token -> (user_id, expiry_time) + self._tokens: dict[str, tuple[int, datetime]] = {} + + def store(self, token: str, user_id: int, ttl: int = 3600) -> None: + """Store a token with TTL (time to live in seconds).""" + expiry = datetime.now(UTC) + timedelta(seconds=ttl) + self._tokens[token] = (user_id, expiry) + + def verify(self, token: str) -> int | None: + """Verify token and return user_id if valid, None otherwise.""" + if token not in self._tokens: + return None + + user_id, expiry = self._tokens[token] + + # Check if token has expired + if datetime.now(UTC) > expiry: + del self._tokens[token] + return None + + return user_id + + def invalidate(self, token: str) -> None: + """Invalidate/delete a token.""" + self._tokens.pop(token, None) + + def cleanup_expired(self) -> None: + """Remove expired tokens.""" + now = datetime.utcnow() + expired_tokens = [ + token for token, (_, expiry) in self._tokens.items() if now > expiry + ] + for token in expired_tokens: + del self._tokens[token] diff --git a/src/baby_monitor/repositories/token/redis_token.py b/src/baby_monitor/repositories/token/redis_token.py new file mode 100644 index 0000000..6e9a364 --- /dev/null +++ b/src/baby_monitor/repositories/token/redis_token.py @@ -0,0 +1,45 @@ +"""Redis token repository (future implementation).""" + +from typing import Any + +from baby_monitor.repositories.interfaces import TokenRepositoryInterface + + +class RedisTokenRepository(TokenRepositoryInterface): + """ + Redis-based token storage. + + Requires redis package: pip install redis + Set REDIS_URI environment variable (e.g., redis://localhost:6379/0) + """ + + def __init__(self, redis_client: Any) -> None: + """ + Initialize with Redis client. + + Args: + redis_client: Redis client instance from redis.from_url() + """ + self.redis = redis_client + + def store(self, token: str, user_id: int, ttl: int = 3600) -> None: + """Store a token with TTL (time to live in seconds).""" + key = f"token:{token}" + self.redis.setex(key, ttl, str(user_id)) + + def verify(self, token: str) -> int | None: + """Verify token and return user_id if valid, None otherwise.""" + key = f"token:{token}" + user_id_str = self.redis.get(key) + if user_id_str: + return int(user_id_str) + return None + + def invalidate(self, token: str) -> None: + """Invalidate/delete a token.""" + key = f"token:{token}" + self.redis.delete(key) + + def cleanup_expired(self) -> None: + """Redis automatically handles expiration, so no cleanup needed.""" + pass # Redis handles TTL automatically diff --git a/src/baby_monitor/repositories/user/postgresql_user.py b/src/baby_monitor/repositories/user/postgresql_user.py new file mode 100644 index 0000000..46491ff --- /dev/null +++ b/src/baby_monitor/repositories/user/postgresql_user.py @@ -0,0 +1,64 @@ +"""PostgreSQL user repository (future implementation).""" + +from sqlalchemy.orm import Session + +from baby_monitor.repositories.interfaces import UserRepositoryInterface +from baby_monitor.models.db.user import User + + +class PostgreSQLUserRepository(UserRepositoryInterface): + """ + PostgreSQL-based user repository. + + This implementation uses the same SQLAlchemy models as SQLite, + just with a different database engine. + + To use this implementation: + 1. Add psycopg2-binary dependency + 2. Update DATABASE_URL in database.py: + DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://user:password@localhost:5432/baby_monitor" + ) + 3. Replace SQLiteUserRepository with this in dependencies + """ + + def __init__(self, db: Session): + self.db = db + + def get_by_username(self, username: str) -> dict | None: + """Get user by username.""" + user = self.db.query(User).filter(User.username == username).first() + if user: + return { + "id": user.id, + "username": user.username, + "hashed_password": user.hashed_password, + "created_at": user.created_at, + } + return None + + def create(self, username: str, hashed_password: str) -> dict: + """Create a new user.""" + user = User(username=username, hashed_password=hashed_password) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return { + "id": user.id, + "username": user.username, + "hashed_password": user.hashed_password, + "created_at": user.created_at, + } + + def get_by_id(self, user_id: int) -> dict | None: + """Get user by ID.""" + user = self.db.query(User).filter(User.id == user_id).first() + if user: + return { + "id": user.id, + "username": user.username, + "hashed_password": user.hashed_password, + "created_at": user.created_at, + } + return None diff --git a/src/baby_monitor/repositories/user/sqlite_user.py b/src/baby_monitor/repositories/user/sqlite_user.py new file mode 100644 index 0000000..ac7318d --- /dev/null +++ b/src/baby_monitor/repositories/user/sqlite_user.py @@ -0,0 +1,50 @@ +"""SQLite implementation of user repository.""" + +from sqlalchemy.orm import Session + +from baby_monitor.repositories.interfaces import UserRepositoryInterface +from baby_monitor.models.db.user import User + + +class SQLiteUserRepository(UserRepositoryInterface): + """SQLite-based user repository.""" + + def __init__(self, db: Session): + self.db = db + + def get_by_username(self, username: str) -> dict | None: + """Get user by username.""" + user = self.db.query(User).filter(User.username == username).first() + if user: + return { + "id": user.id, + "username": user.username, + "hashed_password": user.hashed_password, + "created_at": user.created_at, + } + return None + + def create(self, username: str, hashed_password: str) -> dict: + """Create a new user.""" + user = User(username=username, hashed_password=hashed_password) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return { + "id": user.id, + "username": user.username, + "hashed_password": user.hashed_password, + "created_at": user.created_at, + } + + def get_by_id(self, user_id: int) -> dict | None: + """Get user by ID.""" + user = self.db.query(User).filter(User.id == user_id).first() + if user: + return { + "id": user.id, + "username": user.username, + "hashed_password": user.hashed_password, + "created_at": user.created_at, + } + return None