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