added repositories with dependency injection
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user