22 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
30 changed files with 1797 additions and 77 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.

View File

@@ -25,8 +25,10 @@ class Settings(BaseSettings):
DB_PASS: str = Field('', env='AE_DB_PASSWORD')
# Connection tuning
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE')
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
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 ---
LOG_PATH_APP: str = Field('/logs/aether_api.log', env='AE_API_LOG_PATH')
@@ -73,8 +75,10 @@ class Settings(BaseSettings):
'name': self.DB_NAME,
'username': self.DB_USER,
'password': self.DB_PASS,
'connect_timeout': self.DB_CONNECT_TIMEOUT,
'pool_recycle': self.DB_POOL_RECYCLE,
'connect_timeout': self.DB_CONNECT_TIMEOUT,
'pool_recycle': self.DB_POOL_RECYCLE,
'pool_size': self.DB_POOL_SIZE,
'max_overflow': self.DB_POOL_MAX_OVERFLOW,
}
@property

View File

@@ -43,9 +43,15 @@ def create_ae_engine(uri: str):
engine = create_ae_engine(db_uri)
# DEPRECATED: Global shared 'db' connection. Use engine.connect() in context managers instead.
# Keeping for legacy compatibility but will phase out usage in crud lib.
db = engine.connect()
# DEPRECATED: Global shared 'db' connection. Still used by lib_schema_v3.py and lib_api_crud_v3.py.
# 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()
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...')

View File

@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from app.log import log, logger_reset
# CRITICAL: Import the core module to access current global state
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
@@ -63,11 +63,29 @@ def sql_insert(
return result_insert.lastrowid
return False
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()
log.error('Integrity error (likely duplicate). Returning None')
log.debug(e)
set_last_sql_error(e)
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:
if trans: trans.rollback()
log.error('Unknown exception in sql_insert. Returning False')
@@ -138,7 +156,6 @@ def sql_update(
except OperationalError:
if trans: trans.rollback()
log.error('Operational error (gone away?). Retrying once...')
sql_connect()
try:
with lib_sql_core.engine.connect() as conn:
trans = conn.begin()
@@ -199,6 +216,19 @@ def sql_insert_or_update(
res = conn.execute(stmt, data)
trans.commit()
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:
if trans: trans.rollback()
log.exception(e)
@@ -309,6 +339,21 @@ def sql_select(
return [] if as_list else None
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:
log.error(f"SQL Fetch Error: {e}")
set_last_sql_error(e)
@@ -343,7 +388,6 @@ def run_sql_select(
return conn.execute(sql, data)
except (OperationalError, ProgrammingError) as e:
log.error(f'DB Error: {e}. Retrying once...')
sql_connect()
try:
with lib_sql_core.engine.connect() as conn:
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: Optional[str]
external_id: Optional[str]
code: Optional[str]
name: Optional[str]
description: Optional[str]

View File

@@ -139,6 +139,8 @@ class Event_Session_Base(BaseModel):
created_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
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
# 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]
# topic_id_random: Optional[str]
# topic_id: Optional[int]
topic_id: Optional[int]
type: Optional[str]
@@ -101,18 +101,18 @@ class Post_Base(BaseModel):
if rid and isinstance(rid, str):
values['id'] = rid
values['post_id'] = rid
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
# 2. Prevent "Collision Population" or leakage of integers during API responses
for k in ['id', 'post_id', 'account_id', 'person_id', 'user_id']:
val = values.get(k)
if val is not None and not isinstance(val, str):
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
del values[k]
return values
class Config:

View File

@@ -42,6 +42,23 @@ class Site_Domain_Base(BaseModel):
created_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)
@root_validator(pre=True)
@@ -55,18 +72,18 @@ class Site_Domain_Base(BaseModel):
if rid := values.get('id_random') or values.get('site_domain_id_random'):
values['id'] = rid
values['site_domain_id'] = rid
if s_rid := values.get('site_id_random'):
values['site_id'] = s_rid
if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
# 2. Prevent "Collision Population"
for k in ['id', 'site_id', 'account_id']:
if k in values and not isinstance(values[k], str):
del values[k]
return values
class Config:
@@ -98,7 +115,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
enable: Optional[bool]
hide: Optional[bool] = None
notes: Optional[str] = None
created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None
@@ -133,7 +150,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
values['site_id'] = s_rid
if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
for k in ['id', 'site_id', 'account_id']:
if k in values and not isinstance(values[k], str):
del values[k]

View File

@@ -135,7 +135,8 @@ events_presentation_obj_li = {
'poc_person_full_name',
'public', 'public_hide', 'hide_event_launcher',
'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': {

View File

@@ -33,8 +33,8 @@ other_obj_li = {
],
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
'priority', 'group', 'created_on', 'updated_on'
],
},
@@ -50,8 +50,8 @@ other_obj_li = {
'base_name': Sponsorship_Cfg_Base,
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
'notes', 'created_on', 'updated_on'
],
},
@@ -86,9 +86,9 @@ other_obj_li = {
],
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
'archive_type_id_random', 'archive_type', 'name', 'description',
'filename', 'original_location', 'enable', 'hide', 'priority',
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
'archive_type_id_random', 'archive_type', 'name', 'description',
'filename', 'original_location', 'enable', 'hide', 'priority',
'sort', 'group', 'notes', 'created_on', 'updated_on'
],
},
@@ -108,7 +108,7 @@ other_obj_li = {
'searchable_fields': [
'id', 'account_id', 'archive_id', 'hosted_file_id',
'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',
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
],
@@ -137,9 +137,9 @@ other_obj_li = {
],
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
'hash_sha256', 'title', 'description', 'filename', 'extension',
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
'hash_sha256', 'title', 'description', 'filename', 'extension',
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
'notes', 'created_on', 'updated_on'
],
},
@@ -157,8 +157,8 @@ other_obj_li = {
'base_name': Hosted_File_Link_Base,
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
'created_on', 'updated_on'
],
},
@@ -226,8 +226,8 @@ other_obj_li = {
'base_name': Grant_Base,
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
'code', 'name', 'description', 'enable', 'hide', 'priority', 'sort',
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
'code', 'name', 'description', '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
import json
import time
import secrets
# import secrets
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.config import settings
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.models.api_models import Api_Base
from app.models.response_models import Resp_Body_Base, mk_resp
@@ -114,6 +114,8 @@ async def authenticate_passcode(
# --- 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)])
async def request_jwt(
x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22),
@@ -167,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)
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)])
async def get_api_temp_token(
x_aether_api_key: Optional[str] = Header(None),

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)
async def action_email_auth_key_url(
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),
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_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)
if create_event_person_obj_result := create_update_event_person_obj_v4(
event_person_dict_obj = event_person_data,
event_person_id = event_person_id,
@@ -501,6 +506,14 @@ async def event_id_badge_import(
# 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:
"""
Extracts a per-ticket-type field value from a Zoom CSV row.
@@ -780,6 +793,11 @@ async def event_id_badge_import_zoom_csv(
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,
@@ -811,4 +829,227 @@ async def event_id_badge_import_zoom_csv(
if return_detail:
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:
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()
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
# 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.
@@ -332,7 +347,10 @@ router = APIRouter()
# ### BEGIN ### Event Importing ### event_importing_program_data() ###
# Based on the program import template the clients are given.
# 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)
async def event_importing_program_data(
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'):
event_session_data['description'] = record.get('session_description', '').strip()
event_session_data['start_datetime'] = record.get('session_start_datetime', '').strip()
# event_session_start_datetime = record.get('event_session_start_date', '') + ' ' + record.get('event_session_start_time', '')
# 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['start_datetime'] = _clean_datetime(record.get('session_start_datetime'))
event_session_data['end_datetime'] = _clean_datetime(record.get('session_end_datetime'))
event_session_data['sort'] = record.get('session_sort')
@@ -736,19 +749,11 @@ async def event_importing_program_data(
if record.get('presentation_description'):
event_presentation_data['description'] = record.get('presentation_description', '').strip()
if 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']
else:
event_presentation_data['start_datetime'] = None
data['presentation_start_datetime'] = None
event_presentation_data['start_datetime'] = _clean_datetime(record.get('presentation_start_datetime'))
data['presentation_start_datetime'] = event_presentation_data['start_datetime']
if 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']
else:
event_presentation_data['end_datetime'] = None
data['presentation_end_datetime'] = None
event_presentation_data['end_datetime'] = _clean_datetime(record.get('presentation_end_datetime'))
data['presentation_end_datetime'] = event_presentation_data['end_datetime']
if record.get('presentation_abstract_code'):
event_presentation_data['abstract_code'] = record.get('presentation_abstract_code', '').strip()

View File

@@ -5,7 +5,8 @@ from app.routers import (
data_store,
event_badge_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,
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_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_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_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_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_domain.router, tags=['Site Domain'], 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_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by 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.
- **Hybrid Filtering**: (Proposed) Query parameters should append simple standard filters (e.g., `?enabled=true`) to the complex body logic.
### Response Views (Proposed)
- Implement a `view` parameter (e.g., `?view=rich`) to allow clients to request joined data without using legacy `use_alt_tbl` flags.
### Field Evolution Checklist
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.
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.
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>`
2. **Administrative Bypass**: For authorized scripts needing global access.
* **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.
* **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]
> **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`
* **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)
> [!IMPORTANT]
@@ -293,7 +316,42 @@ Frontend guidance:
---
## Axonius Zoom CSV Upload (Temporary — Apr 2026)
## 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.
@@ -401,7 +459,7 @@ or:
**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`.
@@ -421,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.
**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.
---
@@ -474,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 |
|---|---|---|---|
| `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. |
**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`.
---
@@ -503,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.
@@ -571,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:
1. Verify `x-aether-api-key` is correct.
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.
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

@@ -12,6 +12,16 @@
- [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.
## 🔌 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
- [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed.
- [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] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
- [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.
- [ ] **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`.
## 🔌 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)
- **Status:** **ENFORCED**.
- **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.
- **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`.
- [ ] **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.
## 📝 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.
- **`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`.
- **`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.
## 📜 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_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_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_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. |
@@ -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_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_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_schema.py` | Network verification of the V3 metadata discovery endpoint. |
| `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
- **`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` |
| 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` |
| 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` |
| 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` |
| 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
```
### 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
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

@@ -64,17 +64,72 @@ def test_extra_filters():
resp = requests.get(f"{API_BASE}/user/?enabled=all&hidden=all", headers=get_headers())
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__":
print(f"Starting Consolidated Search Engine E2E Suite")
print(f"Target: {API_BASE}")
start_time = time.time()
try:
test_basic_operators()
test_registry_fields()
test_nested_search()
test_extra_filters()
test_event_session_qry_str_fields()
except Exception as e:
print(f"💥 Suite Error: {e}")
print(f"\nSuite completed in {time.time() - start_time:.2f}s")

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