13 Commits

Author SHA1 Message Date
Scott Idem
3806613427 fix(download): strict ID typing — remove cross-resolution from hosted_file download
event_file download now resolves event_file_id → hosted_file_id explicitly before
delegating, rather than relying on a cross-resolution fallback inside the hosted_file
endpoint. The hosted_file download endpoint now only accepts hosted_file IDs.

Cross-resolution was added reactively (ea117bf) to patch incorrect frontend ID usage
and was never a deliberate design decision. With no per-record account ownership check
on the download path, the implicit ID aliasing was an unauditable gap.

- download_event_file_action: resolves event_file → hosted_file via Redis + SQL before
  delegating; 404s explicitly if chain is broken
- download_file_action: strict hosted_file ID only; cross-resolution fallback removed
- Also fixes ?key= not being forwarded (was missing from event_file endpoint signature)
- TODO: per-record account ownership check (P1), archive_content download endpoint (P2)
- Docs: breaking change note added to frontend guide (remove ~2026-06-24)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:46:47 -04:00
Scott Idem
2429a1f731 fix(crud): strip view-only join columns from order_by_li to prevent ambiguous account_id in WHERE
Sorting by join-derived columns (e.g. event_presenter_family_name on v_event_file)
caused MariaDB to expand the view's JOIN inline, making the unqualified account_id
clause from sql_and_qry_part ambiguous — resulting in a 400 SQL error. filter_order_by
now accepts raw_table_name and validates ORDER BY columns against the physical table
only; join-only columns are silently stripped. Also switches filter_order_by off the
global db connection to engine.connect() context managers. Updated all four call sites
in api_crud_v3.py and api_crud_v3_nested.py.

Docs: add order_by_li raw-table limitation and direct download link patterns to
GUIDE__AE_API_V3_for_Frontend.md; record fix in TODO__Agents.md.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:15:13 -04:00
Scott Idem
c8377a2b22 Version bump because things are in a good state overall. 2026-05-13 17:33:33 -04:00
Scott Idem
f6ba339276 Add archive_content fields and docs 2026-05-07 18:42:43 -04:00
27 changed files with 1490 additions and 93 deletions

View File

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

View File

@@ -140,12 +140,18 @@ def apply_forced_account_filter(and_qry_dict: Optional[Dict], account: AccountCo
return forced return forced
def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Optional[Dict[str, str]]: def filter_order_by(order_by_li: Any, model: Any, table_name: str = None, raw_table_name: str = None) -> Optional[Dict[str, str]]:
""" """
Sanitize Sorting Parameters. Sanitize Sorting Parameters.
Prevents SQL injection and logic errors by validating that requested sort columns Prevents SQL injection and logic errors by validating that requested sort columns
actually exist in the Pydantic model and/or the database table. actually exist in the Pydantic model and/or the database table.
When raw_table_name is provided (the physical table, not the view), columns are
validated against it instead of table_name. This prevents view-only join columns
(e.g. event_presenter_family_name on v_event_file) from reaching ORDER BY —
those columns cause MariaDB to expand the view's JOIN inline, making unqualified
WHERE references like `account_id` ambiguous across joined tables.
""" """
if not order_by_li or not isinstance(order_by_li, dict) or not model: if not order_by_li or not isinstance(order_by_li, dict) or not model:
return order_by_li return order_by_li
@@ -156,14 +162,15 @@ def filter_order_by(order_by_li: Any, model: Any, table_name: str = None) -> Opt
model_fields.update({f.alias for f in model.__fields__.values() if f.alias}) model_fields.update({f.alias for f in model.__fields__.values() if f.alias})
filtered = {k: v for k, v in order_by_li.items() if k in model_fields} filtered = {k: v for k, v in order_by_li.items() if k in model_fields}
if table_name and filtered: check_table = raw_table_name or table_name
from app.db_sql import db if check_table and filtered:
from app import lib_sql_core
from sqlalchemy import text from sqlalchemy import text
final_filtered = {} final_filtered = {}
for column in filtered: for column in filtered:
try: try:
# Lightweight check to see if column exists in SQL with lib_sql_core.engine.connect() as conn:
db.execute(text(f"SELECT `{column}` FROM `{table_name}` LIMIT 0")) conn.execute(text(f"SELECT `{column}` FROM `{check_table}` LIMIT 0"))
final_filtered[column] = filtered[column] final_filtered[column] = filtered[column]
except Exception: except Exception:
pass pass

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -226,11 +226,12 @@ async def get_obj_li(
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
raw_table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
if not table_name or not base_name: if not table_name or not base_name:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
order_by_li = filter_order_by(order_by_li, base_name, table_name) order_by_li = filter_order_by(order_by_li, base_name, table_name, raw_table_name=raw_table_name)
status_filter = get_supported_filters(base_name, status_filter) status_filter = get_supported_filters(base_name, status_filter)
if not obj_cfg.get('public_read', False): if not obj_cfg.get('public_read', False):
@@ -335,11 +336,12 @@ async def search_obj_li(
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
raw_table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
if not table_name or not base_name: if not table_name or not base_name:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
order_by_li = filter_order_by(order_by_li, base_name, table_name) order_by_li = filter_order_by(order_by_li, base_name, table_name, raw_table_name=raw_table_name)
status_filter = get_supported_filters(base_name, status_filter) status_filter = get_supported_filters(base_name, status_filter)
searchable_fields = obj_cfg.get('searchable_fields') searchable_fields = obj_cfg.get('searchable_fields')

View File

@@ -84,6 +84,7 @@ async def get_child_obj_li(
obj_cfg = obj_type_kv_li[obj_name] obj_cfg = obj_type_kv_li[obj_name]
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
raw_table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
# Log parent/child resolution details (use INFO so logs appear in production) # Log parent/child resolution details (use INFO so logs appear in production)
log.info("nested.list start parent=%s parent_table=%s parent_id_random=%s child=%s table=%s allowed_parents=%s", parent_obj_type, parent_table, parent_obj_id, obj_name, table_name, obj_cfg.get('parent_types')) log.info("nested.list start parent=%s parent_table=%s parent_id_random=%s child=%s table=%s allowed_parents=%s", parent_obj_type, parent_table, parent_obj_id, obj_name, table_name, obj_cfg.get('parent_types'))
@@ -91,7 +92,7 @@ async def get_child_obj_li(
if not table_name or not base_name: if not table_name or not base_name:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
order_by_li = filter_order_by(order_by_li, base_name, table_name) order_by_li = filter_order_by(order_by_li, base_name, table_name, raw_table_name=raw_table_name)
status_filter = get_supported_filters(base_name, status_filter) status_filter = get_supported_filters(base_name, status_filter)
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table) resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
@@ -187,11 +188,12 @@ async def search_child_obj_li(
obj_cfg = obj_type_kv_li[obj_name] obj_cfg = obj_type_kv_li[obj_name]
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl'))) table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl'))) base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
raw_table_name = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
if not table_name or not base_name: if not table_name or not base_name:
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.") return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
order_by_li = filter_order_by(order_by_li, base_name, table_name) order_by_li = filter_order_by(order_by_li, base_name, table_name, raw_table_name=raw_table_name)
status_filter = get_supported_filters(base_name, status_filter) status_filter = get_supported_filters(base_name, status_filter)
searchable_fields = obj_cfg.get('searchable_fields') searchable_fields = obj_cfg.get('searchable_fields')

View File

@@ -281,20 +281,34 @@ async def download_event_file_action(
response: Response, response: Response,
event_file_id: str = Path(min_length=11, max_length=22), event_file_id: str = Path(min_length=11, max_length=22),
filename: Optional[str] = Query(None, min_length=4, max_length=255), filename: Optional[str] = Query(None, min_length=4, max_length=255),
key: Optional[str] = Query(None),
site_key: Optional[str] = Query(None), site_key: Optional[str] = Query(None),
range: Optional[str] = Header(None), range: Optional[str] = Header(None),
account: AccountContext = Depends(get_account_context_optional), account: AccountContext = Depends(get_account_context_optional),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
): ):
""" """
Semantic alias for hosted_file download with Event-specific context. Download the underlying file for an event_file record.
Resolves event_file_id → hosted_file_id explicitly before delegating.
""" """
# Simply delegate to the universal hosted_file download logic ef_int_id = redis_lookup_id_random(record_id_random=event_file_id, table_name='event_file')
if not ef_int_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event file record not found.")
ef_rec = sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_int_id})
if not ef_rec or not ef_rec.get('hosted_file_id'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event file has no associated hosted file.")
hosted_file_id_random = get_id_random(record_id=ef_rec['hosted_file_id'], table_name='hosted_file')
if not hosted_file_id_random:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.")
from app.routers.api_v3_actions_hosted_file import download_file_action from app.routers.api_v3_actions_hosted_file import download_file_action
return await download_file_action( return await download_file_action(
response=response, response=response,
hosted_file_id=event_file_id, # The universal downloader now resolves this! hosted_file_id=hosted_file_id_random,
filename=filename, filename=filename,
key=key,
site_key=site_key, site_key=site_key,
range=range, range=range,
account=account, account=account,

View File

@@ -227,28 +227,11 @@ async def download_file_action(
if not is_authorized: if not is_authorized:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid access key.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Authentication required or invalid access key.")
# 2. Resolve File Record # 2. Resolve File Record — strict: only accepts hosted_file IDs.
# ID Vision: Attempt to resolve the ID. # Container objects (event_file, archive_content) must resolve to a hosted_file_id
# 🛑 REMINDER: If adding a new specialized 'container' object (like event_person_profile), # in their own dedicated download endpoints before calling this function.
# ensure the lookup logic is mirrored here to allow direct downloads via container ID.
# If not found in hosted_file, check if it's an event_file or archive_content ID that we can resolve.
resolved_id = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file') resolved_id = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
if not resolved_id:
log.info(f"ID {hosted_file_id} not found in hosted_file. Checking container tables...")
# A. Check event_file
if ef_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='event_file'):
if ef_rec := sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_id}):
resolved_id = ef_rec.get('hosted_file_id')
log.info(f"Resolved event_file {hosted_file_id} to hosted_file {resolved_id}")
# B. Check archive_content
if not resolved_id:
if ac_id := redis_lookup_id_random(record_id_random=hosted_file_id, table_name='archive_content'):
if ac_rec := sql_select(sql="SELECT hosted_file_id FROM archive_content WHERE id = :id", data={'id': ac_id}):
resolved_id = ac_rec.get('hosted_file_id')
log.info(f"Resolved archive_content {hosted_file_id} to hosted_file {resolved_id}")
if not resolved_id: if not resolved_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.")

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ from app.routers import (
event_badge_importing, event_badge_importing,
event_importing, event_importing,
api_v3_actions_email, 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_user, lookup_v3, api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_idaa, api_v3_actions_user, lookup_v3,
user, user,
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
) )
@@ -51,6 +51,7 @@ def setup_routers(app: FastAPI):
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)']) app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)']) app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)']) app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
app.include_router(api_v3_actions_idaa.router, prefix='/v3/action/idaa', tags=['IDAA Actions (V3)'])
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)']) app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)']) app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)'])
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup # app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup

View File

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

View File

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

View File

@@ -96,6 +96,26 @@ The primary way to retrieve data.
* **Endpoint:** `POST /v3/crud/{obj_type}/search` * **Endpoint:** `POST /v3/crud/{obj_type}/search`
* **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects. * **Security:** Automatically filters results to only show records belonging to your `x-account-id`. If no account context is provided, it will return **0 records** for private objects.
#### Sorting with `order_by_li`
Pass a JSON object as the `order_by_li` query parameter to sort results:
```ts
// ?order_by_li={"filename":"ASC","created_on":"DESC"}
const params = new URLSearchParams({
order_by_li: JSON.stringify({ filename: 'ASC', created_on: 'DESC' })
});
```
> [!IMPORTANT]
> **`order_by_li` only accepts columns from the raw base table** — not view-only join columns.
>
> Some object types (e.g. `event_file`) have enriched views that JOIN other tables to expose convenience fields like `event_presenter_family_name`. These are available in search results when using `?view=alt`, but they **cannot** be used in `order_by_li`. Attempting to sort by them silently drops those sort keys (the query proceeds without them).
>
> If you need to sort by a joined field, sort client-side on the returned list.
>
> **Columns safe to sort on for `event_file`:** any field in the `event_file` table itself — `filename`, `title`, `extension`, `created_on`, `updated_on`, `sort`, `enable`, etc.
### C. POST Create / PATCH Update ### C. POST Create / PATCH Update
Modify data in the system. Modify data in the system.
* **Endpoints:** * **Endpoints:**
@@ -266,7 +286,7 @@ When seeding new lookup data (e.g., adding timezones in bulk):
## 5. Event File Data Retrieval (Hosted Files) ## 5. Event File Data Retrieval (Hosted Files)
Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file`). The Hosted File itself is a metadata record for binary content (files), which is accessed via separate Action endpoints (e.g., `/v3/action/hosted_file/download`). This API endpoint provides metadata about the associated hosted file. To retrieve this additional metadata: Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file`). The Hosted File is a metadata record for binary content, accessed via dedicated Action endpoints. To download an event file use `/v3/action/event_file/{event_file_id}/download` — not the hosted_file endpoint directly (each endpoint only accepts its own ID type). To retrieve hosted file metadata alongside an event file record:
* **Endpoint:** `GET /v3/crud/event_file/{event_file_id}` * **Endpoint:** `GET /v3/crud/event_file/{event_file_id}`
* **Query Parameter:** Add `inc_hosted_file=true` * **Query Parameter:** Add `inc_hosted_file=true`
@@ -281,6 +301,48 @@ Every Event File (`event_file`) **must** have a linked Hosted File (`hosted_file
* `hosted_file_size` (string - in bytes) * `hosted_file_size` (string - in bytes)
2. **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc. 2. **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc.
### Direct Download Links (Shareable / External)
Event files can be downloaded without standard auth headers using one of two bypass mechanisms. This is useful for generating shareable links for staff or external recipients.
- **Method:** `GET`
- **Path:** `/v3/action/event_file/{event_file_id}/download`
> [!WARNING]
> **Breaking change (2026-06-10):** This endpoint now requires an `event_file_id`. Previously it accepted `hosted_file_id` or `archive_content_id` and resolved the chain automatically — that cross-resolution has been removed. Pass the correct ID type for the endpoint you are calling. If you were routing downloads through `/v3/action/hosted_file/{hosted_file_id}/download` as a workaround, switch to this endpoint using `event_file_id`. *(Remove this note after ~2026-06-24.)*
#### Auth bypass options
| Query param | Value | When to use |
|---|---|---|
| `?key=<account_id_random>` | Any valid account random ID | Staff sharing within a known account context |
| `?site_key=<site_access_key>` | The site's `access_key` value | Public or semi-public distribution tied to a specific site |
Either param replaces the need for `x-aether-api-key` / `x-account-id` headers, so the URL is self-contained and works in a plain browser tab or `<a href>` link.
#### Optional params
| Query param | Description |
|---|---|
| `filename` | Override the download filename (min 4 chars). Useful for giving files clean display names. |
#### Building a shareable link
```ts
// Build a self-contained download URL for staff/external use
function makeDownloadUrl(eventFileId: string, accountId: string, displayName?: string): string {
const base = `https://dev-api.oneskyit.com/v3/action/event_file/${eventFileId}/download`;
const params = new URLSearchParams({ key: accountId });
if (displayName) params.set('filename', displayName);
return `${base}?${params}`;
}
```
The endpoint supports byte-range requests (`Range` header), so it works correctly for in-browser media streaming as well as direct file downloads.
> [!NOTE]
> The `?key=` bypass verifies only that the account ID exists — it does not confirm the file belongs to that account. It is appropriate for internal staff tools. For publicly distributed links, prefer `?site_key=` which ties access to a specific site's configured key.
--- ---
## 6. Hosted File Actions: Convert & Clip (Frontend Notes) ## 6. Hosted File Actions: Convert & Clip (Frontend Notes)
@@ -301,18 +363,16 @@ These helper endpoints let the frontend request small server-side transformation
- Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`) - Required query params: `link_to_type`, `link_to_id`, `start_time`, `end_time` (format `HH:MM:SS`)
- Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool) - Optional query params: `filename_no_ext` (defaults to `automated_hosted_file_clip_video`), `reencode` (bool), `scale_down` (bool)
- Auth: standard V3 headers - Auth: standard V3 headers
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. Returns 400 on failure.
- Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`. - Behavior: extracts a clip using `ffmpeg` and saves it as a new `hosted_file`.
- Defaults to stream-copying to be fast; set `reencode=true` to force H.264 or `scale_down=true` to resize. - Defaults to stream-copying (fast); set `reencode=true` to force H.264 or `scale_down=true` to resize.
- For longer-running clips you can schedule the job in the background by adding `?background=true`. When scheduled the API returns `202 Accepted` and the clip runs asynchronously on the server; check the returned `hosted_file` record later via the standard V3 `hosted_file` endpoints. - Add `?background=true` to schedule the clip asynchronously — returns `202 Accepted` immediately; poll the `hosted_file` record for completion.
- Returns 400 on synchronous failure; returns 202 when scheduled successfully. - Returns 400 on synchronous failure; 202 when scheduled successfully.
Frontend guidance: Frontend guidance:
- Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you. - Call these routes with the same `link_to_type` / `link_to_id` you plan to associate the resulting hosted_file with — the server resolves random IDs for you.
- After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file. - After a successful response, use the V3 `hosted_file` action endpoints (download/delete) to manage or retrieve the new file.
- These endpoints run synchronously and can take time for large inputs; for heavy or batch workloads use a queued job pattern instead. - Prefer `?background=true` for large inputs to avoid request timeouts. For heavy or batch workloads use a queued job pattern instead.
- These endpoints may take time for large inputs. Prefer using `?background=true` to schedule work and receive a `202 Accepted` response for async processing. For heavy or batch workloads use a queued job pattern instead.
--- ---
@@ -638,6 +698,61 @@ const url = URL.createObjectURL(blob);
--- ---
## 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 ## 11. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID: If you receive a 403 on a valid ID:

View File

@@ -23,6 +23,9 @@
- [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`. - [x] **[P4] Expose `pool_size` / `max_overflow` as env vars** — `create_ae_engine()` calls `settings.DB.get('pool_size', 10)` but `settings.DB` property doesn't include those keys, so they're always hardcoded 10/20. Add `AE_DB_POOL_SIZE` / `AE_DB_POOL_MAX_OVERFLOW` to `config.py`.
## 📋 Feature Tasks ## 📋 Feature Tasks
- [ ] **[P1] Download endpoint account ownership check (June 2026):** `download_file_action` (hosted_file) has no per-record account isolation — any authenticated caller can download any file if they know the ID. After passing the auth gate (`account.auth_method != 'guest'`), the endpoint loads and serves the file with no check that `hosted_file.account_id` matches `account.account_id`. Fix: after loading `hosted_file_obj`, compare `hosted_file_obj.account_id` to `account.account_id`; reject with 403 unless `account.super` or `auth_method == 'bypass'`. Apply the same check in `download_event_file_action` after the `event_file` record is resolved (the event_file's `account_id` is the authoritative ownership gate for that path). Note: `?key=` and `?site_key=` bypass paths are intentionally loose and should remain unchanged for external link support.
- [ ] **[P2] `archive_content` download endpoint (June 2026):** No dedicated download endpoint exists for `archive_content`. Previously, callers could pass an `archive_content_id` to the `hosted_file` download endpoint and cross-resolution would handle it — this was removed as part of the Option B strict-ID-typing change. Needs a new `GET /v3/action/archive_content/{archive_content_id}/download` endpoint that resolves `archive_content_id → hosted_file_id` explicitly (same pattern as `download_event_file_action`).
- [x] **`order_by_li` view-join ambiguity fix (June 2026):** Using view-only join columns (e.g. `event_presenter_family_name` from `v_event_file`) in `order_by_li` caused MariaDB error "Unknown column 'account_id' in WHERE" (HTTP 400). Root cause: `filter_order_by` validated columns against the view — which passes for join-derived fields — and `sql_and_qry_part` generates an unqualified `account_id =` clause that becomes ambiguous when MariaDB expands the view's JOIN inline. Fix: `filter_order_by` now accepts `raw_table_name` and validates ORDER BY columns against the physical table only. Join-only view columns are silently stripped. Updated all three call sites in `api_crud_v3.py` (×2) and `api_crud_v3_nested.py` (×2). **Follow-up (lower priority):** qualify `account_id` in `sql_and_qry_part` to fix the root ambiguity for any future JOIN-capable views.
- [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed. - [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed.
- [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects. - [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects.
- [x] **Detailed Feedback:** Implement descriptive 403 Forbidden reasons. - [x] **Detailed Feedback:** Implement descriptive 403 Forbidden reasons.
@@ -34,10 +37,48 @@
- [x] Whitelist `account_id` in all Event search definitions. - [x] Whitelist `account_id` in all Event search definitions.
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore). - [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
- [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete. - [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete.
- [x] **Restore alt-view convenience fields lost in v1→v3 migration (May 2026):** `site_domain` (`account_name`, `account_code`, `account_enable`, `account_enable_from/to`, `site_enable_from/to`, `site_domain_access_key`, `logo_path`, `style_href`, `script_src`, `google_tracking_id`) and `event_session` (`event_presentation_li_qry_str`, `event_presenter_li_qry_str`). Fields added to Pydantic models and `searchable_fields`. Alt-view fields require `?view=alt` for search.
- [ ] Verify SQL Views join in all required `_random` IDs for performance. - [ ] Verify SQL Views join in all required `_random` IDs for performance.
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token). - [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
- [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`. - [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`.
## 🔌 IDAA: Server-Side Novi Verification (Mini Project)
> **Status: P1P4 Complete (May 2026).** Endpoint live at `GET /v3/action/idaa/novi_member/{uuid}`. P5 (frontend migration) is the remaining step.
> Rationale and frontend integration notes: `aether_app_sveltekit/documentation/CLIENT__IDAA_and_customized_mods.md` → "Planned: Server-Side Novi Verification"
**Goal:** Proxy the Novi member-verification call server-to-server (FastAPI → Novi) so members' browser IPs are no longer in the call path.
- [x] **[P1] New router:** `app/routers/api_v3_actions_idaa.py`
- Route: `GET /v3/action/idaa/novi_member/{uuid}`
- Required auth: `Depends(get_account_context)` — valid API key + any account context (x-account-id, JWT, or bypass). This is the standard V3 gate.
- Reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json` via `_load_idaa_cfg()` (same as Mailman bridge)
- Calls Novi: `GET {novi_api_root_url}/customers/{uuid}` with `Authorization: Basic {api_key}`
- Normalize email: `.replace(' ', '+')` (Novi quirk — see Novi-Mailman bridge notes)
- Build display name: `"{FirstName} {LastName[0]}."` format, fall back to `Name` field
- Returns `{ "verified": true, "full_name": "...", "email": "..." }` on success
- Returns `404` if Novi 200 with no identity data (empty-member anti-pattern)
- Returns `429` if Novi rate limits; `503` if Novi unreachable or 5xx
- Business logic in `app/methods/idaa_novi_verify_methods.py`
- [x] **[P2] Redis cache:**
- Key: `idaa:novi_member:{uuid}` — TTL 4 hours
- Note: `account_id` dropped from key — Novi credentials are hardcoded to the IDAA site; same UUID always returns the same data regardless of caller, so per-caller scoping wastes Redis space and halves hit rate.
- Cache only verified (200) results — do NOT cache 404 (member may have just joined)
- Uses `redis_client` from `lib_redis_helpers.py` directly
- [x] **[P3] Register in registry:** Added to `routers/registry.py` at `/v3/action/idaa` tag `IDAA Actions (V3)`. Confirmed live — endpoint appears in `/openapi.json`.
- [x] **[P4] Tests:** `tests/unit/test_unit_idaa_novi_verify.py` — 9 tests, all passing.
- Mock Novi responses (200/empty-200/404/429/503/unreachable)
- Verify Redis cache is set on 200, hit bypasses Novi call
- Verify email normalization (space → +)
- Verify display name format (5 cases)
- [x] **[P5] Coordinate with Frontend Agent** — **Complete (2026-05-19)**
- Frontend replaced direct `fetch()` to Novi in `+layout.svelte:verify_novi_uuid()`
- Response codes mapped: 200 → verified, 404 → denied, 429 → `'rate_limited'`, 503 → auto-retried (3s, once) then `'api_error'`
- 503 auto-retry added same session to match network-error retry path
## 🛡️ Security & Privacy Baseline (IDAA) ## 🛡️ Security & Privacy Baseline (IDAA)
- **Status:** **ENFORCED**. - **Status:** **ENFORCED**.
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change. - **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.

View File

@@ -33,6 +33,7 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `test_e2e_v3_action_novi_mailman.py` | **Novi-Mailman Bridge — Connections**: Verifies Novi AMS and Mailman 3 API credentials are valid (IDAA). Run first before the lists test. | | `test_e2e_v3_action_novi_mailman.py` | **Novi-Mailman Bridge — Connections**: Verifies Novi AMS and Mailman 3 API credentials are valid (IDAA). Run first before the lists test. |
| `test_e2e_v3_action_novi_mailman_lists.py` | **Novi-Mailman Bridge — List Operations**: Full member lifecycle — read roster, subscribe, verify, unsubscribe — against `mm3@idaa.org`, `mm3@dgrzone.com`, `mm3@oneskyit.com`. | | `test_e2e_v3_action_novi_mailman_lists.py` | **Novi-Mailman Bridge — List Operations**: Full member lifecycle — read roster, subscribe, verify, unsubscribe — against `mm3@idaa.org`, `mm3@dgrzone.com`, `mm3@oneskyit.com`. |
| `test_e2e_v3_action_event_exhibit_tracking_export.py` | **Exhibit Leads Export**: Auth/permission guards, CSV column structure, XLSX bytes, and `return_file` mode for the V3 tracking export action. | | `test_e2e_v3_action_event_exhibit_tracking_export.py` | **Exhibit Leads Export**: Auth/permission guards, CSV column structure, XLSX bytes, and `return_file` mode for the V3 tracking export action. |
| `test_e2e_v3_action_idaa_novi_verify.py` | **IDAA Novi Member Verify**: Auth guard, 200 verified, 404 not-found, 429 rate-limit, 503 unreachable, Redis cache hit, email normalization. (not yet written — add when endpoint is stable) |
| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. | | `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. |
| `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. | | `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. |
| `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. | | `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. |
@@ -81,6 +82,7 @@ Tests exist to be used — run the relevant suite whenever you touch backend cod
| User action route changes (sign-in, password, magic link) | `test_e2e_v3_user_action_routes.py` | | User action route changes (sign-in, password, magic link) | `test_e2e_v3_user_action_routes.py` |
| File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` | | File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` |
| Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` | | Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` |
| IDAA Novi member verify changes | `tests/unit/test_unit_idaa_novi_verify.py`, `test_e2e_v3_action_idaa_novi_verify.py` (e2e pending) |
| Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.py` | | Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.py` |
| Any backend change before frontend hand-off | All of the above | | Any backend change before frontend hand-off | All of the above |
@@ -111,6 +113,16 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py ./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
``` ```
### Running unit tests with pytest
```bash
./environment/bin/python3 -m pytest tests/unit/ -v
```
`pytest` and `pytest-asyncio` are dev-only dependencies (not in `requirements.txt`). After rebuilding the venv (e.g. following an OS Python update), reinstall them:
```bash
./environment/bin/pip install pytest pytest-asyncio
```
### Path Requirements ### Path Requirements
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly. Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.

View File

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

View File

@@ -0,0 +1,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')