10 Commits

Author SHA1 Message Date
Scott Idem
d7b86cc186 docs(todo): add Bitbucket app password → API token migration deadline
App passwords inactive 2026-06-09.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:24:05 -04:00
Scott Idem
9adf5659bc test(errors): add unit test for 1364 schema mismatch error
Covers the fix from 308a7f2 — verifies that MariaDB error 1364
is classified as database_schema and the field name is extracted
into a readable message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 19:09:53 -04:00
Scott Idem
308a7f296f fix(errors): classify 1364 as database_schema with actionable message
Parses the field name from the MariaDB error and returns a clear
"Schema mismatch: column 'X' is NOT NULL..." message instead of the
raw DB string. Consistent with how 1054/1146 (unknown column/table)
are already handled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 18:39:50 -04:00
Scott Idem
950e34cabd docs: mark Novi-Mailman Bridge POC complete and update notes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 18:17:18 -04:00
Scott Idem
6b25cf9c6d feat: add Novi AMS → Mailman 3 cron-based mirror sync bridge (IDAA)
Implements a full proof-of-concept for syncing IDAA's Novi AMS membership
groups to Mailman 3 mailing lists via a cron-triggered reconciliation approach.

Key changes:
- methods: rewrote sync engine around confirmed Novi API shape — group-based
  member fetch (/groups/{guid}/members + /customers/{uuid}), respects
  Active=false and UnsubscribeFromEmails=true flags
- methods: mirror_novi_group_to_mailman_list() diffs Novi group against
  Mailman roster and subscribes/unsubscribes accordingly (full mirror)
- methods: mirror_all_configured_mappings() iterates novi_mailman_sync
  config array in IDAA site cfg_json — this is the cron target
- router: replaced old /sync endpoint with POST /sync (all mappings) and
  POST /sync/group/{guid} (single mapping); removed webhook endpoint
  (sync is cron-based, not event-driven)
- router: added GET/POST/DELETE endpoints for list member inspection
  and manual subscribe/unsubscribe
- tests: two new e2e scripts covering connection checks and full member
  lifecycle; old webhook integration test archived

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:36:32 -04:00
Scott Idem
29579fd9f1 feat: add V3 action endpoint for event exhibit tracking export
- New router: app/routers/api_v3_actions_event_exhibit.py
  - GET /v3/action/event_exhibit/{exhibit_id}/tracking_export
  - Full V3 auth (x-aether-api-key + account context)
  - Multi-tenant ownership check via check_account_access
  - Permission gate: leads_api_access flag OR manager-level access
  - Returns CSV or XLSX file attachment (return_file=false for JSON)
  - Flattens responses_json custom Q&A columns; strips HTML from exhibitor_notes
  - Exports all records regardless of hidden/enabled state

- Registered in registry.py under prefix /v3/action/event_exhibit

- New E2E test: tests/e2e/test_e2e_v3_action_event_exhibit_tracking_export.py
  - 7/7 tests passing against dev-api.oneskyit.com

- Docs: GUIDE__AE_API_V3_for_Frontend.md — new Section 7 covering endpoint
  usage, columns, leads_api_access dual-purpose (3rd-party API + UI export gate)

- Docs: tests/README.md — added test to table and when-to-run matrix
2026-03-16 16:50:32 -04:00
Scott Idem
5f3ba1e03e Saving port clarification. 2026-03-16 12:41:29 -04:00
Scott Idem
eaa18a1d45 fix(nested-crud): re-inject parent FK after model serialization to prevent 1364 errors
Root cause: child model root_validators (Vision ID anti-leakage guard) strip
integer IDs before they can be serialized into the INSERT dict, causing MariaDB
to reject the INSERT with 'Field does not have a default value' (1364).

Fix: re-inject resolved_parent_id into data_to_insert after validated_obj.dict()
in post_child_obj(). This is safe — the integer was already verified against the
DB before model validation.

Affected (were all broken since ~2026-01-27):
  - journal/{id}/journal_entry/
  - event/{id}/event_session/
  - event/{id}/event_person/
  - event/{id}/event_registration/
  - event/{id}/event_presenter/
  - event/{id}/event_presentation/
  - event/{id}/event_location/
  - event/{id}/event_track/
  - event/{id}/event_device/
  - event/{id}/event_abstract/
  - event/{id}/event_badge/ (different symptom: NULL FK)

Tests: add nested create lifecycle regression tests to test_e2e_v3_demo_parity.py
  - POST + Vision check + DELETE for journal/journal_entry and event/event_session
  - All 9 checks passing (7s)

Docs: update tests/README.md with accurate demo_parity description and
  a 'When to Run Tests' matrix to prevent future gaps in coverage.
2026-03-16 12:39:45 -04:00
Scott Idem
ee28a4f26e fix: set case_sensitive=False in config to ensure environment variables are correctly injected on Linode/Staging. 2026-03-11 22:35:22 -04:00
Scott Idem
e608696ec8 Docs: WS guide - client_id is UUID v4 persisted in localStorage, not Date.now() 2026-03-11 19:03:16 -04:00
18 changed files with 1463 additions and 325 deletions

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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.")

View 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)

View File

@@ -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'])

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View 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)

View 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)

View 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)

View 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)

View File

@@ -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)")

View File

@@ -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()