29 Commits

Author SHA1 Message Date
Scott Idem
22e5a3c3fd docs(event_badge): comment why enable is omitted on import updates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:38:25 -04:00
Scott Idem
9962176c74 fix(event_badge): don't overwrite enable on re-import
Remove enable from event_person_data (and sub-dicts) before calling
create_update_event_person_obj_v4 on the update path in all three import
endpoints. enable=True is preserved for initial record creation only,
so manually disabled (blacklisted) records survive subsequent imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 16:36:08 -04:00
Scott Idem
35fa5132e7 feat(event_badge): add Splash (Cvent) XLSX badge import endpoint
Adds POST /event/{event_id}/badge/import/splash_xlsx to handle
Splash registrant XLSX exports for Axonius DC 2026 and future events.
Includes _split_full_name helper for splitting 'Full Name' into
given/family name components.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:27:59 -04:00
Scott Idem
e19fd63d1f docs: mark IDAA Novi P5 (frontend migration) complete (2026-05-19)
Frontend now calls GET /v3/action/idaa/novi_member/{uuid} instead of
making a direct browser-to-Novi call. 503 auto-retry also added same
session to match the network-error retry behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:41:10 -04:00
Scott Idem
051b2fd7ac docs: add agent bootstrap quickstart and consolidate documentation
- Add BOOTSTRAP__AI_Agent_Quickstart.md — fast-path entry doc for AI agents
  covering critical rules, V3 action patterns, Redis/auth/logger_reset gotchas,
  and a mistakes-agents-have-made section
- Expand ARCH__V3_DEVELOPMENT_STANDARDS.md with merged content from
  ARCH__V3_CRUD_LEARNINGS: dependency injection reference, security/isolation
  model detail, and FastAPI/Pydantic gotchas
- Archive 4 outdated docs: GUIDE__LOCAL_DEVELOPMENT (predates Docker),
  FRONTEND_API_SAMPLES (belongs in SvelteKit repo), ARCH__UNIFIED_AGENT
  (replaced by MCP ecosystem), ARCH__V3_CRUD_LEARNINGS (content merged above)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:56:09 -04:00
Scott Idem
221854df90 feat(idaa): server-side Novi member verification endpoint
Proxies GET /customers/{uuid} to Novi AMS server-to-server so members'
browser IPs are no longer in the call path, eliminating false "Access
Denied" for users on hotel/conference WiFi, VPNs, and CDN-filtered nets.

- New router: GET /v3/action/idaa/novi_member/{uuid}
- Business logic in app/methods/idaa_novi_verify_methods.py
  - Redis cache (4h TTL, key: idaa:novi_member:{uuid})
  - 404 never cached (recently-joined member anti-pattern)
  - Email space→+ normalization (Novi quirk)
  - Display name: "FirstName L." format with Name field fallback
- Registered in registry.py under /v3/action/idaa tag
- 9 unit tests covering all response paths (200/404/429/503/unreachable,
  cache hit, email normalization, display name format)
- Frontend guide (Section 12) and tests/README updated with full spec
  and migration table for frontend hand-off

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:35:01 -04:00
Scott Idem
c7335bbc3e fix(event_session): restore event_presentation/presenter_li_qry_str fields
These fields from v_event_session_w_file_count were lost during the v1/v2
-> v3 migration. Added to Event_Session_Base model and to searchable_fields
in the event_session object definition.

Fields are only available via the alt view (v_event_session_w_file_count).
To search: use ?view=alt on the nested search endpoint.
To retrieve: use ?inc_file_count=true on the GET endpoint.

Also:
- Updated ARCH__V3_DEVELOPMENT_STANDARDS.md: expanded Field Evolution
  Checklist with alt-view field rules, Docker restart requirement, and
  documented the ?view= parameter as a live (not proposed) feature.
- Updated TODO__Agents.md: marked migration gap audit as complete.
- Added regression test to test_e2e_v3_search_engine.py.
2026-05-15 12:32:26 -04:00
Scott Idem
45a5acd45d feat(site_domain): restore convenience fields to Site_Domain_Base
Add account_name, account_code, account_enable, account_enable_from/to,
site_enable_from/to, site_domain_access_key, logo_path, style_href,
script_src, and google_tracking_id to Site_Domain_Base.

These fields were present in Site_Domain_FQDN_ID_Base but were lost
during the v1/v2 -> v3 migration. The v_site_domain view already
provides them via JOINs, so no DB changes are required.
2026-05-15 11:46:48 -04:00
Scott Idem
c64c3bc55a fix(importing): normalize non-breaking spaces in CSV datetime fields
Google Sheets embeds \xa0 (non-breaking space) in 12-hour time values
(e.g. "3:00\xa0PM") and when date/time columns are combined. This caused
MariaDB datetime INSERTs to fail with an OperationalError.

Adds _clean_datetime() which strips \xa0, normalizes whitespace, and
parses common import formats (M/D/YYYY H:MM AM/PM, etc.) into
YYYY-MM-DD HH:MM:SS before the DB write. Applied to all four datetime
fields: session and presentation start/end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:15:13 -04:00
Scott Idem
c8377a2b22 Version bump because things are in a good state overall. 2026-05-13 17:33:33 -04:00
Scott Idem
f6ba339276 Add archive_content fields and docs 2026-05-07 18:42:43 -04:00
Scott Idem
ed66ba4bd4 fix(post): retain topic_id and note review 2026-05-01 18:20:10 -04:00
Scott Idem
44e4f5c4e6 feat: migrate email send to V3 action; deprecate api.py legacy endpoints
- Add /v3/action/email/send router (api_v3_actions_email.py) replacing /util/email/send
- Disable util_email router in registry; register new email action router
- Mark /api/request_jwt and /api/temp_token as deprecated (TODO: remove)
- Guide: add §8 Email Send Action, mark Axonius section EXPIRED, renumber §9-§11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:44:28 -04:00
Scott Idem
c378040ad4 Updated docs 2026-04-30 17:14:42 -04:00
Scott Idem
b590bc09a0 fix: require root_url on email_auth_key_url; correct frontend guide for user auth endpoints
- Make root_url a required query param on GET /v3/action/user/{id}/email_auth_key_url
  (previously Optional[str]=None, which produced a malformed link in the emailed URL)
- Update GUIDE__AE_API_V3_for_Frontend.md: document root_url as required, add magic link
  URL format, note valid_email=True side effect, add 404 error, expand 403 conditions
  for authenticate, add 400 for verify_password when no password is set
- Add test_e2e_v3_user_action_routes.py and test_e2e_v3_user_auth_routes.py to tests/README.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:34:49 -04:00
Scott Idem
e71906b59a Saving stress testing 2026-04-19 13:57:31 -04:00
Scott Idem
3d89e95c24 fix(P2): add OperationalError retry to sql_insert, sql_select, sql_insert_or_update
All three were missing the transient-connection retry that sql_update and
run_sql_select already had. On OperationalError (stale/dropped connection),
each now retries once with a fresh engine.connect() without disposing the pool.

IntegrityError (duplicate key, FK violation, NOT NULL) continues to return
None without retrying — the same data would fail again and None signals a
data conflict to callers, distinct from False (error) or an int (success).

sql_insert_or_update retry is safe because ON DUPLICATE KEY UPDATE is idempotent.
sql_insert retry is safe because OperationalError means MariaDB rolled back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:41:26 -04:00
Scott Idem
3db5f7c749 fix(P3): guard startup db connection with try/except in lib_sql_core
Wraps the deprecated global `db = engine.connect()` in a try/except so
a Docker startup race (MariaDB not yet ready) no longer crashes the
Gunicorn worker before it can serve any requests.

Sets db=None on failure; reconnect_db() on the lifespan bootstrap path
re-establishes it once credentials are confirmed.

TODO (P3 full): migrate lib_schema_v3.py:39 and lib_api_crud_v3.py:166
off the global db to engine.connect() context managers, then remove it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:28:28 -04:00
Scott Idem
55debc8009 feat: add stress_list_queries tool and document in tests/README
Concurrent read-only stress test against V3 list endpoints.
Improvements over initial version: --base-url, --limit CLI flags,
interpolated percentile calculation (accurate on small sample sizes),
and pre-sorted times passed to overall summary.
README: added tools table with quick-reference usage examples.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:12:01 -04:00
Scott Idem
ace00929f2 feat: expose DB pool_size and max_overflow as env vars (P4)
Added AE_DB_POOL_SIZE and AE_DB_POOL_MAX_OVERFLOW to config.py with
defaults matching prior hardcoded values (10/20). Wired into settings.DB
property so create_ae_engine() reads them without fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:08:01 -04:00
Scott Idem
c7444a8a89 fix: remove pool-nuking reconnect_db() from OperationalError retry paths
On OperationalError, sql_update and run_sql_select were calling
sql_connect() → reconnect_db() which disposes the entire connection
pool mid-flight, killing other in-flight connections under concurrency.

Removed the sql_connect() calls; the existing retry blocks already open
a fresh engine.connect() context manager, and pool_pre_ping=True handles
stale connection detection. Also drops the now-unused sql_connect import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:24:47 -04:00
Scott Idem
8f1fe5d4df Fixing this:
#3 (zombie import) is genuinely a 2-line fix — remove the import from api.py:10 and move db_connection.py to trash. Zero functional change since db is only in a commented-out line.
2026-04-16 20:09:12 -04:00
Scott Idem
c0626e061e fix: remove account_id injection from nested POST handler
Child objects in the nested endpoint inherit account context from their
parent via the FK relationship and do not carry their own account_id
column (e.g. event_badge, journal_entry). Injecting account_id into
data_to_insert would cause INSERT failures for any child whose table
has no account_id column but whose model has the field (from the view).

The original code set account_id in obj_data before model instantiation,
where the root_validator immediately stripped it — a harmless no-op.
The previous commit turned that dead line into a live injection by moving
it after serialization, which would break journal_entry creates on
non-bypass auth. Removed entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:22:20 -04:00
Scott Idem
dfb5289188 fix: skip account_id injection when model excludes it from DB writes
After the sanitize_payload order fix, account_id was being re-injected
into data_to_insert for models that explicitly list account_id in
fields_to_exclude_from_db (e.g. event_badge, event_device). Those tables
have no account_id column, causing INSERT failures.

Guard the post-sanitize account_id injection in both api_crud_v3.py and
api_crud_v3_nested.py by checking fields_to_exclude_from_db first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:09:11 -04:00
Scott Idem
0ecc5a97d5 fix: resolve secondary FKs in nested POST (event_badge_template_id)
In the nested POST handler (api_crud_v3_nested.py), sanitize_payload was
running before model instantiation. For secondary FK fields like
event_badge_template_id, sanitize_payload resolved the random string →
integer, then the model's root_validator stripped the integer back to None
(Vision ID anti-leakage guard). Only the parent FK survived because it was
explicitly re-injected after serialization.

Fix: moved sanitize_payload to run on data_to_insert after serialization,
matching the flat V3 POST pattern (api_crud_v3.py). Also moved account_id
injection to after sanitize_payload, fixing a latent bug where account_id
was silently written as NULL on non-bypass auth.

Adds regression test to test_e2e_v3_demo_parity.py that creates an
event_badge via nested POST with event_badge_template_id and verifies the
field is non-None in the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:00:51 -04:00
Scott Idem
516865b7d8 Updated docs 2026-04-10 11:56:44 -04:00
Scott Idem
7f9666dc1e fix: authenticate_passcode — priority ordering, full role flags, per-role TTL, min_length 2026-04-10 11:53:58 -04:00
Scott Idem
f9f588ddf2 docs: add temporary Axonius Zoom CSV Upload section (Apr 2026) 2026-04-08 12:38:36 -04:00
Scott Idem
ea25bf78d4 import: map marketing consent CSV column to event_badge.agree_to_tc and allow_tracking 2026-04-07 19:59:51 -04:00
34 changed files with 2420 additions and 99 deletions

View File

@@ -1,5 +1,5 @@
# Aether API v3.00.xx (FastAPI) # Aether API v3.00.20 (FastAPI)
The **Aether API** is a high-performance, multi-tenant backend for the Aether Platform, built on Python **FastAPI**. It powers both legacy and modern (V3/V4) applications, and is now fully containerized for robust, scalable deployment. The **Aether API** is a high-performance, multi-tenant backend for the Aether Platform, built on Python **FastAPI**. It powers both legacy and modern (V3/V4) applications, and is now fully containerized for robust, scalable deployment.

View File

@@ -27,6 +27,8 @@ class Settings(BaseSettings):
# Connection tuning # Connection tuning
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT') DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE') DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE')
DB_POOL_SIZE: int = Field(10, env='AE_DB_POOL_SIZE')
DB_POOL_MAX_OVERFLOW: int = Field(20, env='AE_DB_POOL_MAX_OVERFLOW')
# --- Logging --- # --- Logging ---
LOG_PATH_APP: str = Field('/logs/aether_api.log', env='AE_API_LOG_PATH') LOG_PATH_APP: str = Field('/logs/aether_api.log', env='AE_API_LOG_PATH')
@@ -75,6 +77,8 @@ class Settings(BaseSettings):
'password': self.DB_PASS, 'password': self.DB_PASS,
'connect_timeout': self.DB_CONNECT_TIMEOUT, 'connect_timeout': self.DB_CONNECT_TIMEOUT,
'pool_recycle': self.DB_POOL_RECYCLE, 'pool_recycle': self.DB_POOL_RECYCLE,
'pool_size': self.DB_POOL_SIZE,
'max_overflow': self.DB_POOL_MAX_OVERFLOW,
} }
@property @property

View File

@@ -43,9 +43,15 @@ def create_ae_engine(uri: str):
engine = create_ae_engine(db_uri) engine = create_ae_engine(db_uri)
# DEPRECATED: Global shared 'db' connection. Use engine.connect() in context managers instead. # DEPRECATED: Global shared 'db' connection. Still used by lib_schema_v3.py and lib_api_crud_v3.py.
# Keeping for legacy compatibility but will phase out usage in crud lib. # TODO (P3 full fix): migrate those two call sites to engine.connect() context managers, then remove this.
# Bare connect guarded so a Docker startup race (MariaDB not yet ready) doesn't crash the worker.
# If this fails, db=None — callers that hit it before reconnect_db() runs will raise AttributeError.
try:
db = engine.connect() db = engine.connect()
except Exception:
log.warning("DB SQL Core: Initial db connection failed at startup (MariaDB not ready?). Will retry via reconnect_db().")
db = None
log.info('DB SQL Core: Initializing engine...') log.info('DB SQL Core: Initializing engine...')

View File

@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from app.log import log, logger_reset from app.log import log, logger_reset
# CRITICAL: Import the core module to access current global state # CRITICAL: Import the core module to access current global state
from app import lib_sql_core from app import lib_sql_core
from app.lib_sql_core import sql_connect, set_last_sql_error from app.lib_sql_core import set_last_sql_error
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL # log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
@@ -63,11 +63,29 @@ def sql_insert(
return result_insert.lastrowid return result_insert.lastrowid
return False return False
except IntegrityError as e: except IntegrityError as e:
# Data constraint violation (duplicate key, FK mismatch, NOT NULL) — do NOT retry;
# the same data would fail again. Return None so callers can distinguish from errors.
if trans: trans.rollback() if trans: trans.rollback()
log.error('Integrity error (likely duplicate). Returning None') log.error('Integrity error (likely duplicate). Returning None')
log.debug(e) log.debug(e)
set_last_sql_error(e) set_last_sql_error(e)
return None return None
except OperationalError:
# Transient connection failure. The broken connection rolls back on MariaDB's side,
# so retrying with a fresh connection is safe.
if trans: trans.rollback()
log.warning('Operational error in sql_insert. Retrying once with fresh connection...')
try:
with lib_sql_core.engine.connect() as conn:
trans = conn.begin()
result_insert = conn.execute(sql_insert_stmt, data)
trans.commit()
if result_insert.rowcount == 1 and result_insert.lastrowid > 0:
return result_insert.lastrowid
return False
except Exception as e:
set_last_sql_error(e)
return False
except Exception as e: except Exception as e:
if trans: trans.rollback() if trans: trans.rollback()
log.error('Unknown exception in sql_insert. Returning False') log.error('Unknown exception in sql_insert. Returning False')
@@ -138,7 +156,6 @@ def sql_update(
except OperationalError: except OperationalError:
if trans: trans.rollback() if trans: trans.rollback()
log.error('Operational error (gone away?). Retrying once...') log.error('Operational error (gone away?). Retrying once...')
sql_connect()
try: try:
with lib_sql_core.engine.connect() as conn: with lib_sql_core.engine.connect() as conn:
trans = conn.begin() trans = conn.begin()
@@ -199,6 +216,19 @@ def sql_insert_or_update(
res = conn.execute(stmt, data) res = conn.execute(stmt, data)
trans.commit() trans.commit()
return res.lastrowid if res.lastrowid > 0 else True return res.lastrowid if res.lastrowid > 0 else True
except OperationalError:
# ON DUPLICATE KEY UPDATE is idempotent — safe to retry.
if trans: trans.rollback()
log.warning('Operational error in sql_insert_or_update. Retrying once...')
try:
with lib_sql_core.engine.connect() as conn:
trans = conn.begin()
res = conn.execute(stmt, data)
trans.commit()
return res.lastrowid if res.lastrowid > 0 else True
except Exception as e:
set_last_sql_error(e)
return False
except Exception as e: except Exception as e:
if trans: trans.rollback() if trans: trans.rollback()
log.exception(e) log.exception(e)
@@ -309,6 +339,21 @@ def sql_select(
return [] if as_list else None return [] if as_list else None
rows = result.all() rows = result.all()
except OperationalError:
# Transient connection failure — reads are always safe to retry.
log.error('Operational error in sql_select. Retrying once with fresh connection...')
try:
with lib_sql_core.engine.connect() as conn:
result = conn.execute(stmt, data)
if not result:
return [] if as_list else None
if hasattr(result, 'returns_rows') and not result.returns_rows:
return [] if as_list else None
rows = result.all()
except Exception as e:
log.error(f"SQL Fetch Error on retry: {e}")
set_last_sql_error(e)
return False
except Exception as e: except Exception as e:
log.error(f"SQL Fetch Error: {e}") log.error(f"SQL Fetch Error: {e}")
set_last_sql_error(e) set_last_sql_error(e)
@@ -343,7 +388,6 @@ def run_sql_select(
return conn.execute(sql, data) return conn.execute(sql, data)
except (OperationalError, ProgrammingError) as e: except (OperationalError, ProgrammingError) as e:
log.error(f'DB Error: {e}. Retrying once...') log.error(f'DB Error: {e}. Retrying once...')
sql_connect()
try: try:
with lib_sql_core.engine.connect() as conn: with lib_sql_core.engine.connect() as conn:
return conn.execute(sql, data) return conn.execute(sql, data)

View File

@@ -0,0 +1,154 @@
import datetime
import json
import requests
from typing import Dict, Optional
from app.lib_general import log, logger_reset
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
_CACHE_TTL = datetime.timedelta(hours=4)
# ── Config ────────────────────────────────────────────────────────────────
@logger_reset
def _load_idaa_cfg() -> Optional[Dict]:
"""Load IDAA site cfg_json. Returns parsed dict or None on failure."""
from app.methods.site_methods import load_site_obj
site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True)
if not site:
log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM)
return None
cfg = site.get('cfg_json')
if isinstance(cfg, str):
try:
cfg = json.loads(cfg)
except Exception as e:
log.error("Failed to parse IDAA cfg_json: %s", e)
return None
if not isinstance(cfg, dict):
log.error("IDAA cfg_json is not a dict after parsing.")
return None
return cfg
def _cache_key(uuid: str) -> str:
return f'idaa:novi_member:{uuid}'
# ── Public API ────────────────────────────────────────────────────────────
@logger_reset
def verify_novi_member(uuid: str) -> Dict:
"""
Proxy GET /customers/{uuid} to Novi AMS and return normalized member data.
Returns a dict with one of:
{'status': 200, 'verified': True, 'full_name': '...', 'email': '...'}
{'status': 404, 'reason': '...'}
{'status': 429, 'reason': '...'}
{'status': 503, 'reason': '...'}
Redis cache key: idaa:novi_member:{uuid}, TTL 4 hours.
Only 200 (verified) results are cached — 404 is never cached.
"""
from app.lib_redis_helpers import redis_client
cache_key = _cache_key(uuid)
# ── Cache hit ─────────────────────────────────────────────────────────
cached_raw = redis_client.get(cache_key)
if cached_raw:
try:
cached = json.loads(cached_raw)
log.info("Novi verify cache hit: %s", uuid)
return cached
except Exception:
pass # corrupt cache entry — fall through to Novi
# ── Load credentials ──────────────────────────────────────────────────
cfg = _load_idaa_cfg()
if not cfg:
return {'status': 503, 'reason': 'IDAA site configuration unavailable.'}
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
api_key = cfg.get('novi_idaa_api_key', '')
if not base_url or not api_key:
log.error("novi_api_root_url or novi_idaa_api_key missing from IDAA cfg_json.")
return {'status': 503, 'reason': 'Novi credentials not configured.'}
headers = {'Authorization': f'Basic {api_key}', 'Accept': 'application/json'}
# ── Call Novi ─────────────────────────────────────────────────────────
try:
resp = requests.get(f'{base_url}/customers/{uuid}', headers=headers, timeout=10)
except requests.exceptions.ConnectionError as e:
log.error("Novi unreachable: %s", e)
return {'status': 503, 'reason': 'Novi API unreachable.'}
except requests.exceptions.Timeout:
log.error("Novi request timed out for UUID %s", uuid)
return {'status': 503, 'reason': 'Novi API timed out.'}
except Exception as e:
log.exception("Unexpected error calling Novi for UUID %s: %s", uuid, e)
return {'status': 503, 'reason': 'Unexpected error contacting Novi.'}
if resp.status_code == 429:
log.warning("Novi rate limit hit for UUID %s", uuid)
return {'status': 429, 'reason': 'Novi rate limit exceeded. Try again shortly.'}
if resp.status_code >= 500:
log.error("Novi server error %s for UUID %s", resp.status_code, uuid)
return {'status': 503, 'reason': f'Novi server error ({resp.status_code}).'}
if resp.status_code == 404:
log.info("Novi returned 404 for UUID %s", uuid)
return {'status': 404, 'reason': 'Member not found in Novi.'}
if resp.status_code != 200:
log.error("Unexpected Novi status %s for UUID %s: %s", resp.status_code, uuid, resp.text[:200])
return {'status': 503, 'reason': f'Unexpected Novi response ({resp.status_code}).'}
# ── Parse response ────────────────────────────────────────────────────
try:
data = resp.json()
except Exception:
log.error("Novi returned non-JSON for UUID %s", uuid)
return {'status': 503, 'reason': 'Novi returned an unparseable response.'}
if not isinstance(data, dict):
log.warning("Novi returned non-dict body for UUID %s", uuid)
return {'status': 404, 'reason': 'Member not found in Novi (empty response).'}
# Empty-member anti-pattern: Novi 200 with no identity data
email_raw = (data.get('Email') or '').strip()
if not email_raw:
log.info("Novi 200 with no Email for UUID %s — empty-member anti-pattern", uuid)
return {'status': 404, 'reason': 'Member not found in Novi (no identity data).'}
email = email_raw.replace(' ', '+')
# Build display name: "FirstName LastName[0]." — fall back to Name field
first = (data.get('FirstName') or '').strip()
last = (data.get('LastName') or '').strip()
if first and last:
full_name = f'{first} {last[0]}.'
elif first:
full_name = first
else:
full_name = (data.get('Name') or '').strip() or 'Member'
result = {
'status': 200,
'verified': True,
'full_name': full_name,
'email': email,
}
# ── Cache verified result ─────────────────────────────────────────────
try:
redis_client.setex(cache_key, _CACHE_TTL, json.dumps(result))
except Exception as e:
log.warning("Failed to cache Novi verify result for %s: %s", uuid, e)
return result

View File

@@ -36,6 +36,9 @@ class Archive_Content_Base(BaseModel):
lu_media_type_id: Optional[int] lu_media_type_id: Optional[int]
lu_media_type: Optional[str] lu_media_type: Optional[str]
external_id: Optional[str]
code: Optional[str]
name: Optional[str] name: Optional[str]
description: Optional[str] description: Optional[str]

View File

@@ -139,6 +139,8 @@ class Event_Session_Base(BaseModel):
created_on: Optional[datetime.datetime] = None created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None updated_on: Optional[datetime.datetime] = None
default_qry_str: Optional[str] # Default query string used for searching and filtering sessions. Updated using SQL triggers and a SQL function default_qry_str: Optional[str] # Default query string used for searching and filtering sessions. Updated using SQL triggers and a SQL function
event_presentation_li_qry_str: Optional[str] # Concatenated query string of presentation data for this session (from v_event_session_w_file_count)
event_presenter_li_qry_str: Optional[str] # Concatenated query string of presenter data for this session (from v_event_session_w_file_count)
# Including convenience data # Including convenience data
# This is only for convenience. Probably going to keep unless it causes a problem. # This is only for convenience. Probably going to keep unless it causes a problem.

View File

@@ -32,7 +32,7 @@ class Post_Base(BaseModel):
# type_id: Optional[int] # type_id: Optional[int]
# topic_id_random: Optional[str] # topic_id_random: Optional[str]
# topic_id: Optional[int] topic_id: Optional[int]
type: Optional[str] type: Optional[str]

View File

@@ -42,6 +42,23 @@ class Site_Domain_Base(BaseModel):
created_on: Optional[datetime.datetime] = None created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None updated_on: Optional[datetime.datetime] = None
# Convenience fields from v_site_domain view (joined from account/site)
account_code: Optional[str] = None
account_name: Optional[str] = None
account_enable: Optional[bool] = None
account_enable_from: Optional[datetime.datetime] = None
account_enable_to: Optional[datetime.datetime] = None
site_enable_from: Optional[datetime.datetime] = None
site_enable_to: Optional[datetime.datetime] = None
site_domain_access_key: Optional[str] = None
logo_path: Optional[str] = None
style_href: Optional[str] = None
script_src: Optional[str] = None
google_tracking_id: Optional[str] = None
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@root_validator(pre=True) @root_validator(pre=True)

View File

@@ -135,7 +135,8 @@ events_presentation_obj_li = {
'poc_person_full_name', 'poc_person_full_name',
'public', 'public_hide', 'hide_event_launcher', 'public', 'public_hide', 'hide_event_launcher',
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
'default_qry_str', 'event_location_name' 'default_qry_str', 'event_location_name',
'event_presentation_li_qry_str', 'event_presenter_li_qry_str'
], ],
}, },
'event_track': { 'event_track': {

View File

@@ -108,7 +108,7 @@ other_obj_li = {
'searchable_fields': [ 'searchable_fields': [
'id', 'account_id', 'archive_id', 'hosted_file_id', 'id', 'account_id', 'archive_id', 'hosted_file_id',
'id_random', 'archive_content_id_random', 'account_id_random', 'archive_id_random', 'id_random', 'archive_content_id_random', 'account_id_random', 'archive_id_random',
'archive_content_type', 'lu_media_type', 'name', 'description', 'archive_content_type', 'lu_media_type', 'external_id', 'code', 'name', 'description',
'filename', 'file_extension', 'original_location', 'original_url', 'filename', 'file_extension', 'original_location', 'original_url',
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on' 'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
], ],

View File

@@ -4,15 +4,15 @@ from typing import Dict, List, Optional, Set, Union
from sqlalchemy import text from sqlalchemy import text
import json import json
import time import time
import secrets # import secrets
import jwt as pyjwt # Avoid conflict with app.lib_jwt import jwt as pyjwt # Avoid conflict with app.lib_jwt
from app.db_connection import db # from app.db_connection import db
from app.lib_general import sign_jwt, decode_jwt, log, logging from app.lib_general import sign_jwt, decode_jwt, log, logging
from app.config import settings from app.config import settings
from app.db_sql import sql_insert, sql_update, sql_select, redis_lookup_id_random, get_id_random from app.db_sql import sql_insert, sql_update, sql_select, redis_lookup_id_random, get_id_random
from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template # from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
from app.routers.dependencies_v3 import DeprecationParams from app.routers.dependencies_v3 import DeprecationParams
from app.models.api_models import Api_Base from app.models.api_models import Api_Base
from app.models.response_models import Resp_Body_Base, mk_resp from app.models.response_models import Resp_Body_Base, mk_resp
@@ -21,10 +21,21 @@ router = APIRouter()
# --- Passcode Authentication --- # --- Passcode Authentication ---
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
ROLE_TTL = {
'super': 8 * 3600, # 8 hours
'manager': 24 * 3600, # 24 hours
'administrator': 48 * 3600, # 48 hours
'trusted': 48 * 3600, # 48 hours
'public': 24 * 3600, # 24 hours
'authenticated': 12 * 3600, # 12 hours
}
class PasscodeAuthRequest(BaseModel): class PasscodeAuthRequest(BaseModel):
"""Request model for site-based passcode authentication.""" """Request model for site-based passcode authentication."""
site_id: str = Field(..., description="The random string ID of the site") site_id: str = Field(..., description="The random string ID of the site")
passcode: str = Field(..., description="The passcode to verify") passcode: str = Field(..., min_length=5, description="The passcode to verify")
@router.post('/authenticate_passcode', response_model=Resp_Body_Base) @router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode( async def authenticate_passcode(
@@ -54,10 +65,11 @@ async def authenticate_passcode(
except Exception as e: except Exception as e:
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}") log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
# 3. Verify Passcode and Resolve Role # 3. Verify passcode in explicit priority order (highest privilege wins)
matched_role = None matched_role = None
for role, code in access_codes.items(): for role in ROLE_PRIORITY:
if str(code) == str(passcode): code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role matched_role = role
break break
@@ -70,12 +82,15 @@ async def authenticate_passcode(
if account_id_int := record.get('account_id'): if account_id_int := record.get('account_id'):
account_id_random = get_id_random(record_id=account_id_int, table_name='account') account_id_random = get_id_random(record_id=account_id_int, table_name='account')
# 5. Mint JWT # 5. Mint JWT with complete role flags and per-role TTL
payload = { payload = {
'account_id': account_id_random, 'account_id': account_id_random,
'administrator': (matched_role == 'administrator'),
'manager': (matched_role == 'manager'),
'super': (matched_role == 'super'), 'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({ 'json_str': json.dumps({
'auth_type': 'passcode', 'auth_type': 'passcode',
'site_id': site_id, 'site_id': site_id,
@@ -85,7 +100,7 @@ async def authenticate_passcode(
token = sign_jwt( token = sign_jwt(
secret_key=settings.JWT_KEY, secret_key=settings.JWT_KEY,
ttl=3600 * 24, # 24 hour session ttl=ROLE_TTL[matched_role],
**payload **payload
) )
@@ -99,6 +114,8 @@ async def authenticate_passcode(
# --- JWT Request --- # --- JWT Request ---
# DEPRECATED — no V3 replacement needed; passcode→JWT is the V3 auth pattern (/api/authenticate_passcode).
# No frontend references found. Safe to remove after confirming no live traffic. TODO: remove.
@router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)]) @router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def request_jwt( async def request_jwt(
x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22), x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22),
@@ -152,6 +169,7 @@ async def request_jwt(
token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload) token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload)
return mk_resp(data={ 'jwt': token }) return mk_resp(data={ 'jwt': token })
# DEPRECATED — no active use identified. TODO: remove after confirming no live traffic.
@router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)]) @router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def get_api_temp_token( async def get_api_temp_token(
x_aether_api_key: Optional[str] = Header(None), x_aether_api_key: Optional[str] = Header(None),

View File

@@ -487,8 +487,12 @@ async def post_obj(
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight # Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
# to the DB without conflicting with Vision ID string constraints in the model. # to the DB without conflicting with Vision ID string constraints in the model.
# Guard: skip if the model explicitly excludes account_id from DB writes (e.g. event_badge,
# event_device — the column does not exist in those tables).
if not account.super and account.auth_method != 'bypass' and account.account_id: if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__: if 'account_id' in input_model.__fields__:
excluded = getattr(input_model, 'fields_to_exclude_from_db', [])
if 'account_id' not in excluded:
data_to_insert['account_id'] = account.account_id data_to_insert['account_id'] = account.account_id
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert): if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):

View File

@@ -313,15 +313,6 @@ async def post_child_obj(
if not table_name_insert or not input_model or not table_name_select or not output_model: if not table_name_insert or not input_model or not table_name_select or not output_model:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
obj_data['account_id'] = account.account_id
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
try: try:
validated_obj = input_model(**obj_data) validated_obj = input_model(**obj_data)
except ValidationError as e: except ValidationError as e:
@@ -332,8 +323,18 @@ async def post_child_obj(
data_to_insert = validated_obj.dict(exclude_unset=True) data_to_insert = validated_obj.dict(exclude_unset=True)
# Re-inject parent FK after model serialization. Some model root_validators strip # Sanitize AFTER serialization so that:
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict. # 1. The model receives raw Vision ID strings (passes field-length constraints).
# 2. ID resolution (string → integer) happens on the dict going to the DB,
# avoiding the root_validator's integer-stripping anti-leakage guard.
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
# set — ensuring the child is always linked to the correct parent.
# Note: account_id is intentionally NOT injected here. Child objects in the nested
# endpoint inherit account context from their parent via the FK relationship; they do
# not carry their own account_id column (e.g. event_badge, journal_entry).
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id 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): if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):

View File

@@ -0,0 +1,78 @@
"""
Aether API V3 - Email Action Router
-------------------------------------
Handles transactional email sending.
Routes:
POST /send — send a transactional email
Replaces: POST /util/email/send (legacy — see util_email.py)
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query, Response
from pydantic import BaseModel, Field
from app.lib_email import send_email
from app.lib_general_v3 import AccountContext, get_account_context
from app.models.response_models import Resp_Body_Base, mk_resp
log = logging.getLogger(__name__)
router = APIRouter()
class EmailSendRequest(BaseModel):
from_email: str = Field(..., description="Sender email address")
from_name: Optional[str] = None
to_email: str = Field(..., description="Recipient email address")
to_name: Optional[str] = None
cc_email: Optional[str] = None
cc_name: Optional[str] = None
bcc_email: Optional[str] = None
bcc_name: Optional[str] = None
subject: str = Field(..., description="Email subject line")
body_html: str = Field(..., description="HTML email body")
body_text: Optional[str] = None
@router.post('/send', response_model=Resp_Body_Base)
async def action_email_send(
req: EmailSendRequest,
test: bool = Query(False, description="Simulate send without delivering"),
account_ctx: AccountContext = Depends(get_account_context),
response: Response = Response,
):
log.setLevel(logging.INFO)
success = send_email(
from_email=req.from_email,
from_name=req.from_name,
to_email=req.to_email,
to_name=req.to_name,
cc_email=req.cc_email or '',
cc_name=req.cc_name or '',
bcc_email=req.bcc_email or '',
bcc_name=req.bcc_name or '',
subject=req.subject,
body_text=req.body_text,
body_html=req.body_html,
test=test,
)
if success:
status_code = 200
status_message = f'Email sent to <{req.to_email}>.'
else:
status_code = 400
status_message = f'Email failed to send to <{req.to_email}>.'
log.info(status_message)
resp_data = {
'from_email': req.from_email,
'to_email': req.to_email,
'subject': req.subject[:40],
}
return mk_resp(data=resp_data, status_code=status_code, response=response, status_message=status_message)

View File

@@ -0,0 +1,41 @@
import asyncio
from fastapi import APIRouter, Depends
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.idaa_novi_verify_methods import verify_novi_member
router = APIRouter()
@router.get('/novi_member/{uuid}', response_model=Resp_Body_Base)
async def get_novi_member_verification(
uuid: str,
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(),
):
"""
Proxy Novi AMS member lookup server-to-server.
Returns verified member identity or an appropriate error code.
"""
if delay.sleep_time_s > 0:
await asyncio.sleep(delay.sleep_time_s)
result = verify_novi_member(uuid)
status = result.get('status', 503)
if status == 200:
return mk_resp(data={
'verified': result['verified'],
'full_name': result['full_name'],
'email': result['email'],
})
if status == 404:
return mk_resp(data=False, status_code=404, status_message=result.get('reason', 'Member not found.'))
if status == 429:
return mk_resp(data=False, status_code=429, status_message=result.get('reason', 'Novi rate limit exceeded.'))
return mk_resp(data=False, status_code=503, status_message=result.get('reason', 'Novi API unavailable.'))

View File

@@ -271,7 +271,7 @@ async def action_new_auth_key(
@router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base) @router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
async def action_email_auth_key_url( async def action_email_auth_key_url(
user_id: str = Path(min_length=11, max_length=22), user_id: str = Path(min_length=11, max_length=22),
root_url: Optional[str] = Query(None, min_length=10, max_length=200), root_url: str = Query(..., min_length=10, max_length=200),
key_param_name: str = Query('auth_key', min_length=2, max_length=30), key_param_name: str = Query('auth_key', min_length=2, max_length=30),
account: AccountContext = Depends(get_account_context), account: AccountContext = Depends(get_account_context),
): ):

View File

@@ -432,6 +432,11 @@ async def event_id_badge_import(
event_badge_id = event_person_result.get('event_badge_id') event_badge_id = event_person_result.get('event_badge_id')
event_person_profile_id = event_person_result.get('event_person_profile_id') event_person_profile_id = event_person_result.get('event_person_profile_id')
log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}') log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}')
# Don't touch enable on update — a manually disabled record is effectively
# blacklisted and should survive repeated re-imports of the same file.
event_person_data.pop('enable', None)
event_person_data.get('event_badge', {}).pop('enable', None)
event_person_data.get('event_person_profile', {}).pop('enable', None)
if create_event_person_obj_result := create_update_event_person_obj_v4( if create_event_person_obj_result := create_update_event_person_obj_v4(
event_person_dict_obj = event_person_data, event_person_dict_obj = event_person_data,
event_person_id = event_person_id, event_person_id = event_person_id,
@@ -501,6 +506,14 @@ async def event_id_badge_import(
# WHERE badge_type = 'Adapt26 Sponsor'; # WHERE badge_type = 'Adapt26 Sponsor';
def _split_full_name(full_name: str) -> tuple:
"""Split 'First Last' on last space into (given_name, family_name)."""
parts = full_name.strip().rsplit(' ', 1)
if len(parts) == 2:
return parts[0], parts[1]
return full_name.strip(), ''
def _zoom_ticket_field(record: dict, field_prefix: str, ticket_name: str) -> str: def _zoom_ticket_field(record: dict, field_prefix: str, ticket_name: str) -> str:
""" """
Extracts a per-ticket-type field value from a Zoom CSV row. Extracts a per-ticket-type field value from a Zoom CSV row.
@@ -656,6 +669,44 @@ async def event_id_badge_import_zoom_csv(
if badge_type_code: if badge_type_code:
log.info(f"Axonius mapping applied: '{ticket_name}' -> '{badge_type_code}'") log.info(f"Axonius mapping applied: '{ticket_name}' -> '{badge_type_code}'")
# Parse marketing consent column (if present) and map to badge fields.
# Expected values: "Opt-in" => agree_to_tc=True, allow_tracking=True
# "Opt-out" => agree_to_tc=False, allow_tracking=False
# "N/A" => None/NULL
marketing_raw = None
for _k in ('Agree to receive marketing communication?', 'Agree to receive marketing communication', 'Agree to TC', 'agree_to_tc'):
if _k in record and str(record.get(_k)).strip() != '':
marketing_raw = str(record.get(_k)).strip()
break
agree_to_tc_val = None
allow_tracking_val = None
if marketing_raw is not None:
m = marketing_raw.strip()
m_low = m.lower()
if m_low in ('n/a', 'na'):
agree_to_tc_val = None
allow_tracking_val = None
elif m_low in ('opt-in', 'optin', 'opt in'):
agree_to_tc_val = True
allow_tracking_val = True
elif m_low in ('opt-out', 'optout', 'opt out'):
agree_to_tc_val = False
allow_tracking_val = False
else:
if m_low in ('yes', 'y', 'true', '1'):
agree_to_tc_val = True
allow_tracking_val = True
elif m_low in ('no', 'n', 'false', '0'):
agree_to_tc_val = False
allow_tracking_val = False
else:
agree_to_tc_val = None
allow_tracking_val = None
# Need to deal with this special field/column for Axonius
# "Agree to receive marketing communication?"
event_person_data = { event_person_data = {
'account_id': account_id, 'account_id': account_id,
'event_id': event_id, 'event_id': event_id,
@@ -712,6 +763,8 @@ async def event_id_badge_import_zoom_csv(
'event_badge_template_id_random': 'RKYp2HcQm9o', 'event_badge_template_id_random': 'RKYp2HcQm9o',
'badge_type': ticket_name, 'badge_type': ticket_name,
'badge_type_code': badge_type_code, 'badge_type_code': badge_type_code,
'agree_to_tc': agree_to_tc_val,
'allow_tracking': allow_tracking_val,
}, },
} }
@@ -740,6 +793,11 @@ async def event_id_badge_import_zoom_csv(
event_person_profile_id = event_person_result.get('event_person_profile_id') event_person_profile_id = event_person_result.get('event_person_profile_id')
log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}') log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}')
# Don't touch enable on update — a manually disabled record is effectively
# blacklisted and should survive repeated re-imports of the same file.
event_person_data.pop('enable', None)
event_person_data.get('event_badge', {}).pop('enable', None)
event_person_data.get('event_person_profile', {}).pop('enable', None)
updated_id = create_update_event_person_obj_v4( updated_id = create_update_event_person_obj_v4(
event_person_dict_obj=event_person_data, event_person_dict_obj=event_person_data,
event_person_id=event_person_id, event_person_id=event_person_id,
@@ -772,3 +830,226 @@ async def event_id_badge_import_zoom_csv(
return mk_resp(data=event_badge_person_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_li)} records.', response=commons.response) return mk_resp(data=event_badge_person_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_li)} records.', response=commons.response)
else: else:
return mk_resp(data=event_badge_person_summary_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_summary_li)} records.', response=commons.response) return mk_resp(data=event_badge_person_summary_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_summary_li)} records.', response=commons.response)
# ### BEGIN ### Splash (Cvent) XLSX Badge Import ### event_id_badge_import_splash_xlsx() ###
# Accepts a Splash (Cvent) registrant XLSX export and inserts/updates event_person records.
# Splash exports fixed columns: Full Name, Email, Time of RSVP, Status, plus custom
# fields prefixed with "Custom: ". Email is used as external_id. Full Name is split
# on the last space into given_name/family_name and also stored directly as full_name.
# Updated 2026-06-02
@router.post('/event/{event_id}/badge/import/splash_xlsx', response_model=Resp_Body_Base)
async def event_id_badge_import_splash_xlsx(
event_id: str = Path(min_length=11, max_length=22),
file: UploadFile = File(...),
begin_at: int = 0,
end_at: int = 20000,
import_status_filter: str = 'Attending', # set to '' to import all statuses
return_detail: bool = False,
commons: Common_Route_Params = Depends(common_route_params),
):
"""
Import event badges from a Splash (Cvent) registrant XLSX export.
Splash exports fixed columns (Full Name, Email, Time of RSVP, Status) plus
custom fields prefixed with "Custom: ". Email is used as external_id.
Full Name is split on the last space into given_name/family_name and also
stored directly as full_name. Pass import_status_filter='' to import all
statuses (default is 'Attending').
"""
log.setLevel(logging.INFO)
account_id = commons.x_account_id
event_id_random = event_id
if event_id := redis_lookup_id_random(record_id_random=event_id, table_name='event'): pass
else: return mk_resp(data=None, status_code=404, response=commons.response)
link_to_type = 'event'
link_to_id = event_id
file_info = await save_file(
file=file,
account_id=account_id,
link_to_type=link_to_type,
link_to_id=link_to_id,
)
if file_info['saved']:
log.info('File saved')
else:
log.error('Something may have gone wrong while saving the uploaded file?')
return mk_resp(data=None, status_code=500, response=commons.response)
hosted_files_path = settings.FILES_PATH['hosted_files_root']
subdirectory_dest = os.path.join(hosted_files_path, file_info.get('subdirectory_path'))
hash_filename = file_info.get('hash_sha256') + '.file'
full_file_path = pathlib.Path(os.path.join(subdirectory_dest, hash_filename))
if not full_file_path.exists():
log.warning(f'Not found at full file path: {full_file_path}')
return mk_resp(data=None, status_code=500, response=commons.response)
df = pandas.read_excel(full_file_path, dtype=str, na_filter=False)
df_dict = df.to_dict(orient='records')
log.info(f'Splash XLSX total record count: {len(df_dict)}')
loop_count = 0
skipped_count = 0
event_badge_person_li = []
event_badge_person_summary_li = []
log.setLevel(logging.DEBUG)
for record in df_dict:
log.info(f'Loop Count: {loop_count}')
loop_count += 1
if loop_count <= begin_at: continue
if loop_count > end_at: break
# Status filter — skip rows that don't match when a filter is set.
if import_status_filter:
status = str(record.get('Status', '')).strip()
if status != import_status_filter:
log.info(f'Skipping row with status "{status}" (filter: "{import_status_filter}")')
skipped_count += 1
continue
email = str(record.get('Email', '')).strip()
if not email:
log.warning('Row missing Email — skipping.')
skipped_count += 1
continue
external_id = email
full_name = str(record.get('Full Name', '')).strip()
given_name, family_name = _split_full_name(full_name)
professional_title = str(record.get('Custom: Job Title', '')).strip()
organization = str(record.get('Custom: Company Name', '')).strip()
country = str(record.get('Custom: Country', '')).strip()
state_province = str(record.get('Custom: State', '')).strip()
dietary_restrictions = str(record.get('Custom: Please note any dietary restrictions or preferences.', '')).strip()
# "Custom: Opt-In" → agree_to_tc / allow_tracking
opt_in_raw = str(record.get('Custom: Opt-In', '')).strip().lower()
if opt_in_raw in ('yes', 'y', 'true', '1', 'opt-in', 'opt_in'):
agree_to_tc_val = True
allow_tracking_val = True
elif opt_in_raw in ('no', 'n', 'false', '0', 'opt-out', 'opt_out'):
agree_to_tc_val = False
allow_tracking_val = False
else:
agree_to_tc_val = None
allow_tracking_val = None
event_person_summary = {
'event_id': event_id,
'event_id_random': event_id_random,
'external_id': external_id,
'given_name': given_name,
'family_name': family_name,
'email': email,
}
event_person_data = {
'account_id': account_id,
'event_id': event_id,
'enable': True,
'external_id': external_id,
'event_person_profile': {
'event_id': event_id,
'enable': True,
'given_name': given_name,
'family_name': family_name,
'full_name': full_name,
'email': email,
'professional_title': professional_title,
'affiliations': organization,
'country': country,
'state_province': state_province,
},
'event_badge': {
'enable': True,
'external_id': external_id,
'given_name': given_name,
'family_name': family_name,
'full_name': full_name,
'email': email,
'professional_title': professional_title,
'affiliations': organization,
'country': country,
'state_province': state_province,
'other_1': dietary_restrictions,
# TEMPORARY: Axonius DC event badge template mu_7SRuJYum (23).
'event_badge_template_id': 23,
'event_badge_template_id_random': 'mu_7SRuJYum',
'badge_type_code': 'attendee',
'agree_to_tc': agree_to_tc_val,
'allow_tracking': allow_tracking_val,
},
}
sql_select_event_person = """
SELECT id AS event_person_id, id_random AS event_person_id_random,
external_id AS event_person_external_id,
event_badge_id AS event_badge_id,
event_person_profile_id AS event_person_profile_id
FROM `event_person`
WHERE event_person.event_id = :event_id
AND event_person.external_id = :external_id
/*LIMIT 2*/;
"""
event_person_result = sql_select(sql=sql_select_event_person, data=event_person_summary)
if event_person_result:
if isinstance(event_person_result, list):
log.error(f'Found more than one Event Person with external_id={external_id}. Count: {len(event_person_result)}')
event_person_result = event_person_result[0]
event_person_id = event_person_result.get('event_person_id')
event_badge_id = event_person_result.get('event_badge_id')
event_person_profile_id = event_person_result.get('event_person_profile_id')
log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}')
# Don't touch enable on update — a manually disabled record is effectively
# blacklisted and should survive repeated re-imports of the same file.
event_person_data.pop('enable', None)
event_person_data.get('event_badge', {}).pop('enable', None)
event_person_data.get('event_person_profile', {}).pop('enable', None)
updated_id = create_update_event_person_obj_v4(
event_person_dict_obj=event_person_data,
event_person_id=event_person_id,
account_id=account_id,
event_id=event_id,
event_badge_id=event_badge_id,
event_person_profile_id=event_person_profile_id,
)
if updated_id:
log.warning(f'Event Person updated. ID: {updated_id}')
else:
log.warning(f'Event Person not updated. ID: {event_person_id}')
else:
log.info('No Event Person found. Creating new...')
result_id = create_update_event_person_obj_v4(
event_person_dict_obj=event_person_data,
account_id=account_id,
event_id=event_id,
)
if result_id:
log.warning(f'Event Person created. ID: {result_id}')
else:
log.warning('Event Person not created.')
event_badge_person_li.append(event_person_data)
event_badge_person_summary_li.append(event_person_summary)
processed = len(event_badge_person_li)
if return_detail:
return mk_resp(data=event_badge_person_li, status_message=f'Splash XLSX import complete. Processed {processed} records, skipped {skipped_count}.', response=commons.response)
else:
return mk_resp(data=event_badge_person_summary_li, status_message=f'Splash XLSX import complete. Processed {processed} records, skipped {skipped_count}.', response=commons.response)

View File

@@ -28,6 +28,21 @@ from app.models.response_models import Resp_Body_Base, mk_resp
router = APIRouter() router = APIRouter()
def _clean_datetime(value) -> str | None:
"""Normalize datetime strings from CSV imports (handles \xa0 from Excel, 12-hour format)."""
if not value:
return None
cleaned = str(value).replace('\xa0', ' ').strip()
if not cleaned:
return None
for fmt in ('%m/%d/%Y %I:%M %p', '%m/%d/%Y %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
try:
return datetime.datetime.strptime(cleaned, fmt).strftime('%Y-%m-%d %H:%M:%S')
except ValueError:
continue
return cleaned
# No longer needed? 2024-08-15 # No longer needed? 2024-08-15
# Based on the program import template the clients are given. # Based on the program import template the clients are given.
# Ideally the import file should only contain records with new External IDs. Old records will be checked and only updated if needed. # Ideally the import file should only contain records with new External IDs. Old records will be checked and only updated if needed.
@@ -332,7 +347,10 @@ router = APIRouter()
# ### BEGIN ### Event Importing ### event_importing_program_data() ### # ### BEGIN ### Event Importing ### event_importing_program_data() ###
# Based on the program import template the clients are given. # Based on the program import template the clients are given.
# Create and update locations, sessions, presentations, and presenters as needed. # Create and update locations, sessions, presentations, and presenters as needed.
# Updated 2024-03-25 # Careful with how date and time fields are combined
# This should work: =TEXT(G2,"M/D/YYYY")&" "&TEXT(H2,"H:MM AM/PM")
# Simply adding the fields (=D264+E264) sort of works. This produces non breaking spaces but clean up on import.
# Updated 2026-05-15
@router.post('/event/{event_id}/importing/program_data', response_model=Resp_Body_Base) @router.post('/event/{event_id}/importing/program_data', response_model=Resp_Body_Base)
async def event_importing_program_data( async def event_importing_program_data(
event_id: str = Path(min_length=11, max_length=22), event_id: str = Path(min_length=11, max_length=22),
@@ -656,13 +674,8 @@ async def event_importing_program_data(
if record.get('session_description'): if record.get('session_description'):
event_session_data['description'] = record.get('session_description', '').strip() event_session_data['description'] = record.get('session_description', '').strip()
event_session_data['start_datetime'] = record.get('session_start_datetime', '').strip() event_session_data['start_datetime'] = _clean_datetime(record.get('session_start_datetime'))
# event_session_start_datetime = record.get('event_session_start_date', '') + ' ' + record.get('event_session_start_time', '') event_session_data['end_datetime'] = _clean_datetime(record.get('session_end_datetime'))
# event_session_data['start_datetime'] = event_session_start_datetime
event_session_data['end_datetime'] = record.get('session_end_datetime', '').strip()
# event_session_end_datetime = record.get('event_session_end_date', '') + ' ' + record.get('event_session_end_time', '')
# event_session_data['end_datetime'] = event_session_end_datetime
event_session_data['sort'] = record.get('session_sort') event_session_data['sort'] = record.get('session_sort')
@@ -736,19 +749,11 @@ async def event_importing_program_data(
if record.get('presentation_description'): if record.get('presentation_description'):
event_presentation_data['description'] = record.get('presentation_description', '').strip() event_presentation_data['description'] = record.get('presentation_description', '').strip()
if record.get('presentation_start_datetime'): event_presentation_data['start_datetime'] = _clean_datetime(record.get('presentation_start_datetime'))
event_presentation_data['start_datetime'] = record.get('presentation_start_datetime', '').strip()
data['presentation_start_datetime'] = event_presentation_data['start_datetime'] data['presentation_start_datetime'] = event_presentation_data['start_datetime']
else:
event_presentation_data['start_datetime'] = None
data['presentation_start_datetime'] = None
if record.get('presentation_end_datetime'): event_presentation_data['end_datetime'] = _clean_datetime(record.get('presentation_end_datetime'))
event_presentation_data['end_datetime'] = record.get('presentation_end_datetime', '').strip()
data['presentation_end_datetime'] = event_presentation_data['end_datetime'] data['presentation_end_datetime'] = event_presentation_data['end_datetime']
else:
event_presentation_data['end_datetime'] = None
data['presentation_end_datetime'] = None
if record.get('presentation_abstract_code'): if record.get('presentation_abstract_code'):
event_presentation_data['abstract_code'] = record.get('presentation_abstract_code', '').strip() event_presentation_data['abstract_code'] = record.get('presentation_abstract_code', '').strip()

View File

@@ -5,7 +5,8 @@ from app.routers import (
data_store, data_store,
event_badge_importing, event_badge_importing,
event_importing, event_importing,
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, api_v3_actions_user, lookup_v3, api_v3_actions_email,
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, api_v3_actions_idaa, api_v3_actions_user, lookup_v3,
user, user,
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
) )
@@ -50,7 +51,9 @@ def setup_routers(app: FastAPI):
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_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_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(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
app.include_router(api_v3_actions_idaa.router, prefix='/v3/action/idaa', tags=['IDAA Actions (V3)'])
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)']) app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)'])
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup # app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3']) app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
@@ -63,7 +66,7 @@ def setup_routers(app: FastAPI):
# app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)]) # app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)])
# app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)]) # app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)])
# app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)]) # app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
app.include_router(util_email.router, tags=['Utility: Email']) # app.include_router(util_email.router, tags=['Utility: Email']) # LEGACY (disabled) - superseded by /v3/action/email/send
# app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3 # app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3
# app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3 # app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3
app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3']) app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3'])

View File

@@ -37,11 +37,73 @@ Finalized Jan 15, 2026, to ensure boot stability.
- **POST Based**: Complex filtering is handled via `POST /search` with a JSON body containing `and`, `or`, and `not` logic. - **POST Based**: Complex filtering is handled via `POST /search` with a JSON body containing `and`, `or`, and `not` logic.
- **Hybrid Filtering**: (Proposed) Query parameters should append simple standard filters (e.g., `?enabled=true`) to the complex body logic. - **Hybrid Filtering**: (Proposed) Query parameters should append simple standard filters (e.g., `?enabled=true`) to the complex body logic.
### Response Views (Proposed) ### Field Evolution Checklist
- Implement a `view` parameter (e.g., `?view=rich`) to allow clients to request joined data without using legacy `use_alt_tbl` flags. When a table or view gains, loses, or renames fields, keep the API contract and search registry in sync:
## 4. Stability Rules 1. Update the Pydantic model in `app/models/` first so CRUD serialization matches the new shape.
2. Update the SQL view or table projection so `GET` and `SEARCH` responses actually return the field.
3. Update `searchable_fields` in `app/object_definitions/` only for fields that should be searchable.
4. Add write-only, virtual, or view-only fields to `fields_to_exclude_from_db` when they must not be persisted.
5. Run the schema/search E2E tests that cover the object type before handing the change off.
6. **Restart the Docker API containers** (`docker compose restart ae_api`) — Python file changes inside containers are not picked up until restart.
For `archive_content`, the public field set now includes `external_id` and `code`, and future additions should follow the same order of operations.
#### Alt-view fields (fields only in `tbl_alt`)
Some objects have a richer alternate SQL view (`tbl_alt`) that adds JOINed/computed columns absent from the default view (`tbl_default`). For example, `event_session` uses `v_event_session_w_file_count` as its alt view (triggered by `?view=alt` on search, or `?inc_file_count=true` on GET).
- Fields from `tbl_alt` **must still be declared in the Pydantic model** and in `searchable_fields` — Pydantic strips undeclared fields, and the search whitelist rejects unknown field names regardless of the view.
- When adding such a field, add a comment noting which view provides it (e.g., `# from v_event_session_w_file_count`).
- Searching by an alt-view field on the default endpoint returns `400 Unknown column` — this is correct behaviour. Clients must pass `?view=alt` to use those fields in a search.
- **Known alt-view fields restored May 2026:** `event_presentation_li_qry_str`, `event_presenter_li_qry_str` (event_session); `account_name`, `account_code`, and related convenience fields (site_domain).
### Response Views (`?view=` parameter)
- The nested search router (`api_crud_v3_nested.py`) already supports `?view=<key>` to switch between registered views. `view=default` uses `tbl_default`; `view=alt` uses `tbl_alt`; additional named views can be added to the object registry as `tbl_<name>` / `mdl_<name>`.
- Flat search (`api_crud_v3.py`) does not yet support `?view=` — it always uses `tbl_default`.
## 4. V3 Dependency Injection Reference
All V3 endpoints use granular, composable `Depends()` from `app/lib_general_v3.py`:
| Dependency | Purpose |
|---|---|
| `get_account_context``AccountContext` | Resolves `account_id` with precedence: Header → Query Token → Bypass Header. Raises 403 on guest/missing context. |
| `PaginationParams` | Standardizes `limit` and `offset`. |
| `StatusFilterParams` | Handles `enabled` and `hidden` filtering. |
| `SerializationParams` | Controls Pydantic serialization (`by_alias`, `exclude_unset`). |
| `DelayParams` | Optional latency simulation (`?delay=N`) via `await asyncio.sleep()`. |
`AccountContext` also carries `administrator`, `manager`, and `super` flags, populated by a deferred DB lookup when a JWT is present. These flags control whether account isolation is bypassed for support tasks.
## 5. Security and Data Isolation
### Fail-Closed Strategy
If `account_id` or auth context is missing, the API defaults to a blocking filter (`account_id IS NULL`) — it does NOT fall back to returning all records. Never relax this.
### Multi-Tenant Isolation
- **Forced account filtering**: `apply_forced_account_filter` injects an `account_id` WHERE clause into every list/search query for non-super users.
- **Post-retrieval verification**: Single-object GET, PATCH, DELETE include a secondary ownership check (`check_account_access`). A mismatch returns 403.
- **Hierarchical verification**: Nested endpoints verify parent ownership before allowing operations on children.
- **Creation guard**: On POST, the user's `account_id` is automatically forced onto the new record.
### IDAA Privacy Baseline
No IDAA object (Events, Files, Posts, Meetings) is public by default. All routes require `x-account-id` context. The sole exception is `site_domain` (used for site bootstrapping). This is a **Sev-1 class constraint** — violating it has happened before.
### Bypass / Admin Access
- `x-no-account-id: bypass` → grants super access, resolves to `account_id=1` (One Sky IT Demo). Use only in internal/development utilities; do not expand its use.
- JWT query parameter (`?jwt=...`) is supported for download links and share URLs where custom headers cannot be provided.
## 6. FastAPI and Pydantic Gotchas
- **`response: Response` injection**: Use it as a direct type hint in function signature. `Depends(Response)` is not valid and causes router initialization failures.
- **Parameter order**: In function signatures, arguments without defaults must come before `Depends()` arguments.
- **`asyncio.sleep()` not `time.sleep()`**: Blocking the event loop in an async endpoint causes worker timeouts and `502 Bad Gateway` under load.
- **Pydantic V1 only**: Do not use V2-only features (`computed_field`, `model_validator`, etc.). The migration is a separate planned project — see strategic goals in `TODO__Agents.md`.
- **`obj_type_kv_li` in `ae_obj_types_def.py`**: Supports both modern keys (`tbl`, `mdl`) and legacy keys (`table_name`, `base_name`). Legacy V2 endpoints depend on the legacy keys — do not remove them until V1/V2 are fully retired.
## 7. Stability Rules
1. **Baby Step Testing**: Restart Docker and verify root health after *every* modular change. 1. **Baby Step Testing**: Restart Docker and verify root health after *every* modular change.
2. **Avoid Shadowing**: Never name a module part of the `app.` package the same as a common instance variable (e.g., avoid `app.middleware` package if you use `app = FastAPI()`). 2. **Avoid Shadowing**: Never name a module part of the `app.` package the same as a common instance variable (e.g., avoid `app.middleware` package if you use `app = FastAPI()`).
3. **Deferred Imports**: Use `from app.db_sql import ...` *inside* functions in library modules to prevent circular dependency traps. 3. **Deferred Imports**: Use `from app.db_sql import ...` *inside* functions in library modules to prevent circular dependency traps.
4. **Model changes require container restart**: Editing Python files on the host does not hot-reload inside Docker. Always run `docker compose restart ae_api` after model or object-definition changes, then re-run E2E tests.

View File

@@ -0,0 +1,420 @@
# Aether API — AI Agent Bootstrap / Quickstart
> **Read this first.** This doc is the fast path to being productive on this project.
> It covers the rules, patterns, and gotchas that matter most.
> Deep dives are in the linked docs at the bottom.
---
## 1. What This Project Is
**Aether** is an event management platform built by One Sky IT (Scott Idem).
This repo is the backend: **FastAPI + MariaDB**, running inside Docker.
The frontend (`aether_app_sveltekit/`) talks to this API exclusively via the V3 REST API.
There is **no standalone dev server** — the API runs only in Docker.
**Key clients:**
- **Conference organizers** — Presentation management, badges, sessions
- **Exhibitors** — Leads capture
- **IDAA** — International Doctors in Alcoholics Anonymous (strictly private medical/recovery community)
**Stack at a glance:**
| Layer | Technology |
|---|---|
| Framework | FastAPI + Pydantic V1 (upgrade deferred) |
| Database | MariaDB via SQLAlchemy 1.4 (upgrade deferred) |
| Cache | Redis |
| Auth | Custom headers: `x-aether-api-key` + `x-account-id` |
| Container | Docker / Gunicorn — source is volume-mounted (no rebuild for Python changes, but **restart required**) |
---
## 2. Critical Rules — Read Before Touching Any Code
### Privacy (Sev-1 class failures if violated)
- **IDAA content is ALWAYS private.** All routes under `/idaa/` require authentication.
A previous agent accidentally exposed IDAA bulletin board data publicly.
This is the single most serious class of mistake on this project.
When in doubt — it's private. Always verify with `Depends(get_account_context)`.
- **Journals** are private personal data. Always authenticated.
### File Safety
- **Never use `rm`** to delete files. Move to `~/tmp/gemini_trash` instead.
- **Never commit `.env`** files, API keys, or passwords of any kind.
- Third-party credentials (Novi API key, Mailman credentials) live in the **MariaDB `site.cfg_json` column**, not in `.env` or code.
### Before Every Commit
1. `python3 -m py_compile <changed_file>` — syntax check
2. Restart Docker and verify the API starts clean: `docker compose restart ae_api`
3. Check logs: `docker compose logs -f ae_api` (look for startup errors or import failures)
4. Run the relevant E2E or unit test suite
### Before Starting Any Task
- Read `documentation/TODO__Agents.md` — active tasks, known bugs, and what was recently changed and why.
- Check `tests/README.md` — which test suite covers the area you're about to touch.
### Docker Restart is Mandatory After Python Changes
The API source is volume-mounted, so file edits appear instantly inside the container — but
**Gunicorn does NOT hot-reload**. You MUST run:
```bash
docker compose restart ae_api
```
after any Python file change before testing. This is the #1 cause of "my change didn't take effect."
---
## 3. Environment & Commands Cheat Sheet
```bash
# Start full stack
cd ~/OSIT_dev/aether_container_env && docker compose up -d
# Restart API after any Python change
docker compose restart ae_api
# Follow API logs
docker compose logs -f ae_api
# Shell into the container
docker compose exec ae_api bash
# Run unit tests (from project root)
./environment/bin/python3 -m pytest tests/unit/ -v
# Run a single E2E test
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
# Check a specific Python file compiles
./environment/bin/python3 -m py_compile app/methods/my_new_methods.py
```
**Development API:** `https://dev-api.oneskyit.com`
**Dev API secret key:** `nT0jPeiCfxSifkiDZur9jA`
**Standard test Agent API key:** `PMM4n50teUCaOMMTN8qOJA`
**Dev DB via phpMyAdmin:** `http://localhost:8081`
---
## 4. V3 Action Router Pattern
When adding a new action endpoint (not CRUD), follow this structure:
### File layout
```
app/methods/my_feature_methods.py ← business logic
app/routers/api_v3_actions_my_feature.py ← route handler (thin)
```
Then register in `app/routers/registry.py`:
```python
from app.routers import api_v3_actions_my_feature
# ...
app.include_router(api_v3_actions_my_feature.router, prefix='/v3/action/my_feature', tags=['My Feature (V3 Actions)'])
```
### Standard route handler pattern
```python
from fastapi import APIRouter, Depends
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.my_feature_methods import my_business_logic
router = APIRouter()
@router.get('/my_endpoint/{obj_id}', response_model=Resp_Body_Base)
async def get_my_endpoint(
obj_id: str,
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(),
):
if delay.sleep_time_s > 0:
await asyncio.sleep(delay.sleep_time_s)
result = my_business_logic(obj_id)
status = result.get('status', 503)
if status == 200:
return mk_resp(data=result['data'])
return mk_resp(data=False, status_code=status, status_message=result.get('reason', 'Error.'))
```
### Auth dependency
`Depends(get_account_context)` is the **standard V3 gate**:
- Requires a valid `x-aether-api-key`
- Requires `x-account-id` OR a valid JWT session OR bypass mode
- Raises 403 if `auth_method == 'guest'` or no account context can be resolved
- For IDAA/private routes: this is the minimum gate. Never relax it.
---
## 5. Loading Site-Based Credentials (the `_load_idaa_cfg` Pattern)
Third-party credentials (Novi API key, Mailman credentials, etc.) are stored in MariaDB
in the `site.cfg_json` column for the relevant site record. Do NOT store them in `.env`.
The IDAA site record is:
- `id_random = '58_gJESdlUh'` (site id=17)
- Fields: `novi_api_root_url`, `novi_idaa_api_key`, `mailman_base_url`, `mailman_username`, `mailman_password`, `novi_mailman_sync`
Pattern to load them (use deferred import to avoid circular deps):
```python
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
def _load_idaa_cfg() -> dict:
from app.db_sql import load_site_obj
site = load_site_obj(IDAA_SITE_ID_RANDOM)
if not site:
raise RuntimeError('IDAA site record not found')
cfg = site.get('cfg_json') or {}
if isinstance(cfg, str):
import json
cfg = json.loads(cfg)
return cfg
```
---
## 6. Redis Cache Pattern
```python
import json
import datetime
from app.lib_redis_helpers import redis_client
_CACHE_TTL = datetime.timedelta(hours=4)
def _cache_key(uuid: str) -> str:
return f'idaa:novi_member:{uuid}'
# Reading
raw = redis_client.get(_cache_key(uuid))
if raw:
return json.loads(raw)
# Writing (only on success — never cache error states)
redis_client.setex(_cache_key(uuid), _CACHE_TTL, json.dumps(result))
```
**Key naming convention:** `{module}:{object_type}:{identifier}` — e.g. `idaa:novi_member:{uuid}`
**Never cache:**
- 404 responses — the member may have just joined; next call should hit the source
- 429 / 503 errors — transient failures should not poison future callers
**Cache key scoping:** If the underlying data source is the same regardless of caller
(e.g. Novi credentials are hardcoded to the IDAA site — same UUID always returns same data),
drop `account_id` from the key. Per-caller scoping wastes Redis space and halves hit rate.
---
## 7. The `@logger_reset` Decorator — Unit Test Gotcha
Business logic methods use `@logger_reset` from `app.lib_general`:
```python
from app.lib_general import logger_reset
@logger_reset
def my_method(arg):
...
```
**In unit tests, this decorator MUST be mocked as a passthrough.**
If `app.lib_general` is replaced with a plain `MagicMock()`, the decorator becomes a
MagicMock, which when applied to a function replaces it with `MagicMock()()` — the decorated
function is gone and every call returns garbage.
```python
# WRONG — logger_reset becomes a MagicMock and swallows the function:
sys.modules['app.lib_general'] = MagicMock()
# CORRECT — make it a passthrough decorator:
mock_lib_general = MagicMock()
mock_lib_general.logger_reset = lambda f: f
sys.modules['app.lib_general'] = mock_lib_general
```
This is the #1 cause of unit tests returning `<MagicMock name='...'>` instead of real dicts.
---
## 8. Field Evolution Checklist
When a table or view gains, loses, or renames fields — **do all of these in order**:
1. Update the Pydantic model in `app/models/`
2. Update the SQL view or table projection so GET/SEARCH return the field
3. Update `searchable_fields` in `app/object_definitions/` (only for searchable fields)
4. Add virtual/view-only fields to `fields_to_exclude_from_db` if they must not be persisted
5. Run the relevant schema/search E2E tests
6. **Restart Docker:** `docker compose restart ae_api`
#### Alt-view fields (in `tbl_alt` only)
Some objects have a richer alternate SQL view triggered by `?view=alt`. These fields
**must still be declared in the Pydantic model and `searchable_fields`** even if they only
appear in the alt view — Pydantic strips undeclared fields silently.
---
## 9. Deferred Imports in Library Modules
To avoid circular dependency traps, **never import from `app.db_sql` or other app modules
at the top level of a library module**. Use deferred imports inside functions:
```python
# WRONG — causes circular import at startup:
from app.db_sql import load_site_obj
def my_func():
return load_site_obj('abc')
# CORRECT:
def my_func():
from app.db_sql import load_site_obj
return load_site_obj('abc')
```
---
## 10. Pydantic / SQLAlchemy Version Pins — Do Not Remove
Current intentional pins:
- `pydantic==1.*`
- `SQLAlchemy==1.4.52`
A Pydantic V2 and SQLAlchemy 2.0 migration is planned but not started. Until then,
**do not upgrade these packages** — V2 touches every model definition and the migration
is a dedicated project.
---
## 11. Mistakes Agents Have Made on This Project
These are real incidents — know them before you start.
1. **IDAA data exposed publicly** — an agent removed an auth guard from the bulletin board
router. Consequence: private IDAA recovery community data was publicly accessible.
Always verify `Depends(get_account_context)` is present on every IDAA route.
2. **"My code change has no effect"** — Python file was edited but Docker was not restarted.
The API runs Gunicorn inside Docker with no hot-reload. `docker compose restart ae_api`
is required after every Python change.
3. **`@logger_reset` mock swallows functions in unit tests** — see Section 7 above.
Symptom: `assert result['status'] == 200` fails with `TypeError: 'MagicMock' is not subscriptable`.
4. **`pytest` / `pytest-asyncio` not installed after venv rebuild** — these are dev-only
dependencies not in `requirements.txt`. After any OS Python update (e.g., Arch Linux
upgrading to a new Python minor), rebuild the venv and reinstall:
```bash
./environment/bin/pip install pytest pytest-asyncio
```
5. **Global `db` connection used instead of context manager** — `lib_sql_core.py` has a
global `db = engine.connect()` that is a fragile single connection, not a pool.
For new methods, prefer `engine.connect()` as a context manager. See `TODO__Agents.md`
→ "[P3 full]" task for the planned migration.
6. **`bypass` mode hardcodes `account_id=1`** — `x-no-account-id: bypass` resolves to
`account_id=1` (One Sky IT Demo). Lookup overrides from the Demo account can leak into
bypass sessions. Do not expand bypass usage without documenting the allowlist case.
7. **Caching error states in Redis** — caching 404 or 5xx responses poisons the cache.
A member who just joined Novi would be denied for 4 hours if their 404 was cached.
Only cache verified success (200) results.
8. **Not running `docker compose restart ae_api` between model changes and E2E tests** —
the E2E suite hits the live API, which is still running the old code until restarted.
Tests will pass or fail against stale behavior and the results are meaningless.
---
## 12. Test Patterns
### Unit tests (fast, no DB/network)
```bash
./environment/bin/python3 -m pytest tests/unit/ -v
```
- Mock all DB/Redis/HTTP at the top of the file before importing the module under test
- `@logger_reset` must be mocked as a passthrough (see Section 7)
- Always run from the **project root** — scripts use `sys.path.append(os.getcwd())`
### E2E tests (live API at dev-api.oneskyit.com)
```bash
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
```
- Require the Docker stack to be running with a working DB connection
- Use standard output format: `[✅ PASS]` / `[❌ FAIL]`
- Verify IDs in responses are **strings** (not integers) — ID Vision compliance
### Which tests to run
| Change type | Required suites |
|---|---|
| Model / ID Vision changes | `test_e2e_v3_demo_parity.py`, vision parity tests |
| 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` |
| Any router or registry change | `test_e2e_v3_security_audit.py` |
| Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py` |
| IDAA Novi verify changes | `tests/unit/test_unit_idaa_novi_verify.py` |
---
## 13. Source Layout (Quick Reference)
```
app/
main.py — FastAPI app, lifespan, CORS
config.py — Pydantic Settings (from .env via Docker)
routers/
registry.py — All router registrations live here
api_crud_v3.py — Generic V3 CRUD (flat)
api_crud_v3_nested.py — Generic V3 CRUD (nested/parent-owned)
api_v3_actions_*.py — Action endpoints (one file per domain)
dependencies_v3.py — Shared FastAPI Depends() helpers
models/ — Pydantic V1 models (one file per domain)
object_definitions/ — Per-object searchable_fields, field metadata
ae_obj_types_def.py — Object type registry (all Aether types defined here)
methods/ — Business logic (one file per feature/domain)
lib_api_crud_v3.py — Generic CRUD handler (all object types share this)
lib_schema_v3.py — Dynamic schema/field resolution per object type
lib_general_v3.py — AccountContext, get_account_context, DelayParams
lib_general.py — logger_reset and general utilities
db_sql.py — Import facade (always import from here)
lib_sql_core.py — SQLAlchemy engine, global db connection (Source of Truth)
lib_sql_crud.py — sql_insert, sql_select, sql_update, etc.
lib_redis_helpers.py — redis_client global instance
tests/
unit/ — Isolated logic tests (mock everything, no DB)
integration/ — Requires local MariaDB/Redis
e2e/ — Network tests against live dev API
tools/ — Admin utilities (stress test, registry generator)
mock_config_helper.py — Mock app.config.settings — use in all unit tests
README.md — Which tests cover what; when to run them
documentation/
TODO__Agents.md — Active tasks + session notes ← always read first
ARCH__V3_DEVELOPMENT_STANDARDS.md — Master V3 standards doc
ARCH__V3_CORE.md — Module architecture (lifespan, DB layers, logging)
GUIDE__AE_API_V3_for_Frontend.md — Frontend integration guide (keep current)
GUIDE__DEVELOPMENT.md — Commit SOP, verification checklist
```
---
## 14. Reading Order for Deeper Dives
| What you need | Read |
|---|---|
| Active tasks + known bugs | `documentation/TODO__Agents.md` ← always first |
| V3 standards and strategy | `documentation/ARCH__V3_DEVELOPMENT_STANDARDS.md` |
| Module architecture | `documentation/ARCH__V3_CORE.md` |
| Frontend API integration guide | `documentation/GUIDE__AE_API_V3_for_Frontend.md` |
| WebSocket integration | `documentation/GUIDE__AE_API_V3_for_Frontend_websockets.md` |
| Commit / verification SOP | `documentation/GUIDE__DEVELOPMENT.md` |
| Which tests to run | `tests/README.md` |
| Object type registry | `app/ae_obj_types_def.py` |
| Shared agent docs | `~/agents_sync/aether/docs/UNIFIED_AGENT_ARCH.md` |

View File

@@ -19,8 +19,15 @@ Required for any non-public data (Journals, Badges, Users, etc.).
* **Header:** `x-account-id: <account_id>` * **Header:** `x-account-id: <account_id>`
2. **Administrative Bypass**: For authorized scripts needing global access. 2. **Administrative Bypass**: For authorized scripts needing global access.
* **Header:** `x-no-account-id: bypass` * **Header:** `x-no-account-id: bypass`
* **Scope:** Narrow escape hatch only. Keep it limited to allowlisted bootstrap/public/global-default paths and prefer `x-account-id` or JWT-backed requests everywhere else.
3. **Token Access**: Provide a **JWT** in the query string. 3. **Token Access**: Provide a **JWT** in the query string.
* **Query Param:** `?jwt=<token>` * **Query Param:** `?jwt=<token>`
4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
* `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
* Only explicit `x-no-account-id: bypass` should strip account context.
> [!NOTE]
> The `x-no-account-id` path should continue to shrink over time. If you need a new use, document why `x-account-id` or JWT cannot cover it and mark the use as temporary unless it is a hard bootstrap/global-default requirement.
> [!CAUTION] > [!CAUTION]
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data. > **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
@@ -99,6 +106,22 @@ Modify data in the system.
* **Header:** `x-ae-ignore-extra-fields: true` * **Header:** `x-ae-ignore-extra-fields: true`
* **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database. * **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database.
#### `*_json` field serialization — do NOT pre-stringify in route/component code
The frontend API wrappers (`src/lib/ae_api/api_post__crud_obj.ts` for V3, `src/lib/api/api.ts` for legacy CRUD) automatically serialize any field whose name ends in `_json` (e.g. `cfg_json`, `data_json`) before sending. They pretty-print with 2-space indent via an internal `serialize_json_field_pretty()` helper.
**Pass `*_json` fields as plain JS objects from routes and components.** The serialization layer handles the rest.
```ts
// ✅ Correct — pass as plain object; V3 wrapper serializes it
await update_ae_obj__site({ site_id, data_kv: { cfg_json: { jitsi_token_endpoint: url } } });
// ❌ Wrong — double-encodes the JSON string (the wrapper would stringify an already-stringified value)
await update_ae_obj__site({ site_id, data_kv: { cfg_json: JSON.stringify({ jitsi_token_endpoint: url }) } });
```
The V3 wrapper (`api_post__crud_obj.ts`) only serializes when `typeof value === 'object'`, so it will not double-encode a plain string. The legacy wrapper (`api.ts`) stringifies unconditionally, so pre-stringifying there **will** produce double-encoded JSON. In both cases, the right answer is to pass the raw object and let the layer handle it.
### D. ID Fields in Responses (Vision ID Convention) ### D. ID Fields in Responses (Vision ID Convention)
> [!IMPORTANT] > [!IMPORTANT]
@@ -293,6 +316,108 @@ Frontend guidance:
--- ---
## 8. Email Send Action
Send a transactional email via the Aether API.
- **Method:** `POST`
- **Path:** `/v3/action/email/send`
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)
**Request body:**
```json
{
"from_email": "noreply@example.com",
"from_name": "Example App",
"to_email": "user@example.com",
"to_name": "Alice Smith",
"subject": "Your login link",
"body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
"body_text": "Visit ... to log in.",
"cc_email": null,
"bcc_email": null
}
```
**Query params:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `test` | bool | `false` | Simulate send without delivering |
**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.
> **Replaces:** `POST /util/email/send` (disabled as of May 2026).
---
## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
- **Endpoint:** `POST /event/{event_id}/badge/import/zoom_csv`
- **Auth:** include `x-aether-api-key` (if required) and account context via `x-account-id: <ACCOUNT_ID>`. Admin bypass (`x-no-account-id: bypass`) or `?jwt=<token>` are accepted per site policy.
- **Request:** `multipart/form-data` with single file field `file` (Zoom CSV). Query params:
- `begin_at` (int, default `0`)
- `end_at` (int, default `20000`)
- `return_detail` (bool, default `false`)
- Delimiter is auto-detected; Zoom CSV layout: row 1 = metadata, row 2 = blank, row 3 = headers (the backend skips the first two rows).
Behavior / notes:
- The handler forces `Registrant email` to be used as the `external_id`. `Unique identifier` is used as `external_registration_id` only when it is meaningful (placeholders like `N/A`, `NA`, `UNKNOWN` are ignored).
- Per-ticket custom fields are parsed (Organization, Job title, Phone, Address lines, City, State/Province, Postal/Zip, Country, etc.).
- Marketing-consent values are mapped to `agree_to_tc` and `allow_tracking`.
- TEMP AXONIUS MAPPING: the import temporarily defaults `event_badge_template_id` to `21` and `event_badge_template_id_random` to `RKYp2HcQm9o`. Ticket-name → `badge_type_code` mapping is applied for some labels (e.g., contains "sponsor" → `sponsor`; contains "attend"/"attendee" → `attendee`). This mapping is temporary (April 2026) — surface this to staff.
- Rows missing `Registrant email` are skipped.
- The server upserts via existing backend methods and creates/updates `event_person`, `event_person_profile`, and `event_badge` records as needed.
Frontend guidance:
- UI must be staff-only and should validate an `event_id` is selected.
- For large files, use `begin_at`/`end_at` to process in chunks.
- Prefer `return_detail=false` for large imports to reduce payload size.
Common errors:
- `403` — missing/invalid account context or API key.
- `404` — event not found.
- `500` — file save or processing error.
Example curl (replace placeholders):
```bash
curl -v -X POST "https://api.example.com/event/<EVENT_ID>/badge/import/zoom_csv?begin_at=0&end_at=20000&return_detail=false" \
-H "x-aether-api-key: <API_KEY>" \
-H "x-account-id: <ACCOUNT_ID>" \
-F "file=@/path/to/zoom_export.csv"
```
Sample success (summary mode, `return_detail=false`):
```json
{
"data": [
{
"event_id": "xK9mP3qRtL2",
"event_id_random": "xK9mP3qRtL2",
"external_id": "alice@example.com",
"given_name": "Alice",
"family_name": "Smith",
"email": "alice@example.com"
}
],
"meta": {
"status_code": 200,
"status_name": "OK",
"success": true,
"data_type": "list",
"data_list_count": 1
}
}
```
Sample success (detailed, `return_detail=true`) — `data` contains full `event_person` objects with nested `event_badge` (may include temporary `event_badge_template_id`: `21` and `event_badge_template_id_random`: `RKYp2HcQm9o`).
Paste this section into the guide as a temporary Axonius-specific note (April 2026). Consider linking staff to a sample Zoom CSV for QA.
---
## 7. User Actions (`/v3/action/user/`) ## 7. User Actions (`/v3/action/user/`)
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`. Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
@@ -334,7 +459,7 @@ or:
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`). **Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found. **Errors:** `400` missing credentials, `403` wrong password / account disabled / account not yet enabled / account expired, `404` user not found.
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`. > **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
@@ -354,7 +479,7 @@ Check a user's current password without changing it.
``` ```
or use `"username"` instead of `"user_id"` to look up by username within the account. or use `"username"` instead of `"user_id"` to look up by username within the account.
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found. **Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
--- ---
@@ -407,10 +532,19 @@ Generate a new auth key and email a one-time login link to the user's email addr
| Parameter | Type | Default | Description | | Parameter | Type | Default | Description |
|---|---|---|---| |---|---|---|---|
| `root_url` | `string` | `null` | Base URL the login link is built from. | | `root_url` | `string` | *(required)* | Base URL the login link is built from. Must be provided — if omitted the link in the email will be malformed (`None?...`). |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. | | `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`). > [!IMPORTANT]
> `root_url` is **required in practice**. The FastAPI query param accepts `null` but the email builder does not guard against it — omitting it produces a broken link in the email.
**Magic link URL format (default `key_param_name`):**
```
{root_url}?user_id={user_id_random}&auth_key={auth_key}&valid_email=True
```
The frontend at `root_url` should read these query params and call `POST /v3/action/user/authenticate` with `{ "user_id": "...", "auth_key": "..." }`. Note that `valid_email=True` is **always** injected — authenticating via a magic link automatically marks the user's email as verified.
**Response:** `data: true` on success (email sent). `404` if user not found. `500` if delivery failed — common causes: account email not configured, user `enable = false`, or `allow_auth_key = false`.
--- ---
@@ -436,7 +570,7 @@ Results are automatically scoped to the `x-account-id` provided in the request.
--- ---
## 9. Event Exhibit Tracking Export (Leads Export) ## 10. Event Exhibit Tracking Export (Leads Export)
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file. Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
@@ -504,10 +638,67 @@ const url = URL.createObjectURL(blob);
--- ---
## 10. Troubleshooting 403 Forbidden ## 12. IDAA: Server-Side Novi Member Verification
Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP.
- **Method:** `GET`
- **Path:** `/v3/action/idaa/novi_member/{uuid}`
- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`)
### Request
| Parameter | Location | Required | Description |
|---|---|---|---|
| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) |
### Response on success (`200 OK`)
```json
{
"data": {
"verified": true,
"full_name": "Alice S.",
"email": "alice+member@idaa.org"
}
}
```
- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent.
- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`).
### Error responses
| Status | Meaning | Frontend action |
|---|---|---|
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
### Migration from direct Novi call
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
| Direct Novi result | This endpoint returns | Frontend state |
|---|---|---|
| `200` with identity data | `200` | `verified` |
| `200` with no identity data | `404` | `denied` |
| `404` | `404` | `denied` |
| `429` | `429` | `'rate_limited'` |
| Network error / Novi 5xx | `503` | `'api_error'` |
### Caching
Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt.
---
## 11. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID: If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct. 1. Verify `x-aether-api-key` is correct.
2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`. 2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
3. Verify the record actually belongs to the account ID you are sending. 3. Verify the record actually belongs to the account ID you are sending.
4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not). 4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
5. Confirm the frontend is not treating `params.key` as an implicit bypass and stripping `x-account-id`.
6. If list/search endpoints work but `GET /v3/crud/{obj_type}/{id}` still returns 403, this is likely endpoint-level policy (e.g., requires stronger auth like JWT) rather than a transport/header bug.

View File

@@ -0,0 +1,386 @@
# PROJECT: Site Passcode Security — API-Verified Auth
**Last updated:** 2026-04-10
**Status:** Backend work in progress — frontend pending backend completion
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext
---
## Problem Statement
When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.
**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
### Source of Truth
`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.
---
## Threat Model
| Threat | Current | After Fix |
|---|---|---|
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |
### The fundamental constraint
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.
---
## Proposed Solution: API-Verified Passcode + JWT Session
### Core idea
1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.
### Session restore on reload
- `access_type` still persists in localStorage (no change here)
- The JWT is the **proof** that the access was legitimately granted and is still valid
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
- If JWT expired → reset `access_type` to anonymous, clear JWT
- If JWT valid → no action needed, `access_type` is already correct
This gives session expiry without a network call on every page load.
---
## TTL Per Role — Decided
| Access Level | JWT TTL | Notes |
|---|---|---|
| `super` | 8 hours | Highest privilege |
| `manager` | 24 hours | |
| `administrator` | 48 hours | |
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
| `public` | 24 hours | |
| `authenticated` | 12 hours | |
| `anonymous` | N/A | No passcode |
---
## Caching Decision
**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50150ms), which is acceptable for a once-per-session action.
---
## Backend Changes Required
**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.
**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending.
**File:** `aether_api_fastapi/app/routers/api.py`
The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
### Fix 1: Passcode matching must use explicit priority order
**Current (wrong):**
```python
for role, code in access_codes.items(): # dict insertion order — not guaranteed
if str(code) == str(passcode):
matched_role = role
break
```
**Required:**
```python
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
matched_role = None
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
```
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
### Fix 2: JWT payload must include all six role flags
**Current (incomplete):**
```python
payload = {
'account_id': account_id_random,
'administrator': (matched_role == 'administrator'),
'manager': (matched_role == 'manager'),
'super': (matched_role == 'super'),
# trusted / public / authenticated missing
...
}
```
**Required:**
```python
payload = {
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode', # distinguishes from user login JWTs
'site_id': site_id,
'role': matched_role # canonical role string — frontend uses this
})
}
```
The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
### Fix 3: Per-role TTL
**Current:**
```python
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=3600 * 24, # hardcoded 24h for all roles
**payload
)
```
**Required:**
```python
ROLE_TTL = {
'super': 8 * 3600, # 8 hours
'manager': 24 * 3600, # 24 hours
'administrator': 48 * 3600, # 48 hours
'trusted': 48 * 3600, # 48 hours
'public': 24 * 3600, # 24 hours
'authenticated': 12 * 3600, # 12 hours
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=ROLE_TTL[matched_role],
**payload
)
```
### Fix 4: Add minimum length validation to `passcode` field
**Current:**
```python
passcode: str = Field(..., description="The passcode to verify")
```
**Required:**
```python
passcode: str = Field(..., min_length=5, description="The passcode to verify")
```
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
### Complete corrected endpoint (for reference)
```python
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
ROLE_TTL = {
'super': 8 * 3600,
'manager': 24 * 3600,
'administrator': 48 * 3600,
'trusted': 48 * 3600,
'public': 24 * 3600,
'authenticated': 12 * 3600,
}
class PasscodeAuthRequest(BaseModel):
"""Request model for site-based passcode authentication."""
site_id: str = Field(..., description="Random string ID of the site")
passcode: str = Field(..., min_length=5, description="The passcode to verify")
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode(
auth_req: PasscodeAuthRequest,
response: Response = Response,
):
"""
Passcode-to-JWT Endpoint.
Verifies a passcode against site.access_code_kv_json (single source of truth —
v_site_domain joins from the same site record).
Returns a signed JWT with the site's account context, full role flags, and
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
this token from a user login JWT.
"""
site_id = auth_req.site_id
passcode = auth_req.passcode
# 1. Look up the site record
search_data = {'id_random': site_id}
if record := sql_select(table_name='site', data=search_data):
# 2. Parse access codes
access_codes_raw = record.get('access_code_kv_json')
access_codes = {}
if access_codes_raw:
try:
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
except Exception as e:
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
# 3. Verify passcode in explicit priority order (highest privilege wins)
matched_role = None
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
if matched_role:
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
# 4. Resolve account context
account_id_random = record.get('account_id_random')
if not account_id_random:
if account_id_int := record.get('account_id'):
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
# 5. Mint JWT with complete role flags and per-role TTL
payload = {
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode',
'site_id': site_id,
'role': matched_role
})
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=ROLE_TTL[matched_role],
**payload
)
return mk_resp(
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
response=response
)
else:
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
else:
log.warning(f"Auth Failed: Site {site_id} not found.")
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
```
### Backend Phase 2 (follow-up — not blocking frontend)
**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.
---
## Frontend Changes Required
**These depend on the backend fixes above being deployed first.**
### 1a. `src/lib/app_components/e_app_access_type.svelte`
Replace `handle_check_access_type_passcode` entirely. The new version:
- Is `async`
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
- On network error: shows inline connection error
- Clears `auth_error` when `entered_passcode` changes
API call shape:
```http
POST /authenticate_passcode
Content-Type: application/json
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
```
Add to template (near the passcode input):
```svelte
{#if auth_pending}
<Loader size="1em" class="animate-spin text-gray-400" />
{/if}
{#if auth_error}
<span class="text-error-500 text-xs">{auth_error}</span>
{/if}
```
### 1b. `src/routes/+layout.ts`
**Stop caching passcodes from bootstrap** — remove line ~394:
```ts
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
```
**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
```ts
// Enforce passcode JWT TTL on page load.
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
// User login JWTs (auth_type !== 'passcode') are left untouched.
if (ae_loc_json?.jwt) {
try {
const parts = ae_loc_json.jwt.split('.');
if (parts.length === 3) {
const jwt_payload = JSON.parse(atob(parts[1]));
const json_str = typeof jwt_payload.json_str === 'string'
? JSON.parse(jwt_payload.json_str)
: jwt_payload.json_str;
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
// Passcode JWT has expired — revoke access
ae_loc_json.jwt = null;
ae_loc_json.access_type = 'anonymous';
}
}
} catch {
// Malformed JWT — leave untouched, let existing handling deal with it
}
}
```
### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)
Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).
---
## Migration Notes
- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.
---
## Files Affected
| File | Repo | Change |
| --- | --- | --- |
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |

View File

@@ -12,6 +12,16 @@
- [x] **Config Refactor:** Switch `app/config.py` to `pydantic-settings` to use direct Env Vars (Stop mounting config files). - [x] **Config Refactor:** Switch `app/config.py` to `pydantic-settings` to use direct Env Vars (Stop mounting config files).
- [x] **Locking:** Generate a `requirements.lock` for bit-identical builds. - [x] **Locking:** Generate a `requirements.lock` for bit-identical builds.
## 🔌 DB Connection Hardening (April 2026 Audit)
> Identified during pre-show review. Issues 1 and 2 likely explain observed random connection lags.
- [x] **[P1] Remove zombie `db_connection.py` import** — `app/routers/api.py` imports `db` from `app/db_connection.py`, creating a parasitic second SQLAlchemy engine at startup that is never updated by `reconnect_db()` after bootstrap. The imported `db` is only used in a commented-out line (`api.py:268`). Fix: remove the import; delete or archive `db_connection.py`.
- [x] **[P1] Fix retry mechanism in `sql_update` / `run_sql_select`** — On `OperationalError`, both call `sql_connect()``reconnect_db()` which calls `engine.dispose()`, nuking the entire connection pool mid-flight. Under concurrent requests this kills other in-flight connections. Fix: remove the `sql_connect()` retry call; SQLAlchemy's `pool_pre_ping=True` already handles stale connections — just open a fresh `engine.connect()` for the retry without disposing the pool.
- [x] **[P2] Add retry logic to `sql_insert` and `sql_select`** — Added `OperationalError` retry (single fresh connection attempt) to `sql_insert`, `sql_select`, and `sql_insert_or_update`. `IntegrityError` (duplicate key, FK violation) correctly bypasses retry and returns `None` — retrying the same data would fail again.
- [x] **[P3] Guard `db = engine.connect()` in `lib_sql_core.py` with try/except** — Wrapped in try/except; sets `db = None` on failure so Docker startup race no longer crashes the worker.
- [ ] **[P3 full]** Migrate `lib_schema_v3.py:39` and `lib_api_crud_v3.py:166` off the global `db` to `engine.connect()` context managers, then remove the global `db` entirely.
- [x] **[P4] Expose `pool_size` / `max_overflow` as env vars** — `create_ae_engine()` calls `settings.DB.get('pool_size', 10)` but `settings.DB` property doesn't include those keys, so they're always hardcoded 10/20. Add `AE_DB_POOL_SIZE` / `AE_DB_POOL_MAX_OVERFLOW` to `config.py`.
## 📋 Feature Tasks ## 📋 Feature Tasks
- [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed. - [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed.
- [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects. - [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects.
@@ -24,10 +34,48 @@
- [x] Whitelist `account_id` in all Event search definitions. - [x] Whitelist `account_id` in all Event search definitions.
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore). - [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
- [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete. - [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete.
- [x] **Restore alt-view convenience fields lost in v1→v3 migration (May 2026):** `site_domain` (`account_name`, `account_code`, `account_enable`, `account_enable_from/to`, `site_enable_from/to`, `site_domain_access_key`, `logo_path`, `style_href`, `script_src`, `google_tracking_id`) and `event_session` (`event_presentation_li_qry_str`, `event_presenter_li_qry_str`). Fields added to Pydantic models and `searchable_fields`. Alt-view fields require `?view=alt` for search.
- [ ] Verify SQL Views join in all required `_random` IDs for performance. - [ ] Verify SQL Views join in all required `_random` IDs for performance.
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token). - [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
- [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`. - [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`.
## 🔌 IDAA: Server-Side Novi Verification (Mini Project)
> **Status: P1P4 Complete (May 2026).** Endpoint live at `GET /v3/action/idaa/novi_member/{uuid}`. P5 (frontend migration) is the remaining step.
> Rationale and frontend integration notes: `aether_app_sveltekit/documentation/CLIENT__IDAA_and_customized_mods.md` → "Planned: Server-Side Novi Verification"
**Goal:** Proxy the Novi member-verification call server-to-server (FastAPI → Novi) so members' browser IPs are no longer in the call path.
- [x] **[P1] New router:** `app/routers/api_v3_actions_idaa.py`
- Route: `GET /v3/action/idaa/novi_member/{uuid}`
- Required auth: `Depends(get_account_context)` — valid API key + any account context (x-account-id, JWT, or bypass). This is the standard V3 gate.
- Reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json` via `_load_idaa_cfg()` (same as Mailman bridge)
- Calls Novi: `GET {novi_api_root_url}/customers/{uuid}` with `Authorization: Basic {api_key}`
- Normalize email: `.replace(' ', '+')` (Novi quirk — see Novi-Mailman bridge notes)
- Build display name: `"{FirstName} {LastName[0]}."` format, fall back to `Name` field
- Returns `{ "verified": true, "full_name": "...", "email": "..." }` on success
- Returns `404` if Novi 200 with no identity data (empty-member anti-pattern)
- Returns `429` if Novi rate limits; `503` if Novi unreachable or 5xx
- Business logic in `app/methods/idaa_novi_verify_methods.py`
- [x] **[P2] Redis cache:**
- Key: `idaa:novi_member:{uuid}` — TTL 4 hours
- Note: `account_id` dropped from key — Novi credentials are hardcoded to the IDAA site; same UUID always returns the same data regardless of caller, so per-caller scoping wastes Redis space and halves hit rate.
- Cache only verified (200) results — do NOT cache 404 (member may have just joined)
- Uses `redis_client` from `lib_redis_helpers.py` directly
- [x] **[P3] Register in registry:** Added to `routers/registry.py` at `/v3/action/idaa` tag `IDAA Actions (V3)`. Confirmed live — endpoint appears in `/openapi.json`.
- [x] **[P4] Tests:** `tests/unit/test_unit_idaa_novi_verify.py` — 9 tests, all passing.
- Mock Novi responses (200/empty-200/404/429/503/unreachable)
- Verify Redis cache is set on 200, hit bypasses Novi call
- Verify email normalization (space → +)
- Verify display name format (5 cases)
- [x] **[P5] Coordinate with Frontend Agent** — **Complete (2026-05-19)**
- Frontend replaced direct `fetch()` to Novi in `+layout.svelte:verify_novi_uuid()`
- Response codes mapped: 200 → verified, 404 → denied, 429 → `'rate_limited'`, 503 → auto-retried (3s, once) then `'api_error'`
- 503 auto-retry added same session to match network-error retry path
## 🛡️ Security & Privacy Baseline (IDAA) ## 🛡️ Security & Privacy Baseline (IDAA)
- **Status:** **ENFORCED**. - **Status:** **ENFORCED**.
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change. - **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.
@@ -51,6 +99,7 @@
- **Webhook approach abandoned** — cron is simpler; Novi webhook payload format is unknown and Novi hasn't been configured to send webhooks. - **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. - **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`. - [ ] **Lookup System Batch 2:** Migration of `post_topic`, `user_status`, `file_purpose`.
- [ ] **Post Topic Storage Review:** Revisit whether IDAA BB posts should keep the lookup-based `topic_id`/`topic_name` shape or eventually flatten to saved topic text if the list stays fixed.
- [ ] **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval. - [ ] **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval.
## 📝 Session Notes (March 11, 2026) ## 📝 Session Notes (March 11, 2026)

View File

@@ -7,7 +7,7 @@ This directory contains the automated and manual test scripts for the Aether Fas
- **`unit/`**: Isolated logic tests. These use heavy mocking to bypass database and network requirements. Fast and safe to run in any environment. - **`unit/`**: Isolated logic tests. These use heavy mocking to bypass database and network requirements. Fast and safe to run in any environment.
- **`integration/`**: Local environment tests. These verify component interactions, often requiring a connection to the local MariaDB/Redis instance. - **`integration/`**: Local environment tests. These verify component interactions, often requiring a connection to the local MariaDB/Redis instance.
- **`e2e/` (End-to-End)**: Network-based API tests. these use the `requests` library to call the live API endpoints at `https://dev-api.oneskyit.com`. - **`e2e/` (End-to-End)**: Network-based API tests. these use the `requests` library to call the live API endpoints at `https://dev-api.oneskyit.com`.
- **`tools/`**: Utility scripts for administrative tasks like registry generation or Docker exploration. - **`tools/`**: Utility scripts for administrative tasks like registry generation, Docker exploration, and performance stress testing.
- **`archive/`**: Legacy or deprecated scripts kept for historical reference. - **`archive/`**: Legacy or deprecated scripts kept for historical reference.
## 📜 Standardized E2E Suite (`tests/e2e/`) ## 📜 Standardized E2E Suite (`tests/e2e/`)
@@ -19,6 +19,8 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `test_e2e_v3_search_engine.py` | **Primary Search**: Basic operators, Registry fields, Nested search, and Filter bypass. | | `test_e2e_v3_search_engine.py` | **Primary Search**: Basic operators, Registry fields, Nested search, and Filter bypass. |
| `test_e2e_v3_security_audit.py` | **Core Security**: Verifies multi-tenant isolation, cross-account write blocking, and ID Vision compliance. | | `test_e2e_v3_security_audit.py` | **Core Security**: Verifies multi-tenant isolation, cross-account write blocking, and ID Vision compliance. |
| `test_e2e_v3_auth_security.py` | **Primary Auth**: Site bootstrap, Passcode-to-JWT, and permission boundaries. | | `test_e2e_v3_auth_security.py` | **Primary Auth**: Site bootstrap, Passcode-to-JWT, and permission boundaries. |
| `test_e2e_v3_user_action_routes.py` | **V3 User Actions**: Sign-in (username+password and auth-key flow), verify password, change password, new auth key, email magic link, and auth guards. |
| `test_e2e_v3_user_auth_routes.py` | **Legacy User Routes**: Tests the pre-V3 `/user/*` endpoints (change_password, new_auth_key, verify_password, lookup, email_auth_key_url, authenticate). |
| `test_e2e_v3_actions_file_lifecycle.py` | **Primary Actions**: Upload, Download (ID/Hash/Streaming), and physical Deletion. | | `test_e2e_v3_actions_file_lifecycle.py` | **Primary Actions**: Upload, Download (ID/Hash/Streaming), and physical Deletion. |
| `test_e2e_v3_data_store_lookup.py` | **V3 Parity**: Verifies code-based lookups and latency simulation. | | `test_e2e_v3_data_store_lookup.py` | **V3 Parity**: Verifies code-based lookups and latency simulation. |
| `test_e2e_redis_extensive.py` | **Redis Stress**: Benchmarks bidirectional ID caching across thousands of records. | | `test_e2e_redis_extensive.py` | **Redis Stress**: Benchmarks bidirectional ID caching across thousands of records. |
@@ -31,6 +33,7 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `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.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_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_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_action_idaa_novi_verify.py` | **IDAA Novi Member Verify**: Auth guard, 200 verified, 404 not-found, 429 rate-limit, 503 unreachable, Redis cache hit, email normalization. (not yet written — add when endpoint is stable) |
| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. | | `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_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. |
| `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. | | `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. |
@@ -38,6 +41,28 @@ These consolidated scripts are the primary verification tool for the V3 API.
--- ---
## 🔧 Tools (`tests/tools/`)
| Script | Description |
| :--- | :--- |
| `stress_list_queries.py` | **Read-only concurrency stress test.** Fires N worker threads making R sequential requests across all V3 list endpoints. Reports per-endpoint p50/p95/max latency and error counts. CLI: `--workers` (default 10), `--requests` (default 5), `--limit` (default 20), `--base-url` (default dev API). Exit code 1 on any error. |
| `tool_generate_registry.py` | Generates the object type registry from source definitions. |
| `tool_mcp_docker_explorer.py` | Explores running Docker containers via the MCP bridge. |
**Stress test quick reference:**
```bash
# Baseline (10 workers, 5 rounds, 400 total requests)
./environment/bin/python3 tests/tools/stress_list_queries.py
# Heavy load (35 workers, 5 rounds, 1400 total requests)
./environment/bin/python3 tests/tools/stress_list_queries.py --workers 35 --requests 5
# Target a different environment
./environment/bin/python3 tests/tools/stress_list_queries.py --base-url https://api.oneskyit.com --workers 5
```
---
## 🛠️ Shared Helpers ## 🛠️ Shared Helpers
- **`mock_config_helper.py`**: A critical utility that mocks `app.config.settings` before other modules are imported. Use this in unit tests. - **`mock_config_helper.py`**: A critical utility that mocks `app.config.settings` before other modules are imported. Use this in unit tests.
@@ -54,8 +79,10 @@ Tests exist to be used — run the relevant suite whenever you touch backend cod
| Nested router (`api_crud_v3_nested.py`) changes | `test_e2e_v3_demo_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` | | 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` | | Auth / account context changes | `test_e2e_v3_security_audit.py`, `test_e2e_v3_auth_security.py` |
| User action route changes (sign-in, password, magic link) | `test_e2e_v3_user_action_routes.py` |
| File upload / download changes | `test_e2e_v3_actions_file_lifecycle.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` | | Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` |
| IDAA Novi member verify changes | `tests/unit/test_unit_idaa_novi_verify.py`, `test_e2e_v3_action_idaa_novi_verify.py` (e2e pending) |
| Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.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 | | Any backend change before frontend hand-off | All of the above |
@@ -86,6 +113,16 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py ./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
``` ```
### Running unit tests with pytest
```bash
./environment/bin/python3 -m pytest tests/unit/ -v
```
`pytest` and `pytest-asyncio` are dev-only dependencies (not in `requirements.txt`). After rebuilding the venv (e.g. following an OS Python update), reinstall them:
```bash
./environment/bin/pip install pytest pytest-asyncio
```
### Path Requirements ### 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.

View File

@@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs # journal account: nqOzejLCDXM | event account: GpLf_bnywCs
JOURNAL_PARENT_ID = "OGQK-02-04-94" JOURNAL_PARENT_ID = "OGQK-02-04-94"
EVENT_PARENT_ID = "vfzVJF0LH1O" EVENT_PARENT_ID = "vfzVJF0LH1O"
# event_person: ffkKxiHpOEC (16603) "Scott Idem" under Demo event
EVENT_PERSON_PARENT_ID = "ffkKxiHpOEC"
# event_badge_template: jgfixEpYp1B (18) "Dev Demo 202x"
EVENT_BADGE_TEMPLATE_ID = "jgfixEpYp1B"
# Test Targets: (Object Type, Valid ID Random) # Test Targets: (Object Type, Valid ID Random)
# Note: These IDs are extracted from real active records. # Note: These IDs are extracted from real active records.
@@ -127,6 +131,75 @@ def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
return True return True
def test_nested_create_secondary_fk(parent_type, parent_id, child_type, payload, required_fk_fields):
"""
Regression test for secondary FK resolution in nested POST create.
Bug: sanitize_payload ran BEFORE model instantiation in the nested POST handler.
For FKs other than the parent FK (e.g. event_badge_template_id on event_badge),
sanitize_payload resolved the string → integer, then the model's root_validator
stripped the integer back to None (Vision ID anti-leakage guard). The parent FK
survived only because it was explicitly re-injected; secondary FKs were silently lost.
Fix (api_crud_v3_nested.py): moved sanitize_payload to run on data_to_insert AFTER
model serialization, matching the flat V3 POST pattern.
Verifies:
1. POST returns 200.
2. Each field in required_fk_fields is present AND non-None in the response.
3. All *_id fields are strings (Vision Standard).
4. Cleanup: DELETE the created record.
"""
label = f"Nested Secondary FK ({parent_type}/{child_type})"
print(f"\n--- Regression: {label} ---")
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
headers = get_headers()
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}")
# Check required secondary FK fields are present and non-None
for field in required_fk_fields:
val = data.get(field)
if val is None:
print(f" ❌ [FAIL] Secondary FK '{field}' is None — was not saved to DB.")
# Still attempt cleanup
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
return False
if not isinstance(val, str):
print(f" ❌ [FAIL] Secondary FK '{field}' is {type(val).__name__} ({val}) — must be string (Vision Standard).")
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
return False
print(f" ✅ [PASS] Secondary FK '{field}' = {val}")
# Vision compliance: 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):
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
return False
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
# Cleanup
del_resp = requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", 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(): def test_nested_alias_resolution():
""" """
Verifies that the 'entry' alias and nested resolution works for journals. Verifies that the 'entry' alias and nested resolution works for journals.
@@ -171,6 +244,21 @@ if __name__ == "__main__":
child_type='event_session', child_type='event_session',
payload={'name': '[e2e-test] nested create regression', 'enable': False}, payload={'name': '[e2e-test] nested create regression', 'enable': False},
)) ))
# Secondary FK regression: event_badge_template_id must survive nested POST
# (was silently dropped as NULL before the sanitize_payload order fix)
results.append(test_nested_create_secondary_fk(
parent_type='event_person',
parent_id=EVENT_PERSON_PARENT_ID,
child_type='event_badge',
payload={
'event_badge_template_id': EVENT_BADGE_TEMPLATE_ID,
'given_name': '[e2e-test]',
'family_name': 'secondary-fk-regression',
'enable': False,
'hide': True,
},
required_fk_fields=['event_badge_template_id'],
))
elapsed = time.time() - suite_start elapsed = time.time() - suite_start
if all(results): if all(results):

View File

@@ -64,6 +64,60 @@ def test_extra_filters():
resp = requests.get(f"{API_BASE}/user/?enabled=all&hidden=all", headers=get_headers()) resp = requests.get(f"{API_BASE}/user/?enabled=all&hidden=all", headers=get_headers())
print_result("Bypass Filters (enabled=all)", resp.status_code == 200) print_result("Bypass Filters (enabled=all)", resp.status_code == 200)
def test_event_session_qry_str_fields():
"""
Regression test for event_presentation_li_qry_str and event_presenter_li_qry_str.
These fields were lost during the v1/v2 -> v3 migration and restored May 2026.
They live in v_event_session_w_file_count (triggered by ?inc_file_count=true).
Demo session: DOW3h7v6H42 "How To Do Things" under Demo event pjrcghqwert
"""
print("\n--- Testing event_session qry_str fields (regression: May 2026) ---")
EVENT_ID = "pjrcghqwert"
SESSION_ID = "DOW3h7v6H42"
headers = {
"Content-Type": "application/json",
"X-Aether-API-Key": API_KEY,
"x-no-account-id": "bypass"
}
# 1. Verify fields are returned in the GET response when inc_file_count=true
url = f"{API_BASE}/event_session/{SESSION_ID}?inc_file_count=true"
resp = requests.get(url, headers=headers)
ok = resp.status_code == 200
print_result("GET event_session with inc_file_count", ok, f"(status={resp.status_code})")
if ok:
data = resp.json().get("data", {})
has_pres = "event_presentation_li_qry_str" in data
has_presenter = "event_presenter_li_qry_str" in data
print_result("Field present: event_presentation_li_qry_str", has_pres,
f"(value={data.get('event_presentation_li_qry_str')!r})")
print_result("Field present: event_presenter_li_qry_str", has_presenter,
f"(value={data.get('event_presenter_li_qry_str')!r})")
# 2. Verify searching by event_presentation_li_qry_str via ?view=alt (v_event_session_w_file_count)
# These fields only exist in the alt view, so ?view=alt is required.
search_url = f"{API_BASE}/event/{EVENT_ID}/event_session/search?view=alt"
query = {"and": [{"field": "event_presentation_li_qry_str", "op": "like", "value": "%"}]}
resp = requests.post(search_url, headers=headers, json=query)
print_result("Search by event_presentation_li_qry_str (?view=alt)", resp.status_code == 200,
f"(status={resp.status_code})")
# 3. Verify searching by event_presenter_li_qry_str via ?view=alt
query = {"and": [{"field": "event_presenter_li_qry_str", "op": "like", "value": "%"}]}
resp = requests.post(search_url, headers=headers, json=query)
print_result("Search by event_presenter_li_qry_str (?view=alt)", resp.status_code == 200,
f"(status={resp.status_code})")
# 4. Confirm search on default view still rejects these fields (expected 400 — not in v_event_session)
search_url_default = f"{API_BASE}/event/{EVENT_ID}/event_session/search"
query = {"and": [{"field": "event_presentation_li_qry_str", "op": "like", "value": "%"}]}
resp = requests.post(search_url_default, headers=headers, json=query)
print_result("Search on default view correctly rejects qry_str field (expect 400)", resp.status_code == 400,
f"(status={resp.status_code})")
if __name__ == "__main__": if __name__ == "__main__":
print(f"Starting Consolidated Search Engine E2E Suite") print(f"Starting Consolidated Search Engine E2E Suite")
print(f"Target: {API_BASE}") print(f"Target: {API_BASE}")
@@ -74,6 +128,7 @@ if __name__ == "__main__":
test_registry_fields() test_registry_fields()
test_nested_search() test_nested_search()
test_extra_filters() test_extra_filters()
test_event_session_qry_str_fields()
except Exception as e: except Exception as e:
print(f"💥 Suite Error: {e}") print(f"💥 Suite Error: {e}")

View File

@@ -0,0 +1,152 @@
"""
Read-only concurrent stress test against V3 list endpoints.
Fires N workers each making R sequential requests across a set of
list endpoints, then prints per-endpoint latency stats and an
overall error summary.
Usage (from project root):
./environment/bin/python3 tests/tools/stress_list_queries.py
./environment/bin/python3 tests/tools/stress_list_queries.py --workers 20 --requests 10
./environment/bin/python3 tests/tools/stress_list_queries.py --base-url https://api.oneskyit.com --workers 5
"""
import argparse
import math
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
DEFAULT_BASE_URL = "https://test-api.oneskyit.com"
API_KEY = "nT0jPeiCfxSifkiDZur9jA"
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo
HEADERS = {
"x-aether-api-key": API_KEY,
"x-account-id": ACCOUNT_ID,
}
# Read-only list endpoints to hammer. Each is a (label, path) tuple.
ENDPOINTS = [
("event list", "/v3/crud/event/"),
("event_session list", "/v3/crud/event_session/"),
("event_badge list", "/v3/crud/event_badge/"),
("event_file list", "/v3/crud/event_file/"),
("person list", "/v3/crud/person/"),
("journal list", "/v3/crud/journal/"),
("hosted_file list", "/v3/crud/hosted_file/"),
("data_store list", "/v3/crud/data_store/"),
]
def percentile(sorted_times: list[float], pct: float) -> float:
"""Return the pct-th percentile of a pre-sorted list (0100)."""
if not sorted_times:
return 0.0
k = (len(sorted_times) - 1) * pct / 100
lo, hi = int(math.floor(k)), int(math.ceil(k))
return sorted_times[lo] + (sorted_times[hi] - sorted_times[lo]) * (k - lo)
def do_request(label: str, url: str, session: requests.Session) -> dict:
t0 = time.perf_counter()
try:
r = session.get(url, headers=HEADERS, timeout=15)
elapsed = (time.perf_counter() - t0) * 1000
return {"label": label, "status": r.status_code, "ms": elapsed, "error": None}
except Exception as e:
elapsed = (time.perf_counter() - t0) * 1000
return {"label": label, "status": 0, "ms": elapsed, "error": str(e)}
def worker(worker_id: int, requests_per_worker: int, base_url: str, limit: int) -> list[dict]:
results = []
with requests.Session() as session:
for _ in range(requests_per_worker):
for label, path in ENDPOINTS:
url = f"{base_url}{path}?limit={limit}"
results.append(do_request(label, url, session))
return results
def print_result(label, success, message=""):
icon = "" if success else ""
suffix = f"{message}" if message else ""
print(f" [{icon}] {label}{suffix}")
def main():
parser = argparse.ArgumentParser(description="Concurrent read-only stress test")
parser.add_argument("--workers", type=int, default=10, help="Concurrent worker threads (default: 10)")
parser.add_argument("--requests", type=int, default=5, help="Requests per worker per endpoint (default: 5)")
parser.add_argument("--limit", type=int, default=20, help="?limit= param on each list request (default: 20)")
parser.add_argument("--base-url", type=str, default=DEFAULT_BASE_URL, help=f"API base URL (default: {DEFAULT_BASE_URL})")
args = parser.parse_args()
total_requests = args.workers * args.requests * len(ENDPOINTS)
print(f"\n🔥 Stress Test: {args.workers} workers × {args.requests} rounds × {len(ENDPOINTS)} endpoints = {total_requests} total requests")
print(f" Target: {args.base_url} limit={args.limit}\n")
all_results: list[dict] = []
suite_start = time.perf_counter()
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(worker, wid, args.requests, args.base_url, args.limit) for wid in range(args.workers)]
for f in as_completed(futures):
all_results.extend(f.result())
suite_elapsed = time.perf_counter() - suite_start
# --- Per-endpoint stats ---
print("" * 60)
print(f"{'Endpoint':<35} {'OK':>5} {'ERR':>5} {'p50ms':>7} {'p95ms':>7} {'maxms':>7}")
print("" * 60)
by_label: dict[str, list[dict]] = {}
for r in all_results:
by_label.setdefault(r["label"], []).append(r)
any_fail = False
for label, _ in ENDPOINTS:
rows = by_label.get(label, [])
ok = [r for r in rows if r["status"] in (200, 201, 404) and not r["error"]]
err = [r for r in rows if r not in ok]
times = sorted(r["ms"] for r in ok)
p50 = statistics.median(times) if times else 0
p95 = percentile(times, 95)
mx = max(times) if times else 0
flag = "" if not err else ""
if err:
any_fail = True
print(f" {label:<33} {len(ok):>5} {len(err):>5} {p50:>7.0f} {p95:>7.0f} {mx:>7.0f}{flag}")
print("" * 60)
# --- Error detail ---
errors = [r for r in all_results if r["error"] or r["status"] not in (200, 201, 404)]
if errors:
print(f"\n{len(errors)} errors encountered:")
seen = set()
for r in errors:
key = (r["label"], r["status"], r["error"])
if key not in seen:
seen.add(key)
print(f" [{r['status']}] {r['label']}: {r['error'] or 'non-2xx/404'}")
else:
print("\n✅ Zero errors.")
# --- Overall summary ---
all_times = sorted(r["ms"] for r in all_results if not r["error"])
rps = total_requests / suite_elapsed
print(f"\n🏁 {total_requests} requests in {suite_elapsed:.2f}s ({rps:.1f} req/s)")
if all_times:
print(f" p50={statistics.median(all_times):.0f}ms "
f"p95={percentile(all_times, 95):.0f}ms "
f"max={max(all_times):.0f}ms\n")
sys.exit(1 if any_fail else 0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,219 @@
import sys
import os
import json
from unittest.mock import MagicMock, patch
# Add project root to path
sys.path.append(os.getcwd())
# Mock low-level deps BEFORE importing the target module.
# logger_reset must be a passthrough — if it stays a MagicMock the decorator
# replaces the decorated function with a MagicMock and tests get garbage results.
mock_lib_general = MagicMock()
mock_lib_general.logger_reset = lambda f: f
sys.modules['app.config'] = MagicMock()
sys.modules['app.lib_general'] = mock_lib_general
sys.modules['app.db_sql'] = MagicMock()
sys.modules['app.lib_redis_helpers'] = MagicMock()
from app.methods import idaa_novi_verify_methods as m
# ── Helpers ───────────────────────────────────────────────────────────────
def _make_cfg():
return {
'novi_api_root_url': 'https://www.idaa.org/api',
'novi_idaa_api_key': 'dGVzdGtleQ==',
}
def _novi_resp(email='alice@idaa.org', first='Alice', last='Smith', name=None):
d = {'Email': email, 'FirstName': first, 'LastName': last}
if name is not None:
d['Name'] = name
return d
def _set_redis(cached_value=None):
"""Set redis_client on the already-imported module's imported name."""
r = MagicMock()
r.get.return_value = cached_value
sys.modules['app.lib_redis_helpers'].redis_client = r
return r
# ── Cache hit bypasses Novi ───────────────────────────────────────────────
def test_cache_hit_bypasses_novi():
print('--- test_cache_hit_bypasses_novi ---')
cached = json.dumps({'status': 200, 'verified': True, 'full_name': 'Bob J.', 'email': 'bob@idaa.org'})
redis_mock = _set_redis(cached_value=cached)
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get') as mock_get:
result = m.verify_novi_member('some-uuid')
print('Result:', result)
assert result['status'] == 200
assert result['full_name'] == 'Bob J.'
mock_get.assert_not_called() # Novi was never contacted
print('PASS')
# ── Verified 200 ──────────────────────────────────────────────────────────
def test_verified_member_200():
print('--- test_verified_member_200 ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = _novi_resp()
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
print('Result:', result)
assert result['status'] == 200
assert result['verified'] is True
assert result['full_name'] == 'Alice S.'
assert result['email'] == 'alice@idaa.org'
redis_mock.setex.assert_called_once() # verified result cached
print('PASS')
# ── Email normalization: space → + ────────────────────────────────────────
def test_email_space_normalization():
print('--- test_email_space_normalization ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = _novi_resp(email='alice member@idaa.org')
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
print('Result:', result)
assert result['status'] == 200
assert result['email'] == 'alice+member@idaa.org'
print('PASS')
# ── Display name format ───────────────────────────────────────────────────
def test_display_name_format():
print('--- test_display_name_format ---')
cases = [
(_novi_resp(first='Alice', last='Smith'), 'Alice S.'),
(_novi_resp(first='Alice', last=''), 'Alice'),
(_novi_resp(first='', last='Smith', name='Dr. Alice'), 'Dr. Alice'),
(_novi_resp(first='', last='', name='Dr. Alice'), 'Dr. Alice'),
(_novi_resp(first='', last='', name=''), 'Member'),
]
for novi_data, expected_name in cases:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = novi_data
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
assert result['status'] == 200
assert result['full_name'] == expected_name, \
f"Expected '{expected_name}', got '{result['full_name']}' for input {novi_data}"
print('All display name cases PASS')
# ── Empty-member anti-pattern: Novi 200, no Email ─────────────────────────
def test_empty_member_returns_404():
print('--- test_empty_member_returns_404 ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {} # Novi 200 with no identity data
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('ghost-uuid')
print('Result:', result)
assert result['status'] == 404
redis_mock.setex.assert_not_called() # 404 must NOT be cached
print('PASS')
# ── Novi 404 ──────────────────────────────────────────────────────────────
def test_novi_404_returns_404():
print('--- test_novi_404_returns_404 ---')
mock_resp = MagicMock()
mock_resp.status_code = 404
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('missing-uuid')
print('Result:', result)
assert result['status'] == 404
redis_mock.setex.assert_not_called()
print('PASS')
# ── Novi 429 ──────────────────────────────────────────────────────────────
def test_novi_429_returns_429():
print('--- test_novi_429_returns_429 ---')
mock_resp = MagicMock()
mock_resp.status_code = 429
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 429
redis_mock.setex.assert_not_called()
print('PASS')
# ── Novi 5xx → 503 ────────────────────────────────────────────────────────
def test_novi_5xx_returns_503():
print('--- test_novi_5xx_returns_503 ---')
mock_resp = MagicMock()
mock_resp.status_code = 502
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 503
print('PASS')
# ── Novi unreachable → 503 ────────────────────────────────────────────────
def test_novi_unreachable_returns_503():
print('--- test_novi_unreachable_returns_503 ---')
import requests as req_lib
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', side_effect=req_lib.exceptions.ConnectionError('refused')):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 503
print('PASS')