Compare commits
29 Commits
c837d465ca
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e5a3c3fd | ||
|
|
9962176c74 | ||
|
|
35fa5132e7 | ||
|
|
e19fd63d1f | ||
|
|
051b2fd7ac | ||
|
|
221854df90 | ||
|
|
c7335bbc3e | ||
|
|
45a5acd45d | ||
|
|
c64c3bc55a | ||
|
|
c8377a2b22 | ||
|
|
f6ba339276 | ||
|
|
ed66ba4bd4 | ||
|
|
44e4f5c4e6 | ||
|
|
c378040ad4 | ||
|
|
b590bc09a0 | ||
|
|
e71906b59a | ||
|
|
3d89e95c24 | ||
|
|
3db5f7c749 | ||
|
|
55debc8009 | ||
|
|
ace00929f2 | ||
|
|
c7444a8a89 | ||
|
|
8f1fe5d4df | ||
|
|
c0626e061e | ||
|
|
dfb5289188 | ||
|
|
0ecc5a97d5 | ||
|
|
516865b7d8 | ||
|
|
7f9666dc1e | ||
|
|
f9f588ddf2 | ||
|
|
ea25bf78d4 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...')
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
154
app/methods/idaa_novi_verify_methods.py
Normal file
154
app/methods/idaa_novi_verify_methods.py
Normal 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
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -21,10 +21,21 @@ router = APIRouter()
|
||||
|
||||
# --- Passcode Authentication ---
|
||||
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600, # 8 hours
|
||||
'manager': 24 * 3600, # 24 hours
|
||||
'administrator': 48 * 3600, # 48 hours
|
||||
'trusted': 48 * 3600, # 48 hours
|
||||
'public': 24 * 3600, # 24 hours
|
||||
'authenticated': 12 * 3600, # 12 hours
|
||||
}
|
||||
|
||||
class PasscodeAuthRequest(BaseModel):
|
||||
"""Request model for site-based passcode authentication."""
|
||||
site_id: str = Field(..., description="The random string ID of the site")
|
||||
passcode: str = Field(..., description="The passcode to verify")
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
|
||||
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||
async def authenticate_passcode(
|
||||
@@ -54,10 +65,11 @@ async def authenticate_passcode(
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||
|
||||
# 3. Verify Passcode and Resolve Role
|
||||
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||
matched_role = None
|
||||
for role, code in access_codes.items():
|
||||
if str(code) == str(passcode):
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
|
||||
@@ -70,22 +82,25 @@ async def authenticate_passcode(
|
||||
if account_id_int := record.get('account_id'):
|
||||
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||
|
||||
# 5. Mint JWT
|
||||
# 5. Mint JWT with complete role flags and per-role TTL
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'super': (matched_role == 'super'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode',
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
})
|
||||
}
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=3600 * 24, # 24 hour session
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**payload
|
||||
)
|
||||
|
||||
@@ -99,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),
|
||||
@@ -152,6 +169,7 @@ async def request_jwt(
|
||||
token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload)
|
||||
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),
|
||||
|
||||
@@ -487,9 +487,13 @@ async def post_obj(
|
||||
|
||||
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
|
||||
# to the DB without conflicting with Vision ID string constraints in the model.
|
||||
# Guard: skip if the model explicitly excludes account_id from DB writes (e.g. event_badge,
|
||||
# event_device — the column does not exist in those tables).
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
data_to_insert['account_id'] = account.account_id
|
||||
excluded = getattr(input_model, 'fields_to_exclude_from_db', [])
|
||||
if 'account_id' not in excluded:
|
||||
data_to_insert['account_id'] = account.account_id
|
||||
|
||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||
new_obj_id = sql_insert_result
|
||||
|
||||
@@ -313,15 +313,6 @@ async def post_child_obj(
|
||||
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
obj_data['account_id'] = account.account_id
|
||||
|
||||
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||
|
||||
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
||||
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
try:
|
||||
validated_obj = input_model(**obj_data)
|
||||
except ValidationError as e:
|
||||
@@ -332,8 +323,18 @@ async def post_child_obj(
|
||||
|
||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||
|
||||
# Re-inject parent FK after model serialization. Some model root_validators strip
|
||||
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict.
|
||||
# Sanitize AFTER serialization so that:
|
||||
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||
# 2. ID resolution (string → integer) happens on the dict going to the DB,
|
||||
# avoiding the root_validator's integer-stripping anti-leakage guard.
|
||||
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
|
||||
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
|
||||
# set — ensuring the child is always linked to the correct parent.
|
||||
# Note: account_id is intentionally NOT injected here. Child objects in the nested
|
||||
# endpoint inherit account context from their parent via the FK relationship; they do
|
||||
# not carry their own account_id column (e.g. event_badge, journal_entry).
|
||||
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||
|
||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||
|
||||
78
app/routers/api_v3_actions_email.py
Normal file
78
app/routers/api_v3_actions_email.py
Normal 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)
|
||||
41
app/routers/api_v3_actions_idaa.py
Normal file
41
app/routers/api_v3_actions_idaa.py
Normal 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.'))
|
||||
@@ -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),
|
||||
):
|
||||
|
||||
@@ -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.
|
||||
@@ -656,6 +669,44 @@ async def event_id_badge_import_zoom_csv(
|
||||
if badge_type_code:
|
||||
log.info(f"Axonius mapping applied: '{ticket_name}' -> '{badge_type_code}'")
|
||||
|
||||
# Parse marketing consent column (if present) and map to badge fields.
|
||||
# Expected values: "Opt-in" => agree_to_tc=True, allow_tracking=True
|
||||
# "Opt-out" => agree_to_tc=False, allow_tracking=False
|
||||
# "N/A" => None/NULL
|
||||
marketing_raw = None
|
||||
for _k in ('Agree to receive marketing communication?', 'Agree to receive marketing communication', 'Agree to TC', 'agree_to_tc'):
|
||||
if _k in record and str(record.get(_k)).strip() != '':
|
||||
marketing_raw = str(record.get(_k)).strip()
|
||||
break
|
||||
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
if marketing_raw is not None:
|
||||
m = marketing_raw.strip()
|
||||
m_low = m.lower()
|
||||
if m_low in ('n/a', 'na'):
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
elif m_low in ('opt-in', 'optin', 'opt in'):
|
||||
agree_to_tc_val = True
|
||||
allow_tracking_val = True
|
||||
elif m_low in ('opt-out', 'optout', 'opt out'):
|
||||
agree_to_tc_val = False
|
||||
allow_tracking_val = False
|
||||
else:
|
||||
if m_low in ('yes', 'y', 'true', '1'):
|
||||
agree_to_tc_val = True
|
||||
allow_tracking_val = True
|
||||
elif m_low in ('no', 'n', 'false', '0'):
|
||||
agree_to_tc_val = False
|
||||
allow_tracking_val = False
|
||||
else:
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
|
||||
# Need to deal with this special field/column for Axonius
|
||||
# "Agree to receive marketing communication?"
|
||||
|
||||
event_person_data = {
|
||||
'account_id': account_id,
|
||||
'event_id': event_id,
|
||||
@@ -712,6 +763,8 @@ async def event_id_badge_import_zoom_csv(
|
||||
'event_badge_template_id_random': 'RKYp2HcQm9o',
|
||||
'badge_type': ticket_name,
|
||||
'badge_type_code': badge_type_code,
|
||||
'agree_to_tc': agree_to_tc_val,
|
||||
'allow_tracking': allow_tracking_val,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -740,6 +793,11 @@ async def event_id_badge_import_zoom_csv(
|
||||
event_person_profile_id = event_person_result.get('event_person_profile_id')
|
||||
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,
|
||||
@@ -771,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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.
|
||||
|
||||
420
documentation/BOOTSTRAP__AI_Agent_Quickstart.md
Normal file
420
documentation/BOOTSTRAP__AI_Agent_Quickstart.md
Normal 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` |
|
||||
@@ -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,6 +316,108 @@ Frontend guidance:
|
||||
|
||||
---
|
||||
|
||||
## 8. Email Send Action
|
||||
|
||||
Send a transactional email via the Aether API.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/v3/action/email/send`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"from_email": "noreply@example.com",
|
||||
"from_name": "Example App",
|
||||
"to_email": "user@example.com",
|
||||
"to_name": "Alice Smith",
|
||||
"subject": "Your login link",
|
||||
"body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
|
||||
"body_text": "Visit ... to log in.",
|
||||
"cc_email": null,
|
||||
"bcc_email": null
|
||||
}
|
||||
```
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `test` | bool | `false` | Simulate send without delivering |
|
||||
|
||||
**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.
|
||||
|
||||
> **Replaces:** `POST /util/email/send` (disabled as of May 2026).
|
||||
|
||||
---
|
||||
|
||||
## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)
|
||||
|
||||
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
|
||||
|
||||
- **Endpoint:** `POST /event/{event_id}/badge/import/zoom_csv`
|
||||
- **Auth:** include `x-aether-api-key` (if required) and account context via `x-account-id: <ACCOUNT_ID>`. Admin bypass (`x-no-account-id: bypass`) or `?jwt=<token>` are accepted per site policy.
|
||||
- **Request:** `multipart/form-data` with single file field `file` (Zoom CSV). Query params:
|
||||
- `begin_at` (int, default `0`)
|
||||
- `end_at` (int, default `20000`)
|
||||
- `return_detail` (bool, default `false`)
|
||||
- Delimiter is auto-detected; Zoom CSV layout: row 1 = metadata, row 2 = blank, row 3 = headers (the backend skips the first two rows).
|
||||
|
||||
Behavior / notes:
|
||||
- The handler forces `Registrant email` to be used as the `external_id`. `Unique identifier` is used as `external_registration_id` only when it is meaningful (placeholders like `N/A`, `NA`, `UNKNOWN` are ignored).
|
||||
- Per-ticket custom fields are parsed (Organization, Job title, Phone, Address lines, City, State/Province, Postal/Zip, Country, etc.).
|
||||
- Marketing-consent values are mapped to `agree_to_tc` and `allow_tracking`.
|
||||
- TEMP AXONIUS MAPPING: the import temporarily defaults `event_badge_template_id` to `21` and `event_badge_template_id_random` to `RKYp2HcQm9o`. Ticket-name → `badge_type_code` mapping is applied for some labels (e.g., contains "sponsor" → `sponsor`; contains "attend"/"attendee" → `attendee`). This mapping is temporary (April 2026) — surface this to staff.
|
||||
- Rows missing `Registrant email` are skipped.
|
||||
- The server upserts via existing backend methods and creates/updates `event_person`, `event_person_profile`, and `event_badge` records as needed.
|
||||
|
||||
Frontend guidance:
|
||||
- UI must be staff-only and should validate an `event_id` is selected.
|
||||
- For large files, use `begin_at`/`end_at` to process in chunks.
|
||||
- Prefer `return_detail=false` for large imports to reduce payload size.
|
||||
|
||||
Common errors:
|
||||
- `403` — missing/invalid account context or API key.
|
||||
- `404` — event not found.
|
||||
- `500` — file save or processing error.
|
||||
|
||||
Example curl (replace placeholders):
|
||||
```bash
|
||||
curl -v -X POST "https://api.example.com/event/<EVENT_ID>/badge/import/zoom_csv?begin_at=0&end_at=20000&return_detail=false" \
|
||||
-H "x-aether-api-key: <API_KEY>" \
|
||||
-H "x-account-id: <ACCOUNT_ID>" \
|
||||
-F "file=@/path/to/zoom_export.csv"
|
||||
```
|
||||
|
||||
Sample success (summary mode, `return_detail=false`):
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"event_id": "xK9mP3qRtL2",
|
||||
"event_id_random": "xK9mP3qRtL2",
|
||||
"external_id": "alice@example.com",
|
||||
"given_name": "Alice",
|
||||
"family_name": "Smith",
|
||||
"email": "alice@example.com"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"status_code": 200,
|
||||
"status_name": "OK",
|
||||
"success": true,
|
||||
"data_type": "list",
|
||||
"data_list_count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sample success (detailed, `return_detail=true`) — `data` contains full `event_person` objects with nested `event_badge` (may include temporary `event_badge_template_id`: `21` and `event_badge_template_id_random`: `RKYp2HcQm9o`).
|
||||
|
||||
Paste this section into the guide as a temporary Axonius-specific note (April 2026). Consider linking staff to a sample Zoom CSV for QA.
|
||||
|
||||
---
|
||||
|
||||
## 7. User Actions (`/v3/action/user/`)
|
||||
|
||||
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
|
||||
@@ -334,7 +459,7 @@ or:
|
||||
|
||||
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
|
||||
|
||||
**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`.
|
||||
|
||||
@@ -354,7 +479,7 @@ Check a user's current password without changing it.
|
||||
```
|
||||
or use `"username"` instead of `"user_id"` to look up by username within the account.
|
||||
|
||||
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found.
|
||||
**Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
|
||||
|
||||
---
|
||||
|
||||
@@ -407,10 +532,19 @@ Generate a new auth key and email a one-time login link to the user's email addr
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -436,7 +570,7 @@ Results are automatically scoped to the `x-account-id` provided in the request.
|
||||
|
||||
---
|
||||
|
||||
## 9. Event Exhibit Tracking Export (Leads Export)
|
||||
## 10. Event Exhibit Tracking Export (Leads Export)
|
||||
|
||||
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
|
||||
|
||||
@@ -504,10 +638,67 @@ const url = URL.createObjectURL(blob);
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting 403 Forbidden
|
||||
## 12. IDAA: Server-Side Novi Member Verification
|
||||
|
||||
Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/v3/action/idaa/novi_member/{uuid}`
|
||||
- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`)
|
||||
|
||||
### Request
|
||||
|
||||
| Parameter | Location | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) |
|
||||
|
||||
### Response on success (`200 OK`)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"verified": true,
|
||||
"full_name": "Alice S.",
|
||||
"email": "alice+member@idaa.org"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent.
|
||||
- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`).
|
||||
|
||||
### Error responses
|
||||
|
||||
| Status | Meaning | Frontend action |
|
||||
|---|---|---|
|
||||
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
|
||||
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
|
||||
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
|
||||
|
||||
### Migration from direct Novi call
|
||||
|
||||
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
|
||||
|
||||
| Direct Novi result | This endpoint returns | Frontend state |
|
||||
|---|---|---|
|
||||
| `200` with identity data | `200` | `verified` |
|
||||
| `200` with no identity data | `404` | `denied` |
|
||||
| `404` | `404` | `denied` |
|
||||
| `429` | `429` | `'rate_limited'` |
|
||||
| Network error / Novi 5xx | `503` | `'api_error'` |
|
||||
|
||||
### Caching
|
||||
|
||||
Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting 403 Forbidden
|
||||
|
||||
If you receive a 403 on a valid ID:
|
||||
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.
|
||||
|
||||
386
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
386
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# PROJECT: Site Passcode Security — API-Verified Auth
|
||||
|
||||
**Last updated:** 2026-04-10
|
||||
**Status:** Backend work in progress — frontend pending backend completion
|
||||
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.
|
||||
|
||||
**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
|
||||
|
||||
The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
|
||||
|
||||
### Source of Truth
|
||||
|
||||
`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
| Threat | Current | After Fix |
|
||||
|---|---|---|
|
||||
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
|
||||
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
|
||||
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
|
||||
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
|
||||
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
|
||||
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |
|
||||
|
||||
### The fundamental constraint
|
||||
|
||||
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solution: API-Verified Passcode + JWT Session
|
||||
|
||||
### Core idea
|
||||
|
||||
1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
|
||||
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
|
||||
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
|
||||
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
|
||||
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.
|
||||
|
||||
### Session restore on reload
|
||||
|
||||
- `access_type` still persists in localStorage (no change here)
|
||||
- The JWT is the **proof** that the access was legitimately granted and is still valid
|
||||
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
|
||||
- If JWT expired → reset `access_type` to anonymous, clear JWT
|
||||
- If JWT valid → no action needed, `access_type` is already correct
|
||||
|
||||
This gives session expiry without a network call on every page load.
|
||||
|
||||
---
|
||||
|
||||
## TTL Per Role — Decided
|
||||
|
||||
| Access Level | JWT TTL | Notes |
|
||||
|---|---|---|
|
||||
| `super` | 8 hours | Highest privilege |
|
||||
| `manager` | 24 hours | |
|
||||
| `administrator` | 48 hours | |
|
||||
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
|
||||
| `public` | 24 hours | |
|
||||
| `authenticated` | 12 hours | |
|
||||
| `anonymous` | N/A | No passcode |
|
||||
|
||||
---
|
||||
|
||||
## Caching Decision
|
||||
|
||||
**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action.
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes Required
|
||||
|
||||
**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.
|
||||
|
||||
**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending.
|
||||
|
||||
**File:** `aether_api_fastapi/app/routers/api.py`
|
||||
|
||||
The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
|
||||
|
||||
### Fix 1: Passcode matching must use explicit priority order
|
||||
|
||||
**Current (wrong):**
|
||||
```python
|
||||
for role, code in access_codes.items(): # dict insertion order — not guaranteed
|
||||
if str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
matched_role = None
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
```
|
||||
|
||||
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
|
||||
|
||||
### Fix 2: JWT payload must include all six role flags
|
||||
|
||||
**Current (incomplete):**
|
||||
```python
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'super': (matched_role == 'super'),
|
||||
# trusted / public / authenticated missing
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode', # distinguishes from user login JWTs
|
||||
'site_id': site_id,
|
||||
'role': matched_role # canonical role string — frontend uses this
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
|
||||
|
||||
### Fix 3: Per-role TTL
|
||||
|
||||
**Current:**
|
||||
```python
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=3600 * 24, # hardcoded 24h for all roles
|
||||
**payload
|
||||
)
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600, # 8 hours
|
||||
'manager': 24 * 3600, # 24 hours
|
||||
'administrator': 48 * 3600, # 48 hours
|
||||
'trusted': 48 * 3600, # 48 hours
|
||||
'public': 24 * 3600, # 24 hours
|
||||
'authenticated': 12 * 3600, # 12 hours
|
||||
}
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**payload
|
||||
)
|
||||
```
|
||||
|
||||
### Fix 4: Add minimum length validation to `passcode` field
|
||||
|
||||
**Current:**
|
||||
```python
|
||||
passcode: str = Field(..., description="The passcode to verify")
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
```
|
||||
|
||||
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
|
||||
|
||||
### Complete corrected endpoint (for reference)
|
||||
|
||||
```python
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600,
|
||||
'manager': 24 * 3600,
|
||||
'administrator': 48 * 3600,
|
||||
'trusted': 48 * 3600,
|
||||
'public': 24 * 3600,
|
||||
'authenticated': 12 * 3600,
|
||||
}
|
||||
|
||||
class PasscodeAuthRequest(BaseModel):
|
||||
"""Request model for site-based passcode authentication."""
|
||||
site_id: str = Field(..., description="Random string ID of the site")
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
|
||||
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||
async def authenticate_passcode(
|
||||
auth_req: PasscodeAuthRequest,
|
||||
response: Response = Response,
|
||||
):
|
||||
"""
|
||||
Passcode-to-JWT Endpoint.
|
||||
Verifies a passcode against site.access_code_kv_json (single source of truth —
|
||||
v_site_domain joins from the same site record).
|
||||
Returns a signed JWT with the site's account context, full role flags, and
|
||||
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
|
||||
this token from a user login JWT.
|
||||
"""
|
||||
site_id = auth_req.site_id
|
||||
passcode = auth_req.passcode
|
||||
|
||||
# 1. Look up the site record
|
||||
search_data = {'id_random': site_id}
|
||||
if record := sql_select(table_name='site', data=search_data):
|
||||
# 2. Parse access codes
|
||||
access_codes_raw = record.get('access_code_kv_json')
|
||||
access_codes = {}
|
||||
if access_codes_raw:
|
||||
try:
|
||||
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||
|
||||
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||
matched_role = None
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
|
||||
if matched_role:
|
||||
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
|
||||
|
||||
# 4. Resolve account context
|
||||
account_id_random = record.get('account_id_random')
|
||||
if not account_id_random:
|
||||
if account_id_int := record.get('account_id'):
|
||||
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||
|
||||
# 5. Mint JWT with complete role flags and per-role TTL
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode',
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
})
|
||||
}
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**payload
|
||||
)
|
||||
|
||||
return mk_resp(
|
||||
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
|
||||
response=response
|
||||
)
|
||||
else:
|
||||
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
|
||||
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
|
||||
else:
|
||||
log.warning(f"Auth Failed: Site {site_id} not found.")
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
|
||||
```
|
||||
|
||||
### Backend Phase 2 (follow-up — not blocking frontend)
|
||||
|
||||
**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes Required
|
||||
|
||||
**These depend on the backend fixes above being deployed first.**
|
||||
|
||||
### 1a. `src/lib/app_components/e_app_access_type.svelte`
|
||||
|
||||
Replace `handle_check_access_type_passcode` entirely. The new version:
|
||||
|
||||
- Is `async`
|
||||
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
|
||||
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
|
||||
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
|
||||
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
|
||||
- On network error: shows inline connection error
|
||||
- Clears `auth_error` when `entered_passcode` changes
|
||||
|
||||
API call shape:
|
||||
```http
|
||||
POST /authenticate_passcode
|
||||
Content-Type: application/json
|
||||
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
|
||||
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
|
||||
```
|
||||
|
||||
Add to template (near the passcode input):
|
||||
```svelte
|
||||
{#if auth_pending}
|
||||
<Loader size="1em" class="animate-spin text-gray-400" />
|
||||
{/if}
|
||||
{#if auth_error}
|
||||
<span class="text-error-500 text-xs">{auth_error}</span>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### 1b. `src/routes/+layout.ts`
|
||||
|
||||
**Stop caching passcodes from bootstrap** — remove line ~394:
|
||||
```ts
|
||||
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
|
||||
```
|
||||
|
||||
**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
|
||||
```ts
|
||||
// Enforce passcode JWT TTL on page load.
|
||||
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
|
||||
// User login JWTs (auth_type !== 'passcode') are left untouched.
|
||||
if (ae_loc_json?.jwt) {
|
||||
try {
|
||||
const parts = ae_loc_json.jwt.split('.');
|
||||
if (parts.length === 3) {
|
||||
const jwt_payload = JSON.parse(atob(parts[1]));
|
||||
const json_str = typeof jwt_payload.json_str === 'string'
|
||||
? JSON.parse(jwt_payload.json_str)
|
||||
: jwt_payload.json_str;
|
||||
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
|
||||
// Passcode JWT has expired — revoke access
|
||||
ae_loc_json.jwt = null;
|
||||
ae_loc_json.access_type = 'anonymous';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed JWT — leave untouched, let existing handling deal with it
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)
|
||||
|
||||
Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
|
||||
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
|
||||
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.
|
||||
|
||||
---
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Repo | Change |
|
||||
| --- | --- | --- |
|
||||
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
|
||||
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
|
||||
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
|
||||
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
|
||||
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
|
||||
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |
|
||||
@@ -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: P1–P4 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
||||
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
||||
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
||||
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
||||
# event_person: ffkKxiHpOEC (16603) "Scott Idem" under Demo event
|
||||
EVENT_PERSON_PARENT_ID = "ffkKxiHpOEC"
|
||||
# event_badge_template: jgfixEpYp1B (18) "Dev Demo 202x"
|
||||
EVENT_BADGE_TEMPLATE_ID = "jgfixEpYp1B"
|
||||
|
||||
# Test Targets: (Object Type, Valid ID Random)
|
||||
# Note: These IDs are extracted from real active records.
|
||||
@@ -127,6 +131,75 @@ def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
|
||||
return True
|
||||
|
||||
|
||||
def test_nested_create_secondary_fk(parent_type, parent_id, child_type, payload, required_fk_fields):
|
||||
"""
|
||||
Regression test for secondary FK resolution in nested POST create.
|
||||
|
||||
Bug: sanitize_payload ran BEFORE model instantiation in the nested POST handler.
|
||||
For FKs other than the parent FK (e.g. event_badge_template_id on event_badge),
|
||||
sanitize_payload resolved the string → integer, then the model's root_validator
|
||||
stripped the integer back to None (Vision ID anti-leakage guard). The parent FK
|
||||
survived only because it was explicitly re-injected; secondary FKs were silently lost.
|
||||
|
||||
Fix (api_crud_v3_nested.py): moved sanitize_payload to run on data_to_insert AFTER
|
||||
model serialization, matching the flat V3 POST pattern.
|
||||
|
||||
Verifies:
|
||||
1. POST returns 200.
|
||||
2. Each field in required_fk_fields is present AND non-None in the response.
|
||||
3. All *_id fields are strings (Vision Standard).
|
||||
4. Cleanup: DELETE the created record.
|
||||
"""
|
||||
label = f"Nested Secondary FK ({parent_type}/{child_type})"
|
||||
print(f"\n--- Regression: {label} ---")
|
||||
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
|
||||
headers = get_headers()
|
||||
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
if resp.status_code != 200:
|
||||
print(f" ❌ [FAIL] POST returned {resp.status_code}: {resp.text[:300]}")
|
||||
return False
|
||||
|
||||
data = resp.json().get('data', {})
|
||||
new_id = data.get('id') or data.get('obj_id_random')
|
||||
if not new_id or not isinstance(new_id, str):
|
||||
print(f" ❌ [FAIL] No string 'id' in response. Got: {data}")
|
||||
return False
|
||||
print(f" ✅ [PASS] Created {child_type} with id: {new_id}")
|
||||
|
||||
# Check required secondary FK fields are present and non-None
|
||||
for field in required_fk_fields:
|
||||
val = data.get(field)
|
||||
if val is None:
|
||||
print(f" ❌ [FAIL] Secondary FK '{field}' is None — was not saved to DB.")
|
||||
# Still attempt cleanup
|
||||
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
return False
|
||||
if not isinstance(val, str):
|
||||
print(f" ❌ [FAIL] Secondary FK '{field}' is {type(val).__name__} ({val}) — must be string (Vision Standard).")
|
||||
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
return False
|
||||
print(f" ✅ [PASS] Secondary FK '{field}' = {val}")
|
||||
|
||||
# Vision compliance: all *_id fields must be strings
|
||||
for key, val in data.items():
|
||||
if (key == 'id' or key.endswith('_id')) and not key.endswith('external_id'):
|
||||
if val is not None and not isinstance(val, str):
|
||||
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
|
||||
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
return False
|
||||
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
|
||||
|
||||
# Cleanup
|
||||
del_resp = requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
if del_resp.status_code == 200:
|
||||
print(f" ✅ [PASS] Cleanup: deleted {new_id}")
|
||||
else:
|
||||
print(f" ⚠️ [WARN] Cleanup failed ({del_resp.status_code}) — manual cleanup may be needed for {new_id}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_nested_alias_resolution():
|
||||
"""
|
||||
Verifies that the 'entry' alias and nested resolution works for journals.
|
||||
@@ -171,6 +244,21 @@ if __name__ == "__main__":
|
||||
child_type='event_session',
|
||||
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
||||
))
|
||||
# Secondary FK regression: event_badge_template_id must survive nested POST
|
||||
# (was silently dropped as NULL before the sanitize_payload order fix)
|
||||
results.append(test_nested_create_secondary_fk(
|
||||
parent_type='event_person',
|
||||
parent_id=EVENT_PERSON_PARENT_ID,
|
||||
child_type='event_badge',
|
||||
payload={
|
||||
'event_badge_template_id': EVENT_BADGE_TEMPLATE_ID,
|
||||
'given_name': '[e2e-test]',
|
||||
'family_name': 'secondary-fk-regression',
|
||||
'enable': False,
|
||||
'hide': True,
|
||||
},
|
||||
required_fk_fields=['event_badge_template_id'],
|
||||
))
|
||||
|
||||
elapsed = time.time() - suite_start
|
||||
if all(results):
|
||||
|
||||
@@ -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")
|
||||
|
||||
152
tests/tools/stress_list_queries.py
Normal file
152
tests/tools/stress_list_queries.py
Normal 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 (0–100)."""
|
||||
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()
|
||||
219
tests/unit/test_unit_idaa_novi_verify.py
Normal file
219
tests/unit/test_unit_idaa_novi_verify.py
Normal 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')
|
||||
Reference in New Issue
Block a user