Files
hms-licht-ton/backend/app/cache.py
T
A0 Implementation Engineer e62ece1c06 feat: T03 backend core – FastAPI + DB models + Equipment API + Redis cache + health + tests
- FastAPI app with CORS, lifespan handlers
- Pydantic Settings config (DB, Redis, CORS, SMTP, JWT, Rentman)
- SQLAlchemy async engine + session (DeclarativeBase)
- 6 DB models: EquipmentCache, RentalRequest, RentalRequestItem, Contact, AdminUser, SyncLog
- Pydantic schemas: EquipmentItem, EquipmentDetail, PaginatedEquipment, ContactCreate, ContactResponse
- Redis cache helper: set/get/delete_pattern, rate limiting, equipment key builders
- Equipment router: list (search/category/sort/pagination), detail, categories – all cached
- Contact router: POST with Pydantic validation + rate limiting (5/min)
- Health router: GET /api/health with DB + Redis status
- 28 pytest tests (all pass, 90% coverage)
- Dockerfile, requirements.txt, pytest.ini, test_report.md
2026-07-09 01:26:45 +02:00

100 lines
3.1 KiB
Python

"""Redis cache helper for equipment data and rate limiting."""
import json
import logging
from typing import Any
import redis.asyncio as redis
from app.config import settings
logger = logging.getLogger(__name__)
_cache_client: redis.Redis | None = None
def _build_key(*parts: str) -> str:
"""Build a colon-separated Redis key."""
return ":".join(part.replace(":", "_") for part in parts)
async def get_cache() -> redis.Redis:
"""Return the Redis client singleton, creating it on first use."""
global _cache_client
if _cache_client is None:
_cache_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _cache_client
async def set_cache(key: str, value: Any, ttl: int = 3600) -> None:
"""Store a JSON-serialisable value in Redis with a TTL."""
try:
client = await get_cache()
await client.setex(key, ttl, json.dumps(value))
except Exception as exc: # noqa: BLE001
logger.warning("Cache set failed for key=%s: %s", key, exc)
async def get_cached(key: str) -> Any | None:
"""Retrieve and JSON-deserialise a cached value."""
try:
client = await get_cache()
raw = await client.get(key)
if raw is None:
return None
return json.loads(raw)
except Exception as exc: # noqa: BLE001
logger.warning("Cache get failed for key=%s: %s", key, exc)
return None
async def delete_pattern(pattern: str) -> None:
"""Delete all keys matching a glob pattern."""
try:
client = await get_cache()
async for key in client.scan_iter(match=pattern, count=100):
await client.delete(key)
except Exception as exc: # noqa: BLE001
logger.warning("Cache delete_pattern failed for pattern=%s: %s", pattern, exc)
async def check_rate_limit(identifier: str, max_requests: int = 5, window: int = 60) -> bool:
"""Return True if the identifier is within the rate limit."""
try:
client = await get_cache()
key = _build_key("rate", identifier)
count = await client.incr(key)
if count == 1:
await client.expire(key, window)
return count <= max_requests
except Exception as exc: # noqa: BLE001
logger.warning("Rate limit check failed: %s", exc)
return True
async def ping_cache() -> bool:
"""Check if Redis is reachable."""
try:
client = await get_cache()
return bool(await client.ping())
except Exception:
return False
def equipment_list_key(page: int, category: str | None, sort: str | None, search: str | None) -> str:
"""Build the Redis cache key for an equipment list query."""
cat = category or "all"
srt = sort or "default"
srch = search or "all"
return _build_key("equipment", "list", str(page), cat, srt, srch)
def equipment_detail_key(item_id: int) -> str:
"""Build the Redis cache key for an equipment detail."""
return _build_key("equipment", "detail", str(item_id))
def equipment_categories_key() -> str:
"""Build the Redis cache key for the equipment categories list."""
return _build_key("equipment", "categories")