Proxies GET /customers/{uuid} to Novi AMS server-to-server so members'
browser IPs are no longer in the call path, eliminating false "Access
Denied" for users on hotel/conference WiFi, VPNs, and CDN-filtered nets.
- New router: GET /v3/action/idaa/novi_member/{uuid}
- Business logic in app/methods/idaa_novi_verify_methods.py
- Redis cache (4h TTL, key: idaa:novi_member:{uuid})
- 404 never cached (recently-joined member anti-pattern)
- Email space→+ normalization (Novi quirk)
- Display name: "FirstName L." format with Name field fallback
- Registered in registry.py under /v3/action/idaa tag
- 9 unit tests covering all response paths (200/404/429/503/unreachable,
cache hit, email normalization, display name format)
- Frontend guide (Section 12) and tests/README updated with full spec
and migration table for frontend hand-off
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
6.4 KiB
Python
155 lines
6.4 KiB
Python
import datetime
|
|
import json
|
|
import requests
|
|
from typing import Dict, Optional
|
|
|
|
from app.lib_general import log, logger_reset
|
|
|
|
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
|
|
_CACHE_TTL = datetime.timedelta(hours=4)
|
|
|
|
|
|
# ── Config ────────────────────────────────────────────────────────────────
|
|
|
|
@logger_reset
|
|
def _load_idaa_cfg() -> Optional[Dict]:
|
|
"""Load IDAA site cfg_json. Returns parsed dict or None on failure."""
|
|
from app.methods.site_methods import load_site_obj
|
|
site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True)
|
|
if not site:
|
|
log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM)
|
|
return None
|
|
cfg = site.get('cfg_json')
|
|
if isinstance(cfg, str):
|
|
try:
|
|
cfg = json.loads(cfg)
|
|
except Exception as e:
|
|
log.error("Failed to parse IDAA cfg_json: %s", e)
|
|
return None
|
|
if not isinstance(cfg, dict):
|
|
log.error("IDAA cfg_json is not a dict after parsing.")
|
|
return None
|
|
return cfg
|
|
|
|
|
|
def _cache_key(uuid: str) -> str:
|
|
return f'idaa:novi_member:{uuid}'
|
|
|
|
|
|
# ── Public API ────────────────────────────────────────────────────────────
|
|
|
|
@logger_reset
|
|
def verify_novi_member(uuid: str) -> Dict:
|
|
"""
|
|
Proxy GET /customers/{uuid} to Novi AMS and return normalized member data.
|
|
|
|
Returns a dict with one of:
|
|
{'status': 200, 'verified': True, 'full_name': '...', 'email': '...'}
|
|
{'status': 404, 'reason': '...'}
|
|
{'status': 429, 'reason': '...'}
|
|
{'status': 503, 'reason': '...'}
|
|
|
|
Redis cache key: idaa:novi_member:{uuid}, TTL 4 hours.
|
|
Only 200 (verified) results are cached — 404 is never cached.
|
|
"""
|
|
from app.lib_redis_helpers import redis_client
|
|
|
|
cache_key = _cache_key(uuid)
|
|
|
|
# ── Cache hit ─────────────────────────────────────────────────────────
|
|
cached_raw = redis_client.get(cache_key)
|
|
if cached_raw:
|
|
try:
|
|
cached = json.loads(cached_raw)
|
|
log.info("Novi verify cache hit: %s", uuid)
|
|
return cached
|
|
except Exception:
|
|
pass # corrupt cache entry — fall through to Novi
|
|
|
|
# ── Load credentials ──────────────────────────────────────────────────
|
|
cfg = _load_idaa_cfg()
|
|
if not cfg:
|
|
return {'status': 503, 'reason': 'IDAA site configuration unavailable.'}
|
|
|
|
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
|
api_key = cfg.get('novi_idaa_api_key', '')
|
|
|
|
if not base_url or not api_key:
|
|
log.error("novi_api_root_url or novi_idaa_api_key missing from IDAA cfg_json.")
|
|
return {'status': 503, 'reason': 'Novi credentials not configured.'}
|
|
|
|
headers = {'Authorization': f'Basic {api_key}', 'Accept': 'application/json'}
|
|
|
|
# ── Call Novi ─────────────────────────────────────────────────────────
|
|
try:
|
|
resp = requests.get(f'{base_url}/customers/{uuid}', headers=headers, timeout=10)
|
|
except requests.exceptions.ConnectionError as e:
|
|
log.error("Novi unreachable: %s", e)
|
|
return {'status': 503, 'reason': 'Novi API unreachable.'}
|
|
except requests.exceptions.Timeout:
|
|
log.error("Novi request timed out for UUID %s", uuid)
|
|
return {'status': 503, 'reason': 'Novi API timed out.'}
|
|
except Exception as e:
|
|
log.exception("Unexpected error calling Novi for UUID %s: %s", uuid, e)
|
|
return {'status': 503, 'reason': 'Unexpected error contacting Novi.'}
|
|
|
|
if resp.status_code == 429:
|
|
log.warning("Novi rate limit hit for UUID %s", uuid)
|
|
return {'status': 429, 'reason': 'Novi rate limit exceeded. Try again shortly.'}
|
|
|
|
if resp.status_code >= 500:
|
|
log.error("Novi server error %s for UUID %s", resp.status_code, uuid)
|
|
return {'status': 503, 'reason': f'Novi server error ({resp.status_code}).'}
|
|
|
|
if resp.status_code == 404:
|
|
log.info("Novi returned 404 for UUID %s", uuid)
|
|
return {'status': 404, 'reason': 'Member not found in Novi.'}
|
|
|
|
if resp.status_code != 200:
|
|
log.error("Unexpected Novi status %s for UUID %s: %s", resp.status_code, uuid, resp.text[:200])
|
|
return {'status': 503, 'reason': f'Unexpected Novi response ({resp.status_code}).'}
|
|
|
|
# ── Parse response ────────────────────────────────────────────────────
|
|
try:
|
|
data = resp.json()
|
|
except Exception:
|
|
log.error("Novi returned non-JSON for UUID %s", uuid)
|
|
return {'status': 503, 'reason': 'Novi returned an unparseable response.'}
|
|
|
|
if not isinstance(data, dict):
|
|
log.warning("Novi returned non-dict body for UUID %s", uuid)
|
|
return {'status': 404, 'reason': 'Member not found in Novi (empty response).'}
|
|
|
|
# Empty-member anti-pattern: Novi 200 with no identity data
|
|
email_raw = (data.get('Email') or '').strip()
|
|
if not email_raw:
|
|
log.info("Novi 200 with no Email for UUID %s — empty-member anti-pattern", uuid)
|
|
return {'status': 404, 'reason': 'Member not found in Novi (no identity data).'}
|
|
|
|
email = email_raw.replace(' ', '+')
|
|
|
|
# Build display name: "FirstName LastName[0]." — fall back to Name field
|
|
first = (data.get('FirstName') or '').strip()
|
|
last = (data.get('LastName') or '').strip()
|
|
if first and last:
|
|
full_name = f'{first} {last[0]}.'
|
|
elif first:
|
|
full_name = first
|
|
else:
|
|
full_name = (data.get('Name') or '').strip() or 'Member'
|
|
|
|
result = {
|
|
'status': 200,
|
|
'verified': True,
|
|
'full_name': full_name,
|
|
'email': email,
|
|
}
|
|
|
|
# ── Cache verified result ─────────────────────────────────────────────
|
|
try:
|
|
redis_client.setex(cache_key, _CACHE_TTL, json.dumps(result))
|
|
except Exception as e:
|
|
log.warning("Failed to cache Novi verify result for %s: %s", uuid, e)
|
|
|
|
return result
|