added repositories with dependency injection

This commit is contained in:
Brian Bjarke Jensen
2025-11-06 21:32:56 +01:00
parent c0266acb18
commit 28305eb36f
15 changed files with 546 additions and 0 deletions
+13
View File
@@ -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",
]
@@ -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
@@ -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",
]
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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",
]
@@ -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."""
@@ -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)."""
@@ -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."""
@@ -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]
@@ -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
@@ -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
@@ -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