Compare commits
10 Commits
c7c14e8047
...
d7b86cc186
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b86cc186 | ||
|
|
9adf5659bc | ||
|
|
308a7f296f | ||
|
|
950e34cabd | ||
|
|
6b25cf9c6d | ||
|
|
29579fd9f1 | ||
|
|
5f3ba1e03e | ||
|
|
eaa18a1d45 | ||
|
|
ee28a4f26e | ||
|
|
e608696ec8 |
@@ -4,7 +4,7 @@ FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11
|
||||
|
||||
LABEL maintainer="Scott Idem <scott.idem@oneskyit.com>"
|
||||
|
||||
# 1. Install OS dependencies FIRST.
|
||||
# 1. Install OS dependencies FIRST.
|
||||
# These are the slowest to install and change the least.
|
||||
# Doing this before WORKDIR or any COPY ensures maximum caching.
|
||||
RUN apt-get update && \
|
||||
@@ -25,11 +25,12 @@ RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
RUN pip freeze > /tmp/aether_fastapi_requirements_current.txt
|
||||
|
||||
# NOTE: The application source is mounted as a volume in docker-compose.yml
|
||||
# for real-time development. We don't COPY the source here to keep the
|
||||
# for real-time development. We don't COPY the source here to keep the
|
||||
# image generic and the build near-instant when code changes.
|
||||
|
||||
# Docker health check — verifies DB + Redis connectivity via the /health route.
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
# CMD curl -f http://localhost/health || exit 1
|
||||
CMD curl -f http://localhost:5005/health || exit 1
|
||||
|
||||
CMD ["gunicorn", "--conf", "/conf/gunicorn_fastapi_conf.py"]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Configuration for the Aether FastAPI application.
|
||||
# All settings are read directly from environment variables (injected by Docker via .env).
|
||||
# Previously this file was mounted from aether_container_env/conf/aether_api_config.py.
|
||||
from pydantic import BaseSettings, Field
|
||||
from typing import Any, Dict, List
|
||||
|
||||
@@ -103,7 +102,7 @@ class Settings(BaseSettings):
|
||||
}
|
||||
|
||||
class Config:
|
||||
case_sensitive = True
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -43,6 +43,12 @@ def format_db_error(raw_error: str) -> StandardError:
|
||||
recoverable = True
|
||||
elif code in [1054, 1146]: # Unknown column / Table
|
||||
category = "database_schema"
|
||||
elif code == 1364: # Field has no default value — model/schema mismatch
|
||||
category = "database_schema"
|
||||
field_match = re.search(r"Field '([^']+)' doesn't have a default value", message)
|
||||
if field_match:
|
||||
field_name = field_match.group(1)
|
||||
message = f"Schema mismatch: column '{field_name}' is NOT NULL with no default but was not included in the insert. Check the model definition and database schema."
|
||||
else:
|
||||
category = "database"
|
||||
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import json, requests
|
||||
from typing import Dict, List, Optional
|
||||
from app.db_sql import sql_select, sql_update, sql_insert
|
||||
from app.db_sql import sql_select
|
||||
from app.lib_general import log, logging, logger_reset
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Novi-Mailman Bridge
|
||||
# Synchronizes Novi AMS membership data with Mailman 3 mailing lists.
|
||||
# Novi-Mailman Bridge — IDAA
|
||||
#
|
||||
# Credential Storage (data_store table):
|
||||
# code='novi_api_config' → JSON: { "api_key": "...", "base_url": "https://..." }
|
||||
# code='mailman_api_config' → JSON: { "base_url": "http://...:8001", "username": "...", "password": "..." }
|
||||
# Credentials live in site.cfg_json for the IDAA site (id_random='58_gJESdlUh').
|
||||
# Novi keys already present:
|
||||
# novi_api_root_url — e.g. "https://www.idaa.org/api"
|
||||
# novi_idaa_api_key — Base64 API key (Basic auth)
|
||||
#
|
||||
# Sync Logic:
|
||||
# - Active Novi members → subscribed in target Mailman list(s)
|
||||
# - Lapsed/expired members → unsubscribed (or held, depending on policy)
|
||||
# - Driven by webhooks (Novi membership events) + optional full-sync
|
||||
# Keys that must be added to cfg_json before Mailman or webhooks can work:
|
||||
# mailman_base_url — e.g. "http://lists.idaa.org:8001"
|
||||
# mailman_username — Mailman REST admin user (usually "restadmin")
|
||||
# mailman_password — Mailman REST admin password
|
||||
# mailman_list_id — Target list, e.g. "members@idaa.org"
|
||||
# novi_webhook_secret — Shared secret for HMAC-SHA256 webhook validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ── Credential Helpers ────────────────────────────────────────────────────
|
||||
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
|
||||
|
||||
|
||||
# ── Config Helper ─────────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def _load_novi_config() -> Optional[Dict]:
|
||||
"""Load Novi AMS API credentials from data_store (code='novi_api_config')."""
|
||||
rec = sql_select(table_name='data_store', field_name='code', field_value='novi_api_config')
|
||||
if not rec:
|
||||
log.error("Novi API config not found in data_store (code='novi_api_config').")
|
||||
def _load_idaa_cfg() -> Optional[Dict]:
|
||||
"""
|
||||
Load IDAA site cfg_json. Returns the 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
|
||||
try:
|
||||
return json.loads(rec['text'])
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse Novi config: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@logger_reset
|
||||
def _load_mailman_config() -> Optional[Dict]:
|
||||
"""Load Mailman 3 REST API credentials from data_store (code='mailman_api_config')."""
|
||||
rec = sql_select(table_name='data_store', field_name='code', field_value='mailman_api_config')
|
||||
if not rec:
|
||||
log.error("Mailman API config not found in data_store (code='mailman_api_config').")
|
||||
return None
|
||||
try:
|
||||
return json.loads(rec['text'])
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse Mailman config: {e}")
|
||||
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
|
||||
|
||||
|
||||
# ── Novi AMS Methods ──────────────────────────────────────────────────────
|
||||
@@ -52,26 +52,35 @@ def _load_mailman_config() -> Optional[Dict]:
|
||||
@logger_reset
|
||||
def test_novi_connection() -> Dict:
|
||||
"""
|
||||
Verify Novi AMS API credentials are valid.
|
||||
Returns a dict with 'ok' bool and optional error message.
|
||||
Verify Novi AMS API credentials from IDAA site cfg_json.
|
||||
Uses the first group GUID in novi_idaa_group_guid_li as a lightweight auth probe.
|
||||
Returns {'ok': True, 'member_count': N} on success.
|
||||
"""
|
||||
config = _load_novi_config()
|
||||
if not config:
|
||||
return {"ok": False, "error": "Credentials not configured."}
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return {"ok": False, "error": "Could not load IDAA site config."}
|
||||
|
||||
# Novi uses Basic auth with a Base64-encoded API key.
|
||||
# Confirmed from IDAA Jitsi integration: Authorization: Basic {api_key}
|
||||
base_url = config.get('base_url', '').rstrip('/')
|
||||
api_key = config.get('api_key', '')
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
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:
|
||||
return {"ok": False, "error": "novi_api_root_url or novi_idaa_api_key missing from cfg_json."}
|
||||
|
||||
group_guid_li = cfg.get('novi_idaa_group_guid_li') or []
|
||||
if not group_guid_li:
|
||||
return {"ok": False, "error": "novi_idaa_group_guid_li missing from cfg_json."}
|
||||
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/api/v1/members", headers=headers, params={"$top": 1}, timeout=10)
|
||||
# Use first group as a lightweight auth probe (pageSize=1)
|
||||
guid = group_guid_li[0]
|
||||
resp = requests.get(f"{base_url}/groups/{guid}/members",
|
||||
headers=headers, params={"pageSize": 1}, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True}
|
||||
return {"ok": True, "probe_group": guid}
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
except Exception as e:
|
||||
log.exception(f"Novi connection test failed: {e}")
|
||||
log.exception("Novi connection test failed: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@@ -80,139 +89,181 @@ def get_novi_members(status_filter: Optional[str] = None, page_size: int = 500,
|
||||
"""
|
||||
Fetch member records from Novi AMS.
|
||||
|
||||
Args:
|
||||
status_filter: Optional Novi membership status (e.g. 'Active', 'Lapsed').
|
||||
None returns all members.
|
||||
page_size: Records per page (Novi uses OData $top/$skip).
|
||||
offset: Pagination offset ($skip).
|
||||
Novi has no flat member-list endpoint. Members are fetched per group from
|
||||
novi_idaa_group_guid_li, deduped by UniqueID, then each member's full record
|
||||
(including Email) is fetched via GET /customers/{uuid}.
|
||||
|
||||
Returns:
|
||||
List of member dicts, or None on failure.
|
||||
|
||||
TODO: Confirm OData filter field names against your Novi instance schema.
|
||||
status_filter and pagination (page_size/offset) are not supported at the
|
||||
Novi API level for this approach — all group members are returned.
|
||||
"""
|
||||
config = _load_novi_config()
|
||||
if not config:
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = config.get('base_url', '').rstrip('/')
|
||||
api_key = config.get('api_key', '')
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
params = {"pageSize": page_size, "offset": offset}
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
group_guid_li = cfg.get('novi_idaa_group_guid_li') or []
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
|
||||
if status_filter:
|
||||
params["membershipStatus"] = status_filter
|
||||
|
||||
# TODO: Confirm the bulk member list endpoint for this Novi instance.
|
||||
# The IDAA Jitsi code uses /customers/{uuid} for individual lookups and
|
||||
# /groups/{guid}/members for group membership. A bulk member list may be
|
||||
# /members, /customers, or require a group-based approach.
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/members", headers=headers, params=params, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
log.error(f"Novi API error: {resp.status_code} - {resp.text[:200]}")
|
||||
return None
|
||||
data = resp.json()
|
||||
# Novi may return array directly, or wrap in Results/Members key
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
return data.get('Results') or data.get('Members') or data.get('value') or []
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch Novi members: {e}")
|
||||
if not group_guid_li:
|
||||
log.error("novi_idaa_group_guid_li missing from cfg_json.")
|
||||
return None
|
||||
|
||||
# Step 1: collect unique member UUIDs across all configured groups
|
||||
seen_uuids: set = set()
|
||||
uuid_list: List[str] = []
|
||||
for guid in group_guid_li:
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/groups/{guid}/members",
|
||||
headers=headers, params={"pageSize": page_size}, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
log.error("Novi group %s fetch error: %s", guid, resp.status_code)
|
||||
continue
|
||||
for entry in resp.json():
|
||||
uid = entry.get('UniqueID')
|
||||
if uid and uid not in seen_uuids:
|
||||
seen_uuids.add(uid)
|
||||
uuid_list.append(uid)
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi group %s: %s", guid, e)
|
||||
|
||||
log.info("Novi: %d unique members across %d group(s).", len(uuid_list), len(group_guid_li))
|
||||
|
||||
# Step 2: fetch full customer record (including Email) for each UUID
|
||||
members: List[Dict] = []
|
||||
for uid in uuid_list:
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/customers/{uid}", headers=headers, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
members.append(resp.json())
|
||||
else:
|
||||
log.warning("Novi customer %s fetch error: %s", uid, resp.status_code)
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi customer %s: %s", uid, e)
|
||||
|
||||
return members
|
||||
|
||||
|
||||
# ── Mailman 3 Methods ─────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def test_mailman_connection() -> Dict:
|
||||
"""
|
||||
Verify Mailman 3 REST API credentials are valid.
|
||||
Returns a dict with 'ok' bool and optional error message.
|
||||
Verify Mailman 3 REST API credentials from IDAA site cfg_json.
|
||||
Returns {'ok': True, 'version': '...'} on success.
|
||||
"""
|
||||
config = _load_mailman_config()
|
||||
if not config:
|
||||
return {"ok": False, "error": "Credentials not configured."}
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return {"ok": False, "error": "Could not load IDAA site config."}
|
||||
|
||||
base_url = config.get('base_url', '').rstrip('/')
|
||||
auth = (config.get('username', 'restadmin'), config.get('password', ''))
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
username = cfg.get('mailman_username', 'restadmin')
|
||||
password = cfg.get('mailman_password', '')
|
||||
|
||||
if not base_url or not password:
|
||||
return {"ok": False, "error": "mailman_base_url or mailman_password missing from cfg_json."}
|
||||
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/3.1/system/versions", auth=auth, timeout=10)
|
||||
resp = requests.get(f"{base_url}/3.1/system/versions", auth=(username, password), timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return {"ok": True, "version": resp.json().get('mailman_version')}
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
|
||||
except Exception as e:
|
||||
log.exception(f"Mailman connection test failed: {e}")
|
||||
log.exception("Mailman connection test failed: %s", e)
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@logger_reset
|
||||
def get_mailman_lists() -> Optional[List[Dict]]:
|
||||
def get_mailman_list_members(list_id: str, count: int = 100, page: int = 1) -> Optional[List[Dict]]:
|
||||
"""
|
||||
Return all mailing lists from this Mailman 3 instance.
|
||||
Return members of a specific Mailman 3 list.
|
||||
|
||||
Args:
|
||||
list_id: fqdn_listname e.g. 'mm3@idaa.org' or dot-notation 'mm3.idaa.org'
|
||||
count: page size (Mailman default 20, max typically 100)
|
||||
page: 1-based page number
|
||||
"""
|
||||
config = _load_mailman_config()
|
||||
if not config:
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = config.get('base_url', '').rstrip('/')
|
||||
auth = (config.get('username', 'restadmin'), config.get('password', ''))
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{base_url}/3.1/lists/{list_id_dot}/roster/member",
|
||||
auth=auth,
|
||||
params={"count": count, "page": page},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
log.error("Mailman member list fetch failed for %s: %s", list_id, resp.status_code)
|
||||
return None
|
||||
data = resp.json()
|
||||
return data.get('entries', [])
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch members for list %s: %s", list_id, e)
|
||||
return None
|
||||
|
||||
|
||||
@logger_reset
|
||||
def get_mailman_lists() -> Optional[List[Dict]]:
|
||||
"""Return all mailing lists from this Mailman 3 instance."""
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/3.1/lists", auth=auth, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
log.error(f"Mailman list fetch failed: {resp.status_code}")
|
||||
log.error("Mailman list fetch failed: %s", resp.status_code)
|
||||
return None
|
||||
return resp.json().get('entries', [])
|
||||
except Exception as e:
|
||||
log.exception(f"Failed to fetch Mailman lists: {e}")
|
||||
log.exception("Failed to fetch Mailman lists: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@logger_reset
|
||||
def subscribe_member_to_list(list_id: str, email: str, display_name: str = '') -> bool:
|
||||
"""
|
||||
Subscribe an email address to a Mailman 3 list.
|
||||
Uses pre-confirmed subscription (no confirmation email sent).
|
||||
|
||||
Args:
|
||||
list_id: Mailman list ID, e.g. 'members@yourdomain.org'
|
||||
email: Member email address
|
||||
display_name: Optional display name
|
||||
|
||||
Returns:
|
||||
True on success or already subscribed, False on error.
|
||||
Subscribe an email address to a Mailman 3 list (pre-confirmed, no welcome email).
|
||||
Returns True on success or already-subscribed, False on error.
|
||||
"""
|
||||
config = _load_mailman_config()
|
||||
if not config:
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return False
|
||||
|
||||
base_url = config.get('base_url', '').rstrip('/')
|
||||
auth = (config.get('username', 'restadmin'), config.get('password', ''))
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
|
||||
payload = {
|
||||
"list_id": list_id.replace('@', '.'), # Mailman uses dot notation for list IDs
|
||||
"subscriber": email,
|
||||
"display_name": display_name,
|
||||
"pre_verified": True,
|
||||
"pre_confirmed": True,
|
||||
"pre_approved": True,
|
||||
"list_id": list_id.replace('@', '.'),
|
||||
"subscriber": email,
|
||||
"display_name": display_name,
|
||||
"pre_verified": True,
|
||||
"pre_confirmed": True,
|
||||
"pre_approved": True,
|
||||
"send_welcome_message": False,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(f"{base_url}/3.1/members", auth=auth, json=payload, timeout=10)
|
||||
if resp.status_code in (200, 201):
|
||||
log.info(f"Subscribed {email} to {list_id}")
|
||||
log.info("Subscribed %s to %s", email, list_id)
|
||||
return True
|
||||
if resp.status_code == 409:
|
||||
log.debug(f"{email} already subscribed to {list_id} — skipping.")
|
||||
return True # Already a member, treat as success
|
||||
log.error(f"Subscribe failed for {email}: {resp.status_code} - {resp.text[:200]}")
|
||||
log.debug("%s already subscribed to %s — skipping.", email, list_id)
|
||||
return True
|
||||
log.error("Subscribe failed for %s: %s - %s", email, resp.status_code, resp.text[:200])
|
||||
return False
|
||||
except Exception as e:
|
||||
log.exception(f"Error subscribing {email} to {list_id}: {e}")
|
||||
log.exception("Error subscribing %s to %s: %s", email, list_id, e)
|
||||
return False
|
||||
|
||||
|
||||
@@ -220,165 +271,178 @@ def subscribe_member_to_list(list_id: str, email: str, display_name: str = '') -
|
||||
def unsubscribe_member_from_list(list_id: str, email: str) -> bool:
|
||||
"""
|
||||
Unsubscribe an email address from a Mailman 3 list.
|
||||
|
||||
Returns:
|
||||
True on success or not found (already unsubscribed), False on error.
|
||||
Returns True on success or not-found, False on error.
|
||||
"""
|
||||
config = _load_mailman_config()
|
||||
if not config:
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return False
|
||||
|
||||
base_url = config.get('base_url', '').rstrip('/')
|
||||
auth = (config.get('username', 'restadmin'), config.get('password', ''))
|
||||
base_url = cfg.get('mailman_base_url', '').rstrip('/')
|
||||
auth = (cfg.get('mailman_username', 'restadmin'), cfg.get('mailman_password', ''))
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
|
||||
try:
|
||||
# Mailman member ID is {email}_{list_id} (base64 encoded in REST path)
|
||||
resp = requests.delete(
|
||||
f"{base_url}/3.1/lists/{list_id_dot}/member/{email}",
|
||||
auth=auth,
|
||||
timeout=10
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code in (200, 204):
|
||||
log.info(f"Unsubscribed {email} from {list_id}")
|
||||
log.info("Unsubscribed %s from %s", email, list_id)
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
log.debug(f"{email} not found in {list_id} — skipping.")
|
||||
return True # Not a member, treat as success
|
||||
log.error(f"Unsubscribe failed for {email}: {resp.status_code} - {resp.text[:200]}")
|
||||
log.debug("%s not found in %s — skipping.", email, list_id)
|
||||
return True
|
||||
log.error("Unsubscribe failed for %s: %s - %s", email, resp.status_code, resp.text[:200])
|
||||
return False
|
||||
except Exception as e:
|
||||
log.exception(f"Error unsubscribing {email} from {list_id}: {e}")
|
||||
log.exception("Error unsubscribing %s from %s: %s", email, list_id, e)
|
||||
return False
|
||||
|
||||
|
||||
# ── Sync Engine ───────────────────────────────────────────────────────────
|
||||
# ── Mirror Sync Engine ────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def sync_single_member(email: str, display_name: str, list_id: str, is_active: bool) -> str:
|
||||
def mirror_novi_group_to_mailman_list(novi_group_guid: str, mailman_list_id: str) -> Optional[Dict]:
|
||||
"""
|
||||
Sync one member's subscription state for a given Mailman list.
|
||||
Mirror a single Novi group to a Mailman 3 list.
|
||||
|
||||
Args:
|
||||
email: Member email
|
||||
display_name: Member display name
|
||||
list_id: Target Mailman list ID
|
||||
is_active: True = subscribe, False = unsubscribe
|
||||
|
||||
Returns:
|
||||
'subscribed', 'unsubscribed', or 'error'
|
||||
- Fetches all members of `novi_group_guid` from Novi.
|
||||
- For each member, fetches their customer record to get Email, name, and
|
||||
membership flags. Members with `Active=False` or `UnsubscribeFromEmails=True`
|
||||
are excluded from the target set.
|
||||
- Fetches current members of `mailman_list_id`.
|
||||
- Subscribes addresses in Novi but not in Mailman.
|
||||
- Unsubscribes addresses in Mailman but not in Novi (mirror / full reconcile).
|
||||
- Returns a result dict with counts.
|
||||
"""
|
||||
if is_active:
|
||||
ok = subscribe_member_to_list(list_id, email, display_name)
|
||||
return 'subscribed' if ok else 'error'
|
||||
else:
|
||||
ok = unsubscribe_member_from_list(list_id, email)
|
||||
return 'unsubscribed' if ok else 'error'
|
||||
|
||||
|
||||
@logger_reset
|
||||
def sync_novi_to_mailman(list_id: str, active_status: str = 'Active') -> Optional[Dict]:
|
||||
"""
|
||||
Full sync: pull all Novi members and reconcile their Mailman subscription state.
|
||||
|
||||
- Novi members with `active_status` → subscribed to `list_id`
|
||||
- All other statuses → unsubscribed from `list_id`
|
||||
|
||||
Args:
|
||||
list_id: Target Mailman list ID (e.g. 'members@yourdomain.org')
|
||||
active_status: Novi membership status string that counts as active.
|
||||
|
||||
Returns:
|
||||
Result dict with counts, or None on fatal error.
|
||||
|
||||
TODO: Adjust the Novi member field names (Email, FirstName, LastName,
|
||||
MembershipStatus) to match your actual Novi instance schema.
|
||||
"""
|
||||
log.info(f"Starting full Novi → Mailman sync for list: {list_id}")
|
||||
|
||||
members = get_novi_members()
|
||||
if members is None:
|
||||
log.error("Novi member fetch failed — aborting sync.")
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
log.info(f"Fetched {len(members)} members from Novi.")
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
headers = {"Authorization": f"Basic {api_key}", "Accept": "application/json"}
|
||||
|
||||
results = {"total": len(members), "subscribed": 0, "unsubscribed": 0, "error": 0, "skipped": 0}
|
||||
log.info("Mirror sync: Novi group %s → Mailman list %s", novi_group_guid, mailman_list_id)
|
||||
|
||||
for member in members:
|
||||
# Field names confirmed PascalCase from Novi API (verified via IDAA Jitsi integration).
|
||||
# MembershipStatus field name still needs confirmation against the bulk member endpoint.
|
||||
email = (member.get('Email') or '').strip().replace(' ', '+')
|
||||
fname = member.get('FirstName') or ''
|
||||
lname = member.get('LastName') or ''
|
||||
status = member.get('MembershipStatus') or member.get('Status') or ''
|
||||
# ── Step 1: Novi group → UUIDs ────────────────────────────────────────
|
||||
try:
|
||||
resp = requests.get(f"{base_url}/groups/{novi_group_guid}/members",
|
||||
headers=headers, params={"pageSize": 500}, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
log.error("Novi group fetch failed (%s): %s", novi_group_guid, resp.status_code)
|
||||
return None
|
||||
uuid_list = [m['UniqueID'] for m in resp.json() if m.get('UniqueID')]
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi group %s: %s", novi_group_guid, e)
|
||||
return None
|
||||
|
||||
if not email:
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
log.info("Novi group %s has %d member(s).", novi_group_guid, len(uuid_list))
|
||||
|
||||
display_name = f"{fname} {lname}".strip()
|
||||
is_active = (status == active_status)
|
||||
# ── Step 2: UUID → customer record → email ────────────────────────────
|
||||
# email.lower() → display_name
|
||||
novi_members: Dict[str, str] = {}
|
||||
skipped_inactive = 0
|
||||
skipped_unsub = 0
|
||||
skipped_no_email = 0
|
||||
|
||||
outcome = sync_single_member(email, display_name, list_id, is_active)
|
||||
results[outcome] = results.get(outcome, 0) + 1
|
||||
for uid in uuid_list:
|
||||
try:
|
||||
r = requests.get(f"{base_url}/customers/{uid}", headers=headers, timeout=10)
|
||||
if r.status_code != 200:
|
||||
log.warning("Novi customer %s fetch failed: %s", uid, r.status_code)
|
||||
continue
|
||||
c = r.json()
|
||||
if not c.get('Active', False):
|
||||
skipped_inactive += 1
|
||||
continue
|
||||
if c.get('UnsubscribeFromEmails', False):
|
||||
skipped_unsub += 1
|
||||
continue
|
||||
email = (c.get('Email') or '').strip()
|
||||
if not email:
|
||||
skipped_no_email += 1
|
||||
continue
|
||||
display = f"{c.get('FirstName', '')} {c.get('LastName', '')}".strip()
|
||||
novi_members[email.lower()] = display
|
||||
except Exception as e:
|
||||
log.exception("Failed to fetch Novi customer %s: %s", uid, e)
|
||||
|
||||
log.info(f"Novi → Mailman sync complete: {results}")
|
||||
log.info("Novi active/subscribed members with email: %d (skipped: inactive=%d unsub=%d no_email=%d)",
|
||||
len(novi_members), skipped_inactive, skipped_unsub, skipped_no_email)
|
||||
|
||||
# ── Step 3: Current Mailman members ───────────────────────────────────
|
||||
mailman_entries = get_mailman_list_members(mailman_list_id)
|
||||
if mailman_entries is None:
|
||||
log.error("Could not fetch current Mailman members for %s — aborting.", mailman_list_id)
|
||||
return None
|
||||
mailman_emails = {m['email'].lower() for m in mailman_entries}
|
||||
|
||||
# ── Step 4: Diff ──────────────────────────────────────────────────────
|
||||
novi_email_set = set(novi_members.keys())
|
||||
to_subscribe = novi_email_set - mailman_emails
|
||||
to_unsubscribe = mailman_emails - novi_email_set
|
||||
|
||||
log.info("Diff — to subscribe: %d, to unsubscribe: %d", len(to_subscribe), len(to_unsubscribe))
|
||||
|
||||
results = {
|
||||
"novi_group_guid": novi_group_guid,
|
||||
"mailman_list_id": mailman_list_id,
|
||||
"novi_count": len(novi_email_set),
|
||||
"mailman_count_before": len(mailman_emails),
|
||||
"subscribed": 0,
|
||||
"unsubscribed": 0,
|
||||
"errors": 0,
|
||||
"skipped_inactive": skipped_inactive,
|
||||
"skipped_unsub": skipped_unsub,
|
||||
"skipped_no_email": skipped_no_email,
|
||||
}
|
||||
|
||||
# ── Step 5: Apply ─────────────────────────────────────────────────────
|
||||
for email in to_subscribe:
|
||||
ok = subscribe_member_to_list(mailman_list_id, email, novi_members[email])
|
||||
if ok: results['subscribed'] += 1
|
||||
else: results['errors'] += 1
|
||||
|
||||
for email in to_unsubscribe:
|
||||
ok = unsubscribe_member_from_list(mailman_list_id, email)
|
||||
if ok: results['unsubscribed'] += 1
|
||||
else: results['errors'] += 1
|
||||
|
||||
log.info("Mirror sync complete: %s", results)
|
||||
return results
|
||||
|
||||
|
||||
@logger_reset
|
||||
def handle_novi_webhook(payload: Dict) -> Optional[Dict]:
|
||||
def mirror_all_configured_mappings() -> Optional[List[Dict]]:
|
||||
"""
|
||||
Process a Novi membership webhook event and update the appropriate Mailman list.
|
||||
Run mirror_novi_group_to_mailman_list for every entry in
|
||||
cfg_json['novi_mailman_sync'].
|
||||
|
||||
Expected payload shape (Novi webhook format — confirm against Novi docs):
|
||||
{
|
||||
"EventType": "MembershipActivated" | "MembershipLapsed" | "MembershipExpired" | ...,
|
||||
"Member": {
|
||||
"Email": "...",
|
||||
"FirstName": "...",
|
||||
"LastName": "...",
|
||||
"MembershipStatus": "Active" | "Lapsed" | ...
|
||||
}
|
||||
}
|
||||
|
||||
TODO: Confirm Novi webhook payload format and EventType values.
|
||||
TODO: Pull target list_id from data_store config or per-account settings.
|
||||
Expected cfg_json shape:
|
||||
"novi_mailman_sync": [
|
||||
{"novi_group_guid": "...", "mailman_list_id": "members@idaa.org"},
|
||||
...
|
||||
]
|
||||
"""
|
||||
event_type = payload.get('EventType', '')
|
||||
member = payload.get('Member', {})
|
||||
email = (member.get('Email') or '').strip().replace(' ', '+')
|
||||
fname = member.get('FirstName', '')
|
||||
lname = member.get('LastName', '')
|
||||
status = member.get('MembershipStatus') or member.get('Status', '')
|
||||
|
||||
if not email:
|
||||
log.warning(f"Novi webhook received with no email — skipping. Payload: {payload}")
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return None
|
||||
|
||||
# Load target list_id from config
|
||||
# TODO: Support per-account list routing (e.g. multiple orgs, each with their own list)
|
||||
novi_config = _load_novi_config()
|
||||
if not novi_config:
|
||||
return None
|
||||
list_id = novi_config.get('mailman_list_id', '')
|
||||
if not list_id:
|
||||
log.error("'mailman_list_id' not set in novi_api_config — cannot route webhook.")
|
||||
return None
|
||||
sync_map = cfg.get('novi_mailman_sync') or []
|
||||
if not sync_map:
|
||||
log.warning("novi_mailman_sync not configured in IDAA cfg_json.")
|
||||
return []
|
||||
|
||||
ACTIVE_EVENTS = {'MembershipActivated', 'MembershipRenewed', 'MembershipCreated'}
|
||||
INACTIVE_EVENTS = {'MembershipLapsed', 'MembershipExpired', 'MembershipTerminated', 'MembershipCancelled'}
|
||||
results = []
|
||||
for mapping in sync_map:
|
||||
guid = mapping.get('novi_group_guid', '').strip()
|
||||
list_id = mapping.get('mailman_list_id', '').strip()
|
||||
if not guid or not list_id:
|
||||
log.warning("Skipping incomplete novi_mailman_sync entry: %s", mapping)
|
||||
continue
|
||||
result = mirror_novi_group_to_mailman_list(guid, list_id)
|
||||
results.append(result or {"novi_group_guid": guid, "mailman_list_id": list_id, "error": "sync failed"})
|
||||
|
||||
if event_type in ACTIVE_EVENTS:
|
||||
is_active = True
|
||||
elif event_type in INACTIVE_EVENTS:
|
||||
is_active = False
|
||||
else:
|
||||
log.info(f"Novi webhook: unhandled EventType '{event_type}' for {email} — ignoring.")
|
||||
return {"action": "ignored", "reason": f"Unhandled EventType: {event_type}"}
|
||||
|
||||
display_name = f"{fname} {lname}".strip()
|
||||
outcome = sync_single_member(email, display_name, list_id, is_active)
|
||||
log.info(f"Novi webhook processed: {email} → {outcome} (EventType: {event_type})")
|
||||
return {"action": outcome, "email": email, "event_type": event_type}
|
||||
return results
|
||||
|
||||
@@ -46,7 +46,7 @@ async def get_child_obj_li(
|
||||
):
|
||||
"""
|
||||
List Child Objects (One-to-Many).
|
||||
|
||||
|
||||
Retrieves a list of child objects associated with a specific parent.
|
||||
1. Verifies parent existence and user access to the parent.
|
||||
2. Filters children where `{parent_obj_type}_id` matches the parent's ID.
|
||||
@@ -62,7 +62,7 @@ async def get_child_obj_li(
|
||||
and_like_dict_obj = None
|
||||
or_like_dict_obj = None
|
||||
and_in_dict_li_obj = None
|
||||
|
||||
|
||||
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
|
||||
if jp_obj:
|
||||
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
|
||||
@@ -74,7 +74,7 @@ async def get_child_obj_li(
|
||||
|
||||
order_by_li = safe_json_loads(order_by_li)
|
||||
obj_name = child_obj_type
|
||||
|
||||
|
||||
if obj_name not in obj_type_kv_li or parent_obj_type not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid object type(s).")
|
||||
|
||||
@@ -125,10 +125,10 @@ async def get_child_obj_li(
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
|
||||
|
||||
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
|
||||
|
||||
return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict())
|
||||
|
||||
if sql_result:
|
||||
@@ -155,7 +155,7 @@ async def search_child_obj_li(
|
||||
):
|
||||
"""
|
||||
Search Child Objects (POST).
|
||||
|
||||
|
||||
Advanced search endpoint for nested objects.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
@@ -239,7 +239,7 @@ async def post_child_obj(
|
||||
):
|
||||
"""
|
||||
Create Child Object.
|
||||
|
||||
|
||||
1. Verifies Parent existence and access.
|
||||
2. Automatically links the new child to the parent (`{parent_obj_type}_id` = parent_id).
|
||||
3. Performs standard creation logic (validation, injection, sanitization).
|
||||
@@ -295,6 +295,10 @@ async def post_child_obj(
|
||||
|
||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||
|
||||
# Re-inject parent FK after model serialization. Some model root_validators strip
|
||||
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict.
|
||||
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||
|
||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||
new_obj_id = sql_insert_result
|
||||
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=child_obj_type)
|
||||
@@ -324,7 +328,7 @@ async def get_child_obj(
|
||||
):
|
||||
"""
|
||||
Retrieve Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
@@ -373,7 +377,7 @@ async def patch_child_obj(
|
||||
):
|
||||
"""
|
||||
Update Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent before updating.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
|
||||
@@ -435,7 +439,7 @@ async def get_child_obj(
|
||||
):
|
||||
"""
|
||||
Retrieve Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
@@ -484,7 +488,7 @@ async def patch_child_obj(
|
||||
):
|
||||
"""
|
||||
Update Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent before updating.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
|
||||
@@ -545,7 +549,7 @@ async def delete_child_obj(
|
||||
):
|
||||
"""
|
||||
Delete Child Object.
|
||||
|
||||
|
||||
Verifies that the child belongs to the specified parent before deleting.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from fastapi import APIRouter, Body, Depends, Query
|
||||
from typing import Optional
|
||||
import asyncio
|
||||
|
||||
from app.lib_general import log
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from typing import Optional
|
||||
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.e_novi_mailman_methods import (
|
||||
test_novi_connection,
|
||||
test_mailman_connection,
|
||||
get_mailman_lists,
|
||||
get_mailman_list_members,
|
||||
subscribe_member_to_list,
|
||||
unsubscribe_member_from_list,
|
||||
get_novi_members,
|
||||
sync_novi_to_mailman,
|
||||
handle_novi_webhook,
|
||||
mirror_novi_group_to_mailman_list,
|
||||
mirror_all_configured_mappings,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,7 +27,7 @@ async def test_novi(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Verify Novi AMS API credentials stored in data_store."""
|
||||
"""Verify Novi AMS API credentials from IDAA site cfg_json."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = test_novi_connection()
|
||||
if result.get('ok'):
|
||||
@@ -37,7 +40,7 @@ async def test_mailman(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Verify Mailman 3 REST API credentials stored in data_store."""
|
||||
"""Verify Mailman 3 REST API credentials from IDAA site cfg_json."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = test_mailman_connection()
|
||||
if result.get('ok'):
|
||||
@@ -47,6 +50,53 @@ async def test_mailman(
|
||||
|
||||
# ── Inspection / Preview ──────────────────────────────────────────────────
|
||||
|
||||
@router.get('/mailman/lists/{list_id}/members', response_model=Resp_Body_Base)
|
||||
async def list_mailman_list_members(
|
||||
list_id: str,
|
||||
count: int = Query(100, ge=1, le=500),
|
||||
page: int = Query(1, ge=1),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Return members of a specific Mailman 3 list."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_mailman_list_members(list_id=list_id, count=count, page=page)
|
||||
if data is not None:
|
||||
return mk_resp(data={"count": len(data), "members": data})
|
||||
return mk_resp(data=False, status_code=500, status_message=f"Failed to fetch members for list '{list_id}'.")
|
||||
|
||||
|
||||
@router.post('/mailman/lists/{list_id}/subscribe', response_model=Resp_Body_Base)
|
||||
async def mailman_subscribe(
|
||||
list_id: str,
|
||||
email: str = Query(..., description="Email address to subscribe"),
|
||||
display_name: str = Query('', description="Optional display name"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Subscribe an email address to a Mailman 3 list."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
ok = subscribe_member_to_list(list_id=list_id, email=email, display_name=display_name)
|
||||
if ok:
|
||||
return mk_resp(data={"list_id": list_id, "email": email}, status_message="Subscribed successfully.")
|
||||
return mk_resp(data=False, status_code=500, status_message=f"Failed to subscribe {email} to {list_id}.")
|
||||
|
||||
|
||||
@router.delete('/mailman/lists/{list_id}/subscribe', response_model=Resp_Body_Base)
|
||||
async def mailman_unsubscribe(
|
||||
list_id: str,
|
||||
email: str = Query(..., description="Email address to unsubscribe"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Unsubscribe an email address from a Mailman 3 list."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
ok = unsubscribe_member_from_list(list_id=list_id, email=email)
|
||||
if ok:
|
||||
return mk_resp(data={"list_id": list_id, "email": email}, status_message="Unsubscribed successfully.")
|
||||
return mk_resp(data=False, status_code=500, status_message=f"Failed to unsubscribe {email} from {list_id}.")
|
||||
|
||||
|
||||
@router.get('/mailman/lists', response_model=Resp_Body_Base)
|
||||
async def list_mailman_lists(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
@@ -68,7 +118,7 @@ async def list_novi_members(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""Fetch a page of Novi AMS members. Useful for inspecting data before a full sync."""
|
||||
"""Fetch a page of Novi AMS members."""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
data = get_novi_members(status_filter=status_filter, page_size=page_size, offset=offset)
|
||||
if data is not None:
|
||||
@@ -79,38 +129,36 @@ async def list_novi_members(
|
||||
# ── Sync ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post('/sync', response_model=Resp_Body_Base)
|
||||
async def sync_full(
|
||||
list_id: str = Query(..., description="Target Mailman list ID, e.g. 'members@yourdomain.org'"),
|
||||
active_status: str = Query('Active', description="Novi membership status that maps to 'subscribed'"),
|
||||
async def sync_all_mappings(
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Full sync: pull all Novi members and reconcile Mailman subscription state.
|
||||
Active members are subscribed; all others are unsubscribed.
|
||||
This is the manual / scheduled trigger — the webhook handles real-time updates.
|
||||
Run all Novi → Mailman mirror syncs configured in novi_mailman_sync (IDAA cfg_json).
|
||||
This is the cron target — call on a schedule to keep all lists in sync.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = sync_novi_to_mailman(list_id=list_id, active_status=active_status)
|
||||
if result:
|
||||
return mk_resp(data=result, status_message="Novi → Mailman sync complete.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Sync failed.")
|
||||
results = mirror_all_configured_mappings()
|
||||
if results is not None:
|
||||
return mk_resp(data=results, status_message=f"Mirror sync complete. {len(results)} mapping(s) processed.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Mirror sync failed.")
|
||||
|
||||
|
||||
# ── Webhook ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post('/webhook/novi', response_model=Resp_Body_Base, include_in_schema=True)
|
||||
async def novi_membership_webhook(
|
||||
payload: dict = Body(...),
|
||||
@router.post('/sync/group/{novi_group_guid}', response_model=Resp_Body_Base)
|
||||
async def sync_single_group(
|
||||
novi_group_guid: str,
|
||||
mailman_list_id: str = Query(..., description="Target Mailman list, e.g. 'mm3@idaa.org'"),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Receives Novi AMS membership webhook events and immediately updates
|
||||
the corresponding Mailman subscription — no auth required (Novi pushes to this endpoint).
|
||||
|
||||
TODO: Add HMAC signature verification once Novi webhook secret is configured.
|
||||
Mirror a single Novi group to a specific Mailman list.
|
||||
Useful for testing or forcing a refresh of one mapping.
|
||||
"""
|
||||
log.info(f"Novi webhook received: EventType={payload.get('EventType')} Email={payload.get('Member', {}).get('Email')}")
|
||||
result = handle_novi_webhook(payload)
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
result = mirror_novi_group_to_mailman_list(novi_group_guid, mailman_list_id)
|
||||
if result:
|
||||
return mk_resp(data=result)
|
||||
return mk_resp(data=False, status_code=400, status_message="Webhook payload could not be processed.")
|
||||
return mk_resp(data=result, status_message="Mirror sync complete.")
|
||||
return mk_resp(data=False, status_code=500, status_message="Mirror sync failed.")
|
||||
|
||||
|
||||
|
||||
232
app/routers/api_v3_actions_event_exhibit.py
Normal file
232
app/routers/api_v3_actions_event_exhibit.py
Normal file
@@ -0,0 +1,232 @@
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import pandas
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
from app.lib_api_crud_v3 import check_account_access
|
||||
from app.lib_export import create_export_file, return_full_tmp_path
|
||||
from app.lib_general_v3 import AccountContext, get_account_context
|
||||
from app.methods.event_exhibit_tracking_methods import (
|
||||
get_event_exhibit_tracking_rec_list,
|
||||
load_event_exhibit_tracking_obj,
|
||||
)
|
||||
from app.models.response_models import mk_resp
|
||||
|
||||
"""
|
||||
Aether API V3 - Event Exhibit Action Router
|
||||
---------------------------------------------
|
||||
Handles specialized actions for the Event Exhibit module, such as
|
||||
exporting tracking (lead) data for exhibitors.
|
||||
"""
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
_HTML_TAG_RE = re.compile(r'<[^>]+>')
|
||||
|
||||
|
||||
def _strip_html(text: Optional[str]) -> Optional[str]:
|
||||
if not text:
|
||||
return text
|
||||
return _HTML_TAG_RE.sub('', text)
|
||||
|
||||
|
||||
def _flatten_responses(responses_json: Optional[dict]) -> dict:
|
||||
"""
|
||||
Flatten responses_json into key→value pairs for CSV/Excel export.
|
||||
New format: { question_code: { response: <value> } } → value = inner['response']
|
||||
Legacy format: { label: <scalar> } → value = scalar
|
||||
"""
|
||||
if not responses_json:
|
||||
return {}
|
||||
flat = {}
|
||||
for key, value in responses_json.items():
|
||||
if isinstance(value, dict):
|
||||
flat[key] = value.get('response')
|
||||
else:
|
||||
flat[key] = value
|
||||
return flat
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.get('/{exhibit_id}/tracking_export')
|
||||
async def export_exhibit_tracking(
|
||||
exhibit_id: str = Path(..., min_length=11, max_length=22),
|
||||
file_type: str = Query('CSV', regex=r'^(CSV|XLSX)$'),
|
||||
return_file: bool = Query(True),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
V3 Action: Export all tracking (lead capture) records for an exhibit.
|
||||
|
||||
Auth: Requires `leads_api_access == True` on the exhibit OR manager-level account access.
|
||||
Returns a CSV or XLSX file attachment.
|
||||
"""
|
||||
# 1. Resolve random ID → internal integer
|
||||
exhibit_int_id = redis_lookup_id_random(record_id_random=exhibit_id, table_name='event_exhibit')
|
||||
if not exhibit_int_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Exhibit not found.')
|
||||
|
||||
# 2. Load exhibit record for ownership + permission checks
|
||||
exhibit_rec = sql_select(table_name='v_event_exhibit', record_id=exhibit_int_id)
|
||||
if not exhibit_rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Exhibit not found.')
|
||||
|
||||
# 3. Multi-tenant ownership check
|
||||
if not check_account_access(exhibit_rec, account, 'event_exhibit'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access denied: this exhibit belongs to a different account.',
|
||||
)
|
||||
|
||||
# 4. Permission: leads_api_access flag OR manager-level access
|
||||
if not exhibit_rec.get('leads_api_access') and not account.manager:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Access denied: leads API access is not enabled for this exhibit.',
|
||||
)
|
||||
|
||||
# 5. Fetch all tracking records — no hidden/enabled filter, full export
|
||||
tracking_rec_list = get_event_exhibit_tracking_rec_list(
|
||||
event_exhibit_id=exhibit_int_id,
|
||||
hidden='all',
|
||||
enabled='all',
|
||||
limit=1500,
|
||||
)
|
||||
if tracking_rec_list is False:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve tracking records.',
|
||||
)
|
||||
|
||||
# 6. Build export rows
|
||||
data_rows = []
|
||||
response_keys: list = [] # ordered unique custom-question column names
|
||||
|
||||
for rec in (tracking_rec_list or []):
|
||||
tracking_obj = load_event_exhibit_tracking_obj(
|
||||
event_exhibit_tracking_id=rec.get('event_exhibit_tracking_id'),
|
||||
)
|
||||
if not tracking_obj:
|
||||
continue
|
||||
|
||||
# Apply exhibitor-side field overrides (override values replace badge defaults)
|
||||
if tracking_obj.event_badge_full_name_override:
|
||||
tracking_obj.event_badge_full_name = tracking_obj.event_badge_full_name_override
|
||||
if tracking_obj.event_badge_professional_title_override:
|
||||
tracking_obj.event_badge_professional_title = tracking_obj.event_badge_professional_title_override
|
||||
if tracking_obj.event_badge_affiliations_override:
|
||||
tracking_obj.event_badge_affiliations = tracking_obj.event_badge_affiliations_override
|
||||
if tracking_obj.event_badge_email_override:
|
||||
tracking_obj.event_badge_email = tracking_obj.event_badge_email_override
|
||||
if tracking_obj.event_badge_location_override:
|
||||
tracking_obj.event_badge_location = tracking_obj.event_badge_location_override
|
||||
|
||||
# Flatten custom Q&A responses and collect column keys (order-preserving dedup)
|
||||
responses = _flatten_responses(tracking_obj.responses_json)
|
||||
for key in responses:
|
||||
if key not in response_keys:
|
||||
response_keys.append(key)
|
||||
|
||||
row = {
|
||||
'event_exhibit_tracking_id': tracking_obj.event_exhibit_tracking_id,
|
||||
'created_on': tracking_obj.created_on,
|
||||
'updated_on': tracking_obj.updated_on,
|
||||
'event_exhibit_name': tracking_obj.event_exhibit_name,
|
||||
'event_badge_full_name': tracking_obj.event_badge_full_name,
|
||||
'event_badge_email': tracking_obj.event_badge_email,
|
||||
'event_badge_professional_title': tracking_obj.event_badge_professional_title,
|
||||
'event_badge_affiliations': tracking_obj.event_badge_affiliations,
|
||||
'event_badge_location': tracking_obj.event_badge_location,
|
||||
'event_badge_country': tracking_obj.event_badge_country,
|
||||
'external_person_id': tracking_obj.external_person_id,
|
||||
'exhibitor_notes': _strip_html(tracking_obj.exhibitor_notes),
|
||||
'priority': tracking_obj.priority,
|
||||
'enable': tracking_obj.enable,
|
||||
'hide': tracking_obj.hide,
|
||||
**responses,
|
||||
}
|
||||
data_rows.append(row)
|
||||
|
||||
# 7. Determine file format
|
||||
export_type = 'Excel' if file_type == 'XLSX' else 'CSV'
|
||||
content_type = (
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
if file_type == 'XLSX' else 'text/csv'
|
||||
)
|
||||
datetime_str = datetime.datetime.utcnow().strftime('%Y-%m-%d_%H%M')
|
||||
filename = f'leads_export_{datetime_str}'
|
||||
ext = '.xlsx' if export_type == 'Excel' else '.csv'
|
||||
filename_w_ext = filename + ext
|
||||
|
||||
fixed_columns = [
|
||||
'event_exhibit_tracking_id',
|
||||
'created_on',
|
||||
'updated_on',
|
||||
'event_exhibit_name',
|
||||
'event_badge_full_name',
|
||||
'event_badge_email',
|
||||
'event_badge_professional_title',
|
||||
'event_badge_affiliations',
|
||||
'event_badge_location',
|
||||
'event_badge_country',
|
||||
'external_person_id',
|
||||
'exhibitor_notes',
|
||||
'priority',
|
||||
'enable',
|
||||
'hide',
|
||||
]
|
||||
column_name_li = fixed_columns + response_keys
|
||||
|
||||
# 8. Handle empty result — write headers-only file
|
||||
if not data_rows:
|
||||
hosted_tmp_path = settings.FILES_PATH['hosted_tmp_root']
|
||||
subdir = os.path.join(hosted_tmp_path, 'event_exhibit')
|
||||
pathlib.Path(subdir).mkdir(parents=True, exist_ok=True)
|
||||
full_path = os.path.join(subdir, filename_w_ext)
|
||||
df = pandas.DataFrame(columns=fixed_columns)
|
||||
if export_type == 'CSV':
|
||||
df.to_csv(full_path, index=False)
|
||||
else:
|
||||
df.to_excel(full_path, index=False)
|
||||
if return_file:
|
||||
return FileResponse(path=full_path, filename=filename_w_ext, media_type=content_type)
|
||||
return mk_resp(data=[], tmp_file_path=filename_w_ext)
|
||||
|
||||
# 9. Generate the export file
|
||||
tmp_file_path = create_export_file(
|
||||
data_dict_list=data_rows,
|
||||
column_name_li=column_name_li,
|
||||
subdir_path='event_exhibit',
|
||||
filename=filename,
|
||||
rm_id=False,
|
||||
export_type=export_type,
|
||||
)
|
||||
if not tmp_file_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to generate export file.',
|
||||
)
|
||||
|
||||
if return_file:
|
||||
full_path = return_full_tmp_path(full_tmp_path=tmp_file_path)
|
||||
if not full_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Export file not found after creation.',
|
||||
)
|
||||
return FileResponse(path=full_path, filename=filename_w_ext, media_type=content_type)
|
||||
|
||||
return mk_resp(data=data_rows, tmp_file_path=tmp_file_path)
|
||||
@@ -7,7 +7,7 @@ from app.routers import (
|
||||
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
||||
event_location, event_person,
|
||||
event_presentation, event_presenter, event_session,
|
||||
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, lookup, lookup_v3,
|
||||
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, lookup, lookup_v3,
|
||||
organization, page, person,
|
||||
person_user, qr, site, site_domain, user,
|
||||
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
||||
@@ -50,6 +50,7 @@ def setup_routers(app: FastAPI):
|
||||
app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
|
||||
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
|
||||
app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
|
||||
|
||||
@@ -154,8 +154,77 @@ Frontend guidance:
|
||||
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead.
|
||||
- These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.
|
||||
|
||||
---
|
||||
|
||||
## 5. Troubleshooting 403 Forbidden
|
||||
## 7. Event Exhibit Tracking Export (Leads Export)
|
||||
|
||||
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/v3/action/event_exhibit/{exhibit_id}/tracking_export`
|
||||
- **Auth:** Standard V3 headers (`x-aether-api-key` + `x-account-id` or `?jwt=`)
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `file_type` | `CSV` \| `XLSX` | `CSV` | Output format. |
|
||||
| `return_file` | bool | `true` | `true` → file download response. `false` → JSON body with row data. |
|
||||
|
||||
### Response
|
||||
|
||||
- `Content-Type: text/csv` (CSV) or `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX)
|
||||
- `Content-Disposition: attachment; filename="leads_export_<timestamp>.csv"`
|
||||
- If there are no tracking records, a valid file with headers only is returned (not a 404).
|
||||
|
||||
### Columns Returned
|
||||
|
||||
Fixed columns (always present), followed by any custom question columns flattened from `responses_json`:
|
||||
|
||||
`event_exhibit_tracking_id`, `created_on`, `updated_on`, `event_exhibit_name`, `event_badge_full_name`, `event_badge_email`, `event_badge_professional_title`, `event_badge_affiliations`, `event_badge_location`, `event_badge_country`, `external_person_id`, `exhibitor_notes`, `priority`, `enable`, `hide`, `[custom question codes…]`
|
||||
|
||||
> **Note:** `exhibitor_notes` has HTML tags stripped automatically for clean CSV output.
|
||||
|
||||
### Permission Requirement — `leads_api_access`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This endpoint enforces a **per-exhibit permission flag**. The `event_exhibit` record **must** have `leads_api_access = true` set in the database, OR the caller must have manager-level account access (JWT with `manager: true`).
|
||||
>
|
||||
> If `leads_api_access` is `false` or `null` on the exhibit, the API returns:
|
||||
> ```json
|
||||
> { "detail": "Access denied: leads API access is not enabled for this exhibit." }
|
||||
> ```
|
||||
> **Fix:** Enable the flag on the exhibit record via `PATCH /v3/crud/event_exhibit/{id}` with `{ "leads_api_access": true }`, or set it directly in the database/admin panel.
|
||||
|
||||
#### Dual purpose of `leads_api_access`
|
||||
|
||||
This flag serves two related but distinct roles:
|
||||
|
||||
1. **3rd-party API access (original intent):** Controls whether external systems (exhibitor apps, badge-scanning devices, etc.) are permitted to push or pull lead data for this exhibit via the API.
|
||||
2. **UI export gate (new):** The frontend should read `leads_api_access` from the exhibit record and use it to show or hide the export/download button. Only render the button when the flag is `true` — this prevents users from triggering a request that will always 403.
|
||||
|
||||
The recommended pattern is to fetch the exhibit record first and gate the UI on this field before the user ever sees the export option. The API enforces the same check server-side as a safety net.
|
||||
|
||||
### Example Request
|
||||
|
||||
```ts
|
||||
const resp = await fetch(
|
||||
`https://dev-api.oneskyit.com/v3/action/event_exhibit/${exhibitId}/tracking_export?file_type=CSV&return_file=true`,
|
||||
{
|
||||
headers: {
|
||||
'x-aether-api-key': API_KEY,
|
||||
'x-account-id': accountId,
|
||||
},
|
||||
}
|
||||
);
|
||||
// resp is a file blob — use URL.createObjectURL() or trigger a download
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting 403 Forbidden
|
||||
|
||||
If you receive a 403 on a valid ID:
|
||||
1. Verify `x-aether-api-key` is correct.
|
||||
|
||||
@@ -27,10 +27,7 @@ wss://[api_domain]/v3/ws/group/{group_id}/client/{client_id}
|
||||
|
||||
**`group_id`** — identifies the shared channel (e.g., an event ID, a room name, or a Vision ID random string). All clients using the same `group_id` receive group-targeted messages together.
|
||||
|
||||
**`client_id`** — uniquely identifies this specific connection. Common choices:
|
||||
- `Date.now()` timestamp (e.g. `1773266158823`) — simple and collision-resistant for short-lived sessions
|
||||
- A UUID (`crypto.randomUUID()`)
|
||||
- A Vision ID random string if you need the client to be addressable by a known database identity
|
||||
**`client_id`** — uniquely identifies this specific connection. The backend accepts any unique string (UUID, timestamp, Vision ID — no format validation). The **recommended pattern** is a UUID v4 generated once and persisted in `localStorage` so the same identity is reused across page reloads and sessions on that browser.
|
||||
|
||||
> Use `ws://` for local development and `wss://` in production (any HTTPS site). The Nginx config must include the Upgrade block — see Section 6.
|
||||
|
||||
@@ -50,8 +47,13 @@ wss://dev-api.oneskyit.com/v3/ws/group/{group_id}/client/{client_id}?api_key=<ke
|
||||
|
||||
### C. Connection Example (TypeScript)
|
||||
```ts
|
||||
// client_id: generated once, persisted in localStorage for stable identity across reloads
|
||||
if (!localStorage.getItem('controller_client_id')) {
|
||||
localStorage.setItem('controller_client_id', crypto.randomUUID());
|
||||
}
|
||||
const client_id = localStorage.getItem('controller_client_id')!; // UUID v4, e.g. "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
const group_id = "event_abc123"; // Any unique string identifying the shared channel
|
||||
const client_id = String(Date.now()); // Any unique string identifying this client connection
|
||||
const api_key = import.meta.env.VITE_API_KEY;
|
||||
const jwt = getSessionToken(); // your JWT helper
|
||||
|
||||
|
||||
@@ -32,28 +32,24 @@
|
||||
- **Status:** **ENFORCED**.
|
||||
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.
|
||||
|
||||
## 🔑 Credentials / Access Maintenance
|
||||
- [ ] **Bitbucket API Token Migration:** Bitbucket is deprecating app passwords — all existing ones become inactive **2026-06-09**. Switch `git` remote auth to an API token before that date. Ref: https://support.atlassian.com/bitbucket-cloud/docs/api-tokens/
|
||||
|
||||
## 🚧 Strategic Goals (V3.5+)
|
||||
- [ ] **Pydantic V2 / SQLAlchemy 2.0:** Major framework upgrade for performance and type safety.
|
||||
- SQLAlchemy 2.0 is likely the easier migration (additive, legacy mode available).
|
||||
- Pydantic v2 touches every model definition — do this second.
|
||||
- Current pins: `pydantic==1.*`, `SQLAlchemy==1.4.52` — intentional, do not remove until migration is done.
|
||||
- [~] **Novi-Mailman Bridge:** Synchronization between Novi AMS and Mailman 3.
|
||||
- [x] **Novi-Mailman Bridge:** Cron-based mirror sync between Novi AMS and Mailman 3 — **POC complete 2026-03-17**.
|
||||
- Files: `app/methods/e_novi_mailman_methods.py`, `app/routers/api_v3_actions_e_novi_mailman.py`
|
||||
- Registered at `/v3/action/e_novi_mailman/`
|
||||
- **Confirmed from IDAA Jitsi code:**
|
||||
- Auth: `Authorization: Basic {api_key}` (Base64-encoded key stored in `data_store`)
|
||||
- Novi member fields are PascalCase: `Email`, `FirstName`, `LastName`, `Name`
|
||||
- Individual member lookup: `GET /customers/{uuid}`
|
||||
- Group member list: `GET /groups/{guid}/members?pageSize=200` (returns `Results` or `Members` key)
|
||||
- Emails may contain spaces instead of `+` — sanitize with `.replace(' ', '+')`
|
||||
- **Still needs confirmation:**
|
||||
- Bulk member list endpoint (likely `/members` or `/customers`) — hit `/novi/members` route after creds are set to inspect
|
||||
- `MembershipStatus` field name in bulk response (may be `Status`)
|
||||
- Webhook `EventType` values and payload shape (check Novi webhook docs)
|
||||
- **data_store setup required (two records):**
|
||||
- `novi_api_config` → `{"api_key": "<base64-key>", "base_url": "https://www.idaa.org/api", "mailman_list_id": "members@yourdomain.org"}`
|
||||
- `mailman_api_config` → `{"base_url": "http://<host>:8001", "username": "restadmin", "password": "<password>"}`
|
||||
- **Outstanding TODO in code:** Webhook HMAC signature verification once Novi webhook secret is known.
|
||||
- **Confirmed Novi API shape:** No flat member list. Fetch via `/groups/{guid}/members` → UUIDs, then `/customers/{uuid}` for full record. Fields: `Email`, `FirstName`, `LastName`, `Active` (bool), `UnsubscribeFromEmails` (bool). Emails may contain spaces instead of `+` — sanitized with `.replace(' ', '+')`.
|
||||
- **Credentials:** All in IDAA site `cfg_json` (`id_random='58_gJESdlUh'`, site id=17). Keys: `novi_api_root_url`, `novi_idaa_api_key`, `mailman_base_url`, `mailman_username`, `mailman_password`, `novi_mailman_sync` (array).
|
||||
- **Mailman 3 REST API:** `https://lists.idaa.org/mailman-api` (Nginx proxy → `127.0.0.1:8008` → Docker container). Roster: `/3.1/lists/{list_id_dot}/roster/member`.
|
||||
- **Sync logic:** Full mirror — subscribe Novi-only addresses, unsubscribe Mailman-only addresses. Respects `Active=false` and `UnsubscribeFromEmails=true`.
|
||||
- **Cron target:** `POST /v3/action/e_novi_mailman/sync` — runs all `novi_mailman_sync` mappings.
|
||||
- **Webhook approach abandoned** — cron is simpler; Novi webhook payload format is unknown and Novi hasn't been configured to send webhooks.
|
||||
- **Remaining:** Set production group→list mappings in `cfg_json`, configure cron schedule, rotate Mailman `restadmin` password.
|
||||
- [ ] **Lookup System Batch 2:** Migration of `post_topic`, `user_status`, `file_purpose`.
|
||||
- [ ] **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval.
|
||||
|
||||
|
||||
@@ -25,9 +25,12 @@ These consolidated scripts are the primary verification tool for the V3 API.
|
||||
| `test_e2e_v3_event_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across event models. |
|
||||
| `test_e2e_v3_cms_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across CMS (post/comment) models. |
|
||||
| `test_e2e_v3_core_vision_parity.py`| **Vision ID**: Verifies string-ID and polymorphic resolution across core models (Account, Person, Address, Contact, DataStore). |
|
||||
| `test_e2e_v3_demo_parity.py` | **Demo Parity**: Comprehensive check for Badge, Exhibit, Tracking, and nested Journal Entries. |
|
||||
| `test_e2e_v3_demo_parity.py` | **Demo Parity + Nested Create Regression**: Vision ID check for Badge, Exhibit, Tracking; nested create lifecycle (POST+DELETE) for `journal/journal_entry` and `event/event_session`; alias resolution. **Run after any model or nested-router change.** |
|
||||
| `test_e2e_v3_action_event_file.py` | **Event Actions**: Specialized atomic upload and linking for event files. |
|
||||
| `test_e2e_v3_action_zoom.py` | **Zoom Integration**: Verifies OAuth and ticket sync logic for Zoom Events. |
|
||||
| `test_e2e_v3_action_novi_mailman.py` | **Novi-Mailman Bridge — Connections**: Verifies Novi AMS and Mailman 3 API credentials are valid (IDAA). Run first before the lists test. |
|
||||
| `test_e2e_v3_action_novi_mailman_lists.py` | **Novi-Mailman Bridge — List Operations**: Full member lifecycle — read roster, subscribe, verify, unsubscribe — against `mm3@idaa.org`, `mm3@dgrzone.com`, `mm3@oneskyit.com`. |
|
||||
| `test_e2e_v3_action_event_exhibit_tracking_export.py` | **Exhibit Leads Export**: Auth/permission guards, CSV column structure, XLSX bytes, and `return_file` mode for the V3 tracking export action. |
|
||||
| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. |
|
||||
| `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. |
|
||||
| `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. |
|
||||
@@ -41,6 +44,23 @@ These consolidated scripts are the primary verification tool for the V3 API.
|
||||
|
||||
---
|
||||
|
||||
## 🚦 When to Run Tests
|
||||
|
||||
Tests exist to be used — run the relevant suite whenever you touch backend code, not just when something breaks.
|
||||
|
||||
| Change type | Required suites |
|
||||
| :--- | :--- |
|
||||
| Model `root_validator` / ID Vision changes | `test_e2e_v3_demo_parity.py`, `test_e2e_v3_event_vision_parity.py`, `test_e2e_v3_core_vision_parity.py` |
|
||||
| Nested router (`api_crud_v3_nested.py`) changes | `test_e2e_v3_demo_parity.py` |
|
||||
| Search / filter changes | `test_e2e_v3_search_engine.py` |
|
||||
| Auth / account context changes | `test_e2e_v3_security_audit.py`, `test_e2e_v3_auth_security.py` |
|
||||
| File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` |
|
||||
| Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` |
|
||||
| Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.py` |
|
||||
| Any backend change before frontend hand-off | All of the above |
|
||||
|
||||
---
|
||||
|
||||
## 🧹 Maintenance Policy
|
||||
|
||||
1. **Standardization**: All E2E tests should use the standard Agent API Key (`PMM4n50teUCaOMMTN8qOJA`) and provide clean `[✅ PASS]` or `[❌ FAIL]` output.
|
||||
@@ -67,4 +87,61 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P
|
||||
```
|
||||
|
||||
### Path Requirements
|
||||
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.
|
||||
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Development / Testing / Demo environment information
|
||||
* Use snake_case (or Snake_Case or Snake_case or test_NASA_example or test_API_key)
|
||||
* Aether test/demo base URL: 'http://demo.localhost:5173'
|
||||
* Aether development API: 'https://dev-api.oneskyit.com'
|
||||
|
||||
These are IDs for records that we can use for testing. Please do not delete them. They are also used for demo purposes with clients.
|
||||
|
||||
### Core Modules
|
||||
* Aether test/demo Account: '_XY7DXtc9MY' (1) "One Sky IT Demo"
|
||||
* Aether test/demo Site: '92vkYC4fVEl' (12) "One Sky IT Demo"
|
||||
* Aether test/demo Site Domain: '_6jcTbnJk-o' (12) "demo.localhost:5173"
|
||||
* Aether test/demo Site Domain: 'heXRgHOs4ns' (30) "sk-demo.oneskyit.com"
|
||||
* Aether test/demo Site Domain: 'DASm8fP92yw' (69) "dev-demo.oneskyit.com"
|
||||
* Aether test/demo Site Domain: '2i_0Za6yRPo' (2) "demo.oneskyit.com"
|
||||
* Aether test/demo Person: 'QWODAPCNLQU' (49) "Osiris Idem"
|
||||
* Aether test/demo Person: 'HMQRNPIXQMK' (48) "Cleo Idem"
|
||||
|
||||
### Events Modules
|
||||
* Aether test/demo Event: 'pjrcghqwert' (1) "Demo One Sky IT Conference"
|
||||
* Aether test/demo Event Session: 'DOW3h7v6H42' (703) "How To Do Things"
|
||||
* Aether test/demo Event Session (Digital Posters): "K8cxUIEWyQk" "The Beginning of Digital Posters!"
|
||||
* Aether test/demo Event Session (Digital Posters): "1Un1xI1Rgk8" "Poster Session 99: All about posters!"
|
||||
* Aether test/demo Event Presentation: '7U2eXSjR6H4' (1670) "Build a House"
|
||||
* Aether test/demo Event Presenter: 'gT-hxnifb-0' (2202) "Bob The Builder"
|
||||
* Aether test/demo Event File: 'OOsHXtng5mr' (2985) "1 Quick Test for macOS.mp4"
|
||||
* Aether test/demo Event Badge: 'UIJT-73-63-61' (37163) "Scott Idem"
|
||||
* Aether test/demo Event Person: 'ffkKxiHpOEC' (16603) "Scott Idem"
|
||||
* Aether test/demo Event Badge Template: 'jgfixEpYp1B' (18) "Dev Demo 202x"
|
||||
* Aether test/demo Event Badge Template: 'rzmUgsk7mkq' (19) "Dev Demo 202x Workshops"
|
||||
* Aether test/demo Event Location: 'VXXY-98-46-14' (26) "Ballroom 1"
|
||||
* Aether test/demo Event Location: 'FGRN-67-92-45' (298) "Ballroom AB"
|
||||
* Aether test/demo Event Location: 'PQKB-15-39-81' (78) "Poster Display Station A"
|
||||
|
||||
### Journals Module
|
||||
* Aether test/demo Journal: 'BVYE-94-46-29' (42) "Testing Things"
|
||||
* Aether test/demo Journal Entry: 'xRx-Y4-h3-fU' (233) "Another Journal Entry in the Test Journal"
|
||||
|
||||
### Archives Module (IDAA Archives)
|
||||
* Aether test/demo Archive: 'nAA2bHLv8RK' (1) "One Sky Test Archive"
|
||||
* Aether test/demo Archive Content: 'UjKzrk-GKu5' (1) "Hosted File Test"
|
||||
|
||||
### Posts Module (IDAA Bulletin Board)
|
||||
* Aether test/demo Post:
|
||||
* Aether test/demo Post:
|
||||
|
||||
### Events Module (IDAA Recovery Meetings)
|
||||
* Aether test/demo Event: '1Pkd025vvxU' (36) "IDAA Recovery Meeting Test"
|
||||
* Aether test/demo Event: 'gIZgAjISkf8' (43) "IDAA Recovery Meeting Test"
|
||||
|
||||
### IDAA and Novi AMS
|
||||
Scott Idem (test 1)
|
||||
* Novi (customer) UUID: "1dadf11c-b74b-4582-8a0a-7ec738a033dc"
|
||||
* Novi Email: "stidem+test1@gmail.com"
|
||||
51
tests/archive/test_novi_webhook_ARCHIVED.py
Normal file
51
tests/archive/test_novi_webhook_ARCHIVED.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
ARCHIVED 2026-03-17
|
||||
The webhook endpoint (/webhook/novi) was removed — sync is cron-based.
|
||||
If Novi webhook support is added in future, restore the endpoint in
|
||||
api_v3_actions_e_novi_mailman.py and move this file back to tests/e2e/.
|
||||
The webhook secret is stored as novi_webhook_secret in IDAA site cfg_json.
|
||||
|
||||
Original: Integration test — send a signed Novi webhook payload to the API.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get('AE_API_BASE', 'https://dev-api.oneskyit.com')
|
||||
ENDPOINT = f"{BASE_URL}/v3/action/e_novi_mailman/webhook/novi"
|
||||
SECRET = os.environ.get('NOVI_WEBHOOK_SECRET', 'test-secret')
|
||||
|
||||
payload = {
|
||||
"EventType": "MembershipActivated",
|
||||
"Member": {
|
||||
"Email": "test+webhook@example.com",
|
||||
"FirstName": "Test",
|
||||
"LastName": "Webhook",
|
||||
"MembershipStatus": "Active"
|
||||
}
|
||||
}
|
||||
|
||||
body = json.dumps(payload).encode('utf-8')
|
||||
signature = hmac.new(SECRET.encode('utf-8'), body, hashlib.sha256).hexdigest()
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Novi-Signature': signature
|
||||
}
|
||||
|
||||
print('Posting to', ENDPOINT)
|
||||
resp = requests.post(ENDPOINT, headers=headers, data=body, timeout=30)
|
||||
print('Status:', resp.status_code)
|
||||
try:
|
||||
print('JSON:', resp.json())
|
||||
except Exception:
|
||||
print('Body:', resp.text)
|
||||
|
||||
if resp.status_code == 200:
|
||||
print('\u2705 PASS: webhook accepted')
|
||||
else:
|
||||
print('\u274C FAIL: webhook rejected')
|
||||
raise SystemExit(1)
|
||||
231
tests/e2e/test_e2e_v3_action_event_exhibit_tracking_export.py
Normal file
231
tests/e2e/test_e2e_v3_action_event_exhibit_tracking_export.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
E2E Test: V3 Action — Event Exhibit Tracking Export
|
||||
Route: GET /v3/action/event_exhibit/{exhibit_id}/tracking_export
|
||||
|
||||
Tests:
|
||||
1. Auth guard — rejected when API key is missing
|
||||
2. Auth guard — rejected when account context is missing
|
||||
3. Permission guard — rejected when leads_api_access is not enabled (without manager bypass)
|
||||
4. Success (bypass) — CSV file returned with correct headers
|
||||
5. Success (bypass) — XLSX file returned
|
||||
6. Column structure — expected fixed columns present in CSV
|
||||
7. 404 for a bogus exhibit ID
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/action/event_exhibit"
|
||||
API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
||||
|
||||
# This exhibit is the stable demo record (from tests/README.md "event_exhibit")
|
||||
# xK_9yEj1bQY is verified to exist in the demo environment (TARGETS list in demo_parity).
|
||||
EXHIBIT_ID = "xK_9yEj1bQY"
|
||||
|
||||
BYPASS_HEADERS = {
|
||||
"x-aether-api-key": API_KEY,
|
||||
"x-no-account-id": "bypass",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
def print_result(label: str, success: bool, message: str = ""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
line = f" {status} | {label}"
|
||||
if message:
|
||||
line += f" — {message}"
|
||||
print(line)
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_missing_api_key():
|
||||
"""No auth at all → 403."""
|
||||
resp = requests.get(f"{BASE_URL}/{EXHIBIT_ID}/tracking_export")
|
||||
ok = resp.status_code == 403
|
||||
print_result("Missing API key → 403", ok, f"got {resp.status_code}")
|
||||
return ok
|
||||
|
||||
|
||||
def test_missing_account_context():
|
||||
"""API key present but no account context → 403."""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/{EXHIBIT_ID}/tracking_export",
|
||||
headers={"x-aether-api-key": API_KEY},
|
||||
)
|
||||
ok = resp.status_code == 403
|
||||
print_result("Missing account context → 403", ok, f"got {resp.status_code}")
|
||||
return ok
|
||||
|
||||
|
||||
def test_leads_api_access_gate():
|
||||
"""
|
||||
A real (non-bypass) account that does NOT own this exhibit should be blocked.
|
||||
Demo account '_XY7DXtc9MY' is used here; if it happens to own the exhibit and
|
||||
have leads_api_access, this test will get a 200 — that's still acceptable data
|
||||
(the endpoint is working). The main value is confirming no 500 error.
|
||||
"""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/{EXHIBIT_ID}/tracking_export",
|
||||
headers={
|
||||
"x-aether-api-key": API_KEY,
|
||||
"x-account-id": "_XY7DXtc9MY", # Demo account
|
||||
},
|
||||
)
|
||||
# Accept 403 (correct gate) or 200 (demo account owns exhibit with access enabled)
|
||||
ok = resp.status_code in (200, 403)
|
||||
note = "blocked (correct)" if resp.status_code == 403 else "allowed (demo account owns exhibit)"
|
||||
print_result("leads_api_access gate", ok, f"got {resp.status_code} — {note}")
|
||||
return ok
|
||||
|
||||
|
||||
def test_bogus_exhibit_id():
|
||||
"""Non-existent exhibit ID → 404."""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/AAAAAAAAAAA/tracking_export",
|
||||
headers=BYPASS_HEADERS,
|
||||
)
|
||||
ok = resp.status_code == 404
|
||||
print_result("Bogus exhibit ID → 404", ok, f"got {resp.status_code}")
|
||||
return ok
|
||||
|
||||
|
||||
def test_csv_export_bypass():
|
||||
"""Bypass auth → CSV file returned with correct content-type and columns."""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/{EXHIBIT_ID}/tracking_export",
|
||||
params={"file_type": "CSV", "return_file": "true"},
|
||||
headers=BYPASS_HEADERS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print_result("CSV export (bypass)", False, f"got {resp.status_code}: {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
# Content-Type check
|
||||
ct = resp.headers.get("content-type", "")
|
||||
ct_ok = "text/csv" in ct
|
||||
print_result("CSV content-type header", ct_ok, ct)
|
||||
|
||||
# Content-Disposition check
|
||||
cd = resp.headers.get("content-disposition", "")
|
||||
cd_ok = "attachment" in cd and ".csv" in cd
|
||||
print_result("CSV content-disposition header", cd_ok, cd)
|
||||
|
||||
# Parse and check columns
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(resp.text))
|
||||
fieldnames = reader.fieldnames or []
|
||||
expected_fixed = [
|
||||
"event_exhibit_tracking_id",
|
||||
"created_on",
|
||||
"updated_on",
|
||||
"event_exhibit_name",
|
||||
"event_badge_full_name",
|
||||
"event_badge_email",
|
||||
"event_badge_professional_title",
|
||||
"event_badge_affiliations",
|
||||
"event_badge_location",
|
||||
"event_badge_country",
|
||||
"external_person_id",
|
||||
"exhibitor_notes",
|
||||
"priority",
|
||||
"enable",
|
||||
"hide",
|
||||
]
|
||||
missing = [c for c in expected_fixed if c not in fieldnames]
|
||||
cols_ok = len(missing) == 0
|
||||
print_result(
|
||||
"CSV expected columns present",
|
||||
cols_ok,
|
||||
f"missing: {missing}" if missing else f"{len(fieldnames)} columns total",
|
||||
)
|
||||
|
||||
rows = list(reader)
|
||||
print_result("CSV parseable", True, f"{len(rows)} data rows")
|
||||
except Exception as e:
|
||||
print_result("CSV parse", False, str(e))
|
||||
return False
|
||||
|
||||
return ct_ok and cd_ok and cols_ok
|
||||
|
||||
|
||||
def test_xlsx_export_bypass():
|
||||
"""Bypass auth → XLSX file returned with correct content-type."""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/{EXHIBIT_ID}/tracking_export",
|
||||
params={"file_type": "XLSX", "return_file": "true"},
|
||||
headers=BYPASS_HEADERS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print_result("XLSX export (bypass)", False, f"got {resp.status_code}: {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
ct = resp.headers.get("content-type", "")
|
||||
ct_ok = "spreadsheetml" in ct or "openxmlformats" in ct or "octet-stream" in ct
|
||||
print_result("XLSX content-type header", ct_ok, ct)
|
||||
|
||||
cd = resp.headers.get("content-disposition", "")
|
||||
cd_ok = "attachment" in cd and ".xlsx" in cd
|
||||
print_result("XLSX content-disposition header", cd_ok, cd)
|
||||
|
||||
# Basic magic-bytes check (XLSX starts with PK zip header)
|
||||
magic_ok = resp.content[:2] == b"PK"
|
||||
print_result("XLSX magic bytes (PK zip)", magic_ok, "")
|
||||
|
||||
return ct_ok and cd_ok and magic_ok
|
||||
|
||||
|
||||
def test_return_file_false():
|
||||
"""return_file=false → JSON response body instead of a file download."""
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/{EXHIBIT_ID}/tracking_export",
|
||||
params={"file_type": "CSV", "return_file": "false"},
|
||||
headers=BYPASS_HEADERS,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
print_result("return_file=false", False, f"got {resp.status_code}: {resp.text[:200]}")
|
||||
return False
|
||||
|
||||
ct = resp.headers.get("content-type", "")
|
||||
json_ok = "json" in ct
|
||||
print_result("return_file=false → JSON response", json_ok, ct)
|
||||
return json_ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("E2E: V3 Action — Event Exhibit Tracking Export")
|
||||
print("=" * 60)
|
||||
t_start = time.time()
|
||||
|
||||
results = [
|
||||
test_missing_api_key(),
|
||||
test_missing_account_context(),
|
||||
test_leads_api_access_gate(),
|
||||
test_bogus_exhibit_id(),
|
||||
test_csv_export_bypass(),
|
||||
test_xlsx_export_bypass(),
|
||||
test_return_file_false(),
|
||||
]
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
passed = sum(results)
|
||||
total = len(results)
|
||||
print()
|
||||
print(f"Results: {passed}/{total} passed ({elapsed:.2f}s)")
|
||||
sys.exit(0 if passed == total else 1)
|
||||
99
tests/e2e/test_e2e_v3_action_novi_mailman.py
Normal file
99
tests/e2e/test_e2e_v3_action_novi_mailman.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
E2E tests for the Novi-Mailman Bridge — API connection checks.
|
||||
|
||||
Verifies that both the Novi AMS API and Mailman 3 REST API are reachable
|
||||
and that the credentials stored in IDAA site cfg_json are valid.
|
||||
|
||||
Run from project root:
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_action_novi_mailman.py
|
||||
|
||||
Environment:
|
||||
AE_API_BASE — override the target API base URL (default: https://dev-api.oneskyit.com)
|
||||
|
||||
Related tests:
|
||||
test_e2e_v3_action_novi_mailman_lists.py — member read/subscribe/unsubscribe lifecycle
|
||||
test_e2e_v3_action_novi_mailman_sync.py — full Novi → Mailman mirror sync (TODO)
|
||||
|
||||
Credential storage:
|
||||
All Novi and Mailman credentials live in IDAA site cfg_json (id_random='58_gJESdlUh').
|
||||
See project memory for key names.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get('AE_API_BASE', 'https://dev-api.oneskyit.com')
|
||||
ACTION_BASE = f"{BASE_URL}/v3/action/e_novi_mailman"
|
||||
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
|
||||
|
||||
AUTH_HEADERS = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-no-account-id": "bypass",
|
||||
}
|
||||
|
||||
pass_count = 0
|
||||
fail_count = 0
|
||||
|
||||
|
||||
def print_result(label: str, success: bool, message: str = ""):
|
||||
global pass_count, fail_count
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
msg = f" {message}" if message else ""
|
||||
print(f" [{status}] {label}{msg}")
|
||||
if success:
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
|
||||
def test_novi_connection():
|
||||
print("\n[1] Novi API Connection")
|
||||
try:
|
||||
resp = requests.get(f"{ACTION_BASE}/test_connection/novi", headers=AUTH_HEADERS, timeout=15)
|
||||
data = resp.json().get('data', {})
|
||||
if resp.status_code == 200 and data.get('ok'):
|
||||
print_result("Novi credentials valid", True)
|
||||
elif resp.status_code == 401:
|
||||
print_result("Novi credentials valid", False, f"401 — {data.get('error', resp.text[:120])}")
|
||||
else:
|
||||
print_result("Novi credentials valid", False, f"HTTP {resp.status_code}: {resp.text[:120]}")
|
||||
except Exception as e:
|
||||
print_result("Novi credentials valid", False, f"Exception: {e}")
|
||||
|
||||
|
||||
def test_mailman_connection():
|
||||
print("\n[2] Mailman 3 API Connection")
|
||||
try:
|
||||
resp = requests.get(f"{ACTION_BASE}/test_connection/mailman", headers=AUTH_HEADERS, timeout=15)
|
||||
data = resp.json().get('data', {})
|
||||
if resp.status_code == 200 and data.get('ok'):
|
||||
print_result("Mailman credentials valid", True, f"version={data.get('version', 'unknown')}")
|
||||
elif resp.status_code == 401:
|
||||
print_result("Mailman credentials valid", False,
|
||||
f"401 — {data.get('error', resp.text[:120])} "
|
||||
f"(add mailman_* keys to IDAA cfg_json)")
|
||||
else:
|
||||
print_result("Mailman credentials valid", False, f"HTTP {resp.status_code}: {resp.text[:120]}")
|
||||
except Exception as e:
|
||||
print_result("Mailman credentials valid", False, f"Exception: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print(" Novi-Mailman Bridge — Connection Tests")
|
||||
print(f" Target: {ACTION_BASE}")
|
||||
print("=" * 60)
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
test_novi_connection()
|
||||
test_mailman_connection()
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Results: {pass_count} passed, {fail_count} failed ({elapsed:.2f}s)")
|
||||
print("=" * 60)
|
||||
|
||||
sys.exit(0 if fail_count == 0 else 1)
|
||||
166
tests/e2e/test_e2e_v3_action_novi_mailman_lists.py
Normal file
166
tests/e2e/test_e2e_v3_action_novi_mailman_lists.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
E2E tests for the Novi-Mailman Bridge — Mailman list member operations.
|
||||
|
||||
Covers the full member lifecycle:
|
||||
1. Read current members of all TEST_LISTS
|
||||
2. Subscribe TEST_EMAIL to the primary test list (TEST_LISTS[0])
|
||||
3. Verify the address appears in the member roster
|
||||
4. Unsubscribe TEST_EMAIL (cleanup)
|
||||
5. Verify the address is removed
|
||||
|
||||
Run from project root:
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_action_novi_mailman_lists.py
|
||||
|
||||
Configuration:
|
||||
TEST_LISTS[0] — list used for the subscribe/unsubscribe lifecycle test
|
||||
TEST_EMAIL — address used as the test subscriber (safe to add/remove)
|
||||
TEST_LISTS[1:] — additional lists read-only (member count + roster check)
|
||||
|
||||
Notes:
|
||||
- Uses dot-notation for list IDs in URL paths (mm3@idaa.org → mm3.idaa.org)
|
||||
because @ is a special character in URL paths.
|
||||
- Mailman pre-confirms subscriptions (no confirmation email sent to TEST_EMAIL).
|
||||
- Run test_e2e_v3_action_novi_mailman.py first to confirm both APIs are reachable.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/action/e_novi_mailman"
|
||||
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
|
||||
TEST_EMAIL = "scott.idem+mm3api@gmail.com"
|
||||
TEST_NAME = "Scott Idem (MM3 API Test)"
|
||||
|
||||
TEST_LISTS = [
|
||||
"mm3@idaa.org",
|
||||
"mm3@dgrzone.com",
|
||||
"mm3@oneskyit.com",
|
||||
]
|
||||
|
||||
HEADERS = {
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-no-account-id": "bypass",
|
||||
}
|
||||
|
||||
pass_count = 0
|
||||
fail_count = 0
|
||||
|
||||
|
||||
def print_result(label: str, success: bool, message: str = ""):
|
||||
global pass_count, fail_count
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
msg = f" {message}" if message else ""
|
||||
print(f" [{status}] {label}{msg}")
|
||||
if success:
|
||||
pass_count += 1
|
||||
else:
|
||||
fail_count += 1
|
||||
|
||||
|
||||
def get_members(list_id: str) -> list | None:
|
||||
"""Return member list for a given list_id, or None on failure."""
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
resp = requests.get(f"{BASE_URL}/mailman/lists/{list_id_dot}/members",
|
||||
headers=HEADERS, timeout=15)
|
||||
if resp.status_code == 200 and resp.json().get('meta', {}).get('success'):
|
||||
return resp.json()['data'].get('members', [])
|
||||
return None
|
||||
|
||||
|
||||
def subscribe(list_id: str, email: str, display_name: str = '') -> bool:
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/mailman/lists/{list_id_dot}/subscribe",
|
||||
headers=HEADERS,
|
||||
params={"email": email, "display_name": display_name},
|
||||
timeout=15,
|
||||
)
|
||||
return resp.status_code == 200 and resp.json().get('meta', {}).get('success')
|
||||
|
||||
|
||||
def unsubscribe(list_id: str, email: str) -> bool:
|
||||
list_id_dot = list_id.replace('@', '.')
|
||||
resp = requests.delete(
|
||||
f"{BASE_URL}/mailman/lists/{list_id_dot}/subscribe",
|
||||
headers=HEADERS,
|
||||
params={"email": email},
|
||||
timeout=15,
|
||||
)
|
||||
return resp.status_code == 200 and resp.json().get('meta', {}).get('success')
|
||||
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_read_members():
|
||||
print("\n[1] Read Members")
|
||||
for list_id in TEST_LISTS:
|
||||
members = get_members(list_id)
|
||||
if members is not None:
|
||||
emails = [m['email'] for m in members]
|
||||
print_result(f"Read {list_id}", True, f"{len(members)} member(s): {', '.join(emails) or 'none'}")
|
||||
else:
|
||||
print_result(f"Read {list_id}", False, "request failed")
|
||||
|
||||
|
||||
def test_subscribe():
|
||||
print(f"\n[2] Subscribe {TEST_EMAIL}")
|
||||
list_id = TEST_LISTS[0] # mm3@idaa.org
|
||||
ok = subscribe(list_id, TEST_EMAIL, TEST_NAME)
|
||||
print_result(f"Subscribe to {list_id}", ok)
|
||||
|
||||
|
||||
def test_verify_subscription():
|
||||
print(f"\n[3] Verify {TEST_EMAIL} appears in member list")
|
||||
list_id = TEST_LISTS[0]
|
||||
members = get_members(list_id)
|
||||
if members is None:
|
||||
print_result(f"Verify subscription in {list_id}", False, "could not fetch members")
|
||||
return
|
||||
found = any(m['email'] == TEST_EMAIL for m in members)
|
||||
print_result(f"Found {TEST_EMAIL} in {list_id}", found,
|
||||
"" if found else "not found after subscribe")
|
||||
|
||||
|
||||
def test_unsubscribe():
|
||||
print(f"\n[4] Unsubscribe {TEST_EMAIL} (cleanup)")
|
||||
list_id = TEST_LISTS[0]
|
||||
ok = unsubscribe(list_id, TEST_EMAIL)
|
||||
print_result(f"Unsubscribe from {list_id}", ok)
|
||||
|
||||
|
||||
def test_verify_unsubscription():
|
||||
print(f"\n[5] Verify {TEST_EMAIL} removed from member list")
|
||||
list_id = TEST_LISTS[0]
|
||||
members = get_members(list_id)
|
||||
if members is None:
|
||||
print_result(f"Verify removal from {list_id}", False, "could not fetch members")
|
||||
return
|
||||
still_present = any(m['email'] == TEST_EMAIL for m in members)
|
||||
print_result(f"{TEST_EMAIL} removed from {list_id}", not still_present,
|
||||
"still present after unsubscribe" if still_present else "")
|
||||
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print(" Novi-Mailman Bridge — List Member Operations E2E")
|
||||
print(f" Target: {BASE_URL}")
|
||||
print(f" Test email: {TEST_EMAIL}")
|
||||
print("=" * 60)
|
||||
|
||||
t_start = time.time()
|
||||
|
||||
test_read_members()
|
||||
test_subscribe()
|
||||
test_verify_subscription()
|
||||
test_unsubscribe()
|
||||
test_verify_unsubscription()
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" Results: {pass_count} passed, {fail_count} failed ({elapsed:.2f}s)")
|
||||
print("=" * 60)
|
||||
|
||||
sys.exit(0 if fail_count == 0 else 1)
|
||||
@@ -1,10 +1,15 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
# --- Configuration ---
|
||||
BASE_URL = "https://dev-api.oneskyit.com/v3/crud"
|
||||
API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
||||
# ACCOUNT_ID = "_XY7DXtc9MY"
|
||||
|
||||
# Stable parent IDs used for nested create regression tests.
|
||||
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
||||
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
||||
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
||||
|
||||
# Test Targets: (Object Type, Valid ID Random)
|
||||
# Note: These IDs are extracted from real active records.
|
||||
@@ -30,20 +35,20 @@ def verify_demo_parity(obj_type, record_id):
|
||||
"""
|
||||
print(f"--- Testing {obj_type}: {record_id} ---")
|
||||
url = f"{BASE_URL}/{obj_type}/{record_id}"
|
||||
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=get_headers())
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json().get('data', {})
|
||||
failures = []
|
||||
|
||||
|
||||
# 1. Check Vision Standard (All *_id fields must be strings)
|
||||
for key, val in data.items():
|
||||
if key == "id" or (key.endswith("_id") and not key.endswith("external_id")):
|
||||
if val is not None and not isinstance(val, str):
|
||||
failures.append(f"{key} is {type(val).__name__} ({val})")
|
||||
|
||||
|
||||
# 2. Specific check for account_id in tracking
|
||||
if obj_type == "event_exhibit_tracking":
|
||||
if "account_id" not in data or data["account_id"] is None:
|
||||
@@ -64,11 +69,64 @@ def verify_demo_parity(obj_type, record_id):
|
||||
else:
|
||||
print(f" ❌ [ERROR] Status {response.status_code}: {response.text[:200]}")
|
||||
return False
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f" 💥 [EXCEPTION] {e}")
|
||||
return False
|
||||
|
||||
def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
|
||||
"""
|
||||
Regression test for nested POST create (parent FK injection).
|
||||
|
||||
Bug: root_validators on child models stripped integer parent FKs before
|
||||
INSERT, causing MariaDB 1364 errors. Fixed in api_crud_v3_nested.py by
|
||||
re-injecting resolved_parent_id into data_to_insert after serialization.
|
||||
|
||||
Verifies:
|
||||
1. POST /{parent_type}/{parent_id}/{child_type}/ returns 200
|
||||
2. Response data has a string 'id' (Vision Standard)
|
||||
3. Cleanup: DELETE the created record
|
||||
"""
|
||||
label = f"Nested Create ({parent_type}/{child_type})"
|
||||
print(f"\n--- Regression: {label} ---")
|
||||
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
|
||||
headers = get_headers()
|
||||
|
||||
# --- CREATE ---
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
if resp.status_code != 200:
|
||||
print(f" ❌ [FAIL] POST returned {resp.status_code}: {resp.text[:300]}")
|
||||
return False
|
||||
|
||||
data = resp.json().get('data', {})
|
||||
new_id = data.get('id') or data.get('obj_id_random')
|
||||
|
||||
if not new_id or not isinstance(new_id, str):
|
||||
print(f" ❌ [FAIL] No string 'id' in response. Got: {data}")
|
||||
return False
|
||||
|
||||
print(f" ✅ [PASS] Created {child_type} with id: {new_id}")
|
||||
|
||||
# --- VISION COMPLIANCE: parent FK must not appear as integer ---
|
||||
for key, val in data.items():
|
||||
if (key == 'id' or key.endswith('_id')) and not key.endswith('external_id'):
|
||||
if val is not None and not isinstance(val, str):
|
||||
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
|
||||
return False
|
||||
|
||||
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
|
||||
|
||||
# --- CLEANUP ---
|
||||
delete_url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}"
|
||||
del_resp = requests.delete(delete_url, headers=headers)
|
||||
if del_resp.status_code == 200:
|
||||
print(f" ✅ [PASS] Cleanup: deleted {new_id}")
|
||||
else:
|
||||
print(f" ⚠️ [WARN] Cleanup failed ({del_resp.status_code}) — manual cleanup may be needed for {new_id}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_nested_alias_resolution():
|
||||
"""
|
||||
Verifies that the 'entry' alias and nested resolution works for journals.
|
||||
@@ -78,7 +136,7 @@ def test_nested_alias_resolution():
|
||||
parent_id = "OGQK-02-04-94"
|
||||
child_id = "xWX-NX-e6-EN"
|
||||
url = f"{BASE_URL}/journal/{parent_id}/entry/{child_id}"
|
||||
|
||||
|
||||
resp = requests.get(url, headers=get_headers())
|
||||
if resp.status_code == 200:
|
||||
print(f" ✅ [PASS] Nested alias resolution successful.")
|
||||
@@ -88,8 +146,9 @@ def test_nested_alias_resolution():
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite_start = time.time()
|
||||
print("🚀 Starting Aether V3 Demo Parity Suite\n")
|
||||
|
||||
|
||||
results = []
|
||||
for obj_type, record_id in TARGETS:
|
||||
results.append(verify_demo_parity(obj_type, record_id))
|
||||
@@ -97,7 +156,24 @@ if __name__ == "__main__":
|
||||
|
||||
results.append(test_nested_alias_resolution())
|
||||
|
||||
# --- Nested Create Regression Tests ---
|
||||
# These guard against the Jan 2026 bug where child model root_validators
|
||||
# stripped the parent FK integer before INSERT, causing MariaDB 1364 errors.
|
||||
results.append(test_nested_create_lifecycle(
|
||||
parent_type='journal',
|
||||
parent_id=JOURNAL_PARENT_ID,
|
||||
child_type='journal_entry',
|
||||
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
||||
))
|
||||
results.append(test_nested_create_lifecycle(
|
||||
parent_type='event',
|
||||
parent_id=EVENT_PARENT_ID,
|
||||
child_type='event_session',
|
||||
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
||||
))
|
||||
|
||||
elapsed = time.time() - suite_start
|
||||
if all(results):
|
||||
print("\n🏆 DEMO SUITE SUCCESS: All critical endpoints are verified stable.")
|
||||
print(f"\n🏆 DEMO SUITE SUCCESS: All critical endpoints are verified stable. ({elapsed:.2f}s)")
|
||||
else:
|
||||
print("\n🚨 DEMO SUITE FAILURE: Some critical checks failed.")
|
||||
print(f"\n🚨 DEMO SUITE FAILURE: Some critical checks failed. ({elapsed:.2f}s)")
|
||||
|
||||
@@ -41,6 +41,22 @@ def test_null_error_handling():
|
||||
else:
|
||||
print("❌ Null error check FAILED.")
|
||||
|
||||
def test_1364_schema_mismatch():
|
||||
print("\n--- Testing 1364 Schema Mismatch ---")
|
||||
raw = "(MySQLdb.OperationalError) (1364, \"Field 'account' doesn't have a default value\")"
|
||||
formatted = format_db_error(raw)
|
||||
print(f"Raw: {raw}")
|
||||
print(f"Formatted: {formatted}")
|
||||
|
||||
if (formatted.category == "database_schema"
|
||||
and formatted.code == 1364
|
||||
and "account" in formatted.message
|
||||
and "NOT NULL" in formatted.message):
|
||||
print("✅ 1364 schema mismatch handled correctly.")
|
||||
else:
|
||||
print("❌ 1364 schema mismatch check FAILED.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_error_formatting()
|
||||
test_null_error_handling()
|
||||
test_1364_schema_mismatch()
|
||||
|
||||
Reference in New Issue
Block a user