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
This commit is contained in:
A0 Implementation Engineer
2026-07-09 01:26:45 +02:00
parent 3bfa54b4b3
commit e62ece1c06
29 changed files with 1251 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
"""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")