Compare commits
50 Commits
1f9cbb0a1f
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e5a3c3fd | ||
|
|
9962176c74 | ||
|
|
35fa5132e7 | ||
|
|
e19fd63d1f | ||
|
|
051b2fd7ac | ||
|
|
221854df90 | ||
|
|
c7335bbc3e | ||
|
|
45a5acd45d | ||
|
|
c64c3bc55a | ||
|
|
c8377a2b22 | ||
|
|
f6ba339276 | ||
|
|
ed66ba4bd4 | ||
|
|
44e4f5c4e6 | ||
|
|
c378040ad4 | ||
|
|
b590bc09a0 | ||
|
|
e71906b59a | ||
|
|
3d89e95c24 | ||
|
|
3db5f7c749 | ||
|
|
55debc8009 | ||
|
|
ace00929f2 | ||
|
|
c7444a8a89 | ||
|
|
8f1fe5d4df | ||
|
|
c0626e061e | ||
|
|
dfb5289188 | ||
|
|
0ecc5a97d5 | ||
|
|
516865b7d8 | ||
|
|
7f9666dc1e | ||
|
|
f9f588ddf2 | ||
|
|
ea25bf78d4 | ||
|
|
c837d465ca | ||
|
|
2659047d24 | ||
|
|
18374f855f | ||
|
|
e5acefe8f6 | ||
|
|
082163b5df | ||
|
|
e35fdb4f67 | ||
|
|
02a2be7275 | ||
|
|
eba3456b7b | ||
|
|
987b552157 | ||
|
|
7ad158883a | ||
|
|
2b608d7a1a | ||
|
|
535fc9f2b5 | ||
|
|
8e9fb88e5a | ||
|
|
42eaa6676e | ||
|
|
b5c50fd116 | ||
|
|
2a1f270db6 | ||
|
|
ebc5db96da | ||
|
|
153c2ce6dd | ||
|
|
9faf22d841 | ||
|
|
293f447a1c | ||
|
|
4629e1ec63 |
@@ -1,5 +1,5 @@
|
||||
|
||||
# Aether API v3.x (FastAPI)
|
||||
# Aether API v3.00.20 (FastAPI)
|
||||
|
||||
The **Aether API** is a high-performance, multi-tenant backend for the Aether Platform, built on Python **FastAPI**. It powers both legacy and modern (V3/V4) applications, and is now fully containerized for robust, scalable deployment.
|
||||
|
||||
|
||||
@@ -25,8 +25,10 @@ class Settings(BaseSettings):
|
||||
DB_PASS: str = Field('', env='AE_DB_PASSWORD')
|
||||
|
||||
# Connection tuning
|
||||
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
|
||||
DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE')
|
||||
DB_CONNECT_TIMEOUT: int = Field(20, env='AE_DB_CONNECTION_TIMEOUT')
|
||||
DB_POOL_RECYCLE: int = Field(1800, env='AE_DB_POOL_RECYCLE')
|
||||
DB_POOL_SIZE: int = Field(10, env='AE_DB_POOL_SIZE')
|
||||
DB_POOL_MAX_OVERFLOW: int = Field(20, env='AE_DB_POOL_MAX_OVERFLOW')
|
||||
|
||||
# --- Logging ---
|
||||
LOG_PATH_APP: str = Field('/logs/aether_api.log', env='AE_API_LOG_PATH')
|
||||
@@ -73,8 +75,10 @@ class Settings(BaseSettings):
|
||||
'name': self.DB_NAME,
|
||||
'username': self.DB_USER,
|
||||
'password': self.DB_PASS,
|
||||
'connect_timeout': self.DB_CONNECT_TIMEOUT,
|
||||
'pool_recycle': self.DB_POOL_RECYCLE,
|
||||
'connect_timeout': self.DB_CONNECT_TIMEOUT,
|
||||
'pool_recycle': self.DB_POOL_RECYCLE,
|
||||
'pool_size': self.DB_POOL_SIZE,
|
||||
'max_overflow': self.DB_POOL_MAX_OVERFLOW,
|
||||
}
|
||||
|
||||
@property
|
||||
|
||||
@@ -43,9 +43,15 @@ def create_ae_engine(uri: str):
|
||||
|
||||
engine = create_ae_engine(db_uri)
|
||||
|
||||
# DEPRECATED: Global shared 'db' connection. Use engine.connect() in context managers instead.
|
||||
# Keeping for legacy compatibility but will phase out usage in crud lib.
|
||||
db = engine.connect()
|
||||
# DEPRECATED: Global shared 'db' connection. Still used by lib_schema_v3.py and lib_api_crud_v3.py.
|
||||
# TODO (P3 full fix): migrate those two call sites to engine.connect() context managers, then remove this.
|
||||
# Bare connect guarded so a Docker startup race (MariaDB not yet ready) doesn't crash the worker.
|
||||
# If this fails, db=None — callers that hit it before reconnect_db() runs will raise AttributeError.
|
||||
try:
|
||||
db = engine.connect()
|
||||
except Exception:
|
||||
log.warning("DB SQL Core: Initial db connection failed at startup (MariaDB not ready?). Will retry via reconnect_db().")
|
||||
db = None
|
||||
|
||||
log.info('DB SQL Core: Initializing engine...')
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
from app.log import log, logger_reset
|
||||
# CRITICAL: Import the core module to access current global state
|
||||
from app import lib_sql_core
|
||||
from app.lib_sql_core import sql_connect, set_last_sql_error
|
||||
from app.lib_sql_core import set_last_sql_error
|
||||
|
||||
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
|
||||
@@ -63,11 +63,29 @@ def sql_insert(
|
||||
return result_insert.lastrowid
|
||||
return False
|
||||
except IntegrityError as e:
|
||||
# Data constraint violation (duplicate key, FK mismatch, NOT NULL) — do NOT retry;
|
||||
# the same data would fail again. Return None so callers can distinguish from errors.
|
||||
if trans: trans.rollback()
|
||||
log.error('Integrity error (likely duplicate). Returning None')
|
||||
log.debug(e)
|
||||
set_last_sql_error(e)
|
||||
return None
|
||||
except OperationalError:
|
||||
# Transient connection failure. The broken connection rolls back on MariaDB's side,
|
||||
# so retrying with a fresh connection is safe.
|
||||
if trans: trans.rollback()
|
||||
log.warning('Operational error in sql_insert. Retrying once with fresh connection...')
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
result_insert = conn.execute(sql_insert_stmt, data)
|
||||
trans.commit()
|
||||
if result_insert.rowcount == 1 and result_insert.lastrowid > 0:
|
||||
return result_insert.lastrowid
|
||||
return False
|
||||
except Exception as e:
|
||||
set_last_sql_error(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
if trans: trans.rollback()
|
||||
log.error('Unknown exception in sql_insert. Returning False')
|
||||
@@ -138,7 +156,6 @@ def sql_update(
|
||||
except OperationalError:
|
||||
if trans: trans.rollback()
|
||||
log.error('Operational error (gone away?). Retrying once...')
|
||||
sql_connect()
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
@@ -199,6 +216,19 @@ def sql_insert_or_update(
|
||||
res = conn.execute(stmt, data)
|
||||
trans.commit()
|
||||
return res.lastrowid if res.lastrowid > 0 else True
|
||||
except OperationalError:
|
||||
# ON DUPLICATE KEY UPDATE is idempotent — safe to retry.
|
||||
if trans: trans.rollback()
|
||||
log.warning('Operational error in sql_insert_or_update. Retrying once...')
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
trans = conn.begin()
|
||||
res = conn.execute(stmt, data)
|
||||
trans.commit()
|
||||
return res.lastrowid if res.lastrowid > 0 else True
|
||||
except Exception as e:
|
||||
set_last_sql_error(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
if trans: trans.rollback()
|
||||
log.exception(e)
|
||||
@@ -309,6 +339,21 @@ def sql_select(
|
||||
return [] if as_list else None
|
||||
|
||||
rows = result.all()
|
||||
except OperationalError:
|
||||
# Transient connection failure — reads are always safe to retry.
|
||||
log.error('Operational error in sql_select. Retrying once with fresh connection...')
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
result = conn.execute(stmt, data)
|
||||
if not result:
|
||||
return [] if as_list else None
|
||||
if hasattr(result, 'returns_rows') and not result.returns_rows:
|
||||
return [] if as_list else None
|
||||
rows = result.all()
|
||||
except Exception as e:
|
||||
log.error(f"SQL Fetch Error on retry: {e}")
|
||||
set_last_sql_error(e)
|
||||
return False
|
||||
except Exception as e:
|
||||
log.error(f"SQL Fetch Error: {e}")
|
||||
set_last_sql_error(e)
|
||||
@@ -343,7 +388,6 @@ def run_sql_select(
|
||||
return conn.execute(sql, data)
|
||||
except (OperationalError, ProgrammingError) as e:
|
||||
log.error(f'DB Error: {e}. Retrying once...')
|
||||
sql_connect()
|
||||
try:
|
||||
with lib_sql_core.engine.connect() as conn:
|
||||
return conn.execute(sql, data)
|
||||
|
||||
@@ -199,7 +199,11 @@ def sql_search_qry_part(
|
||||
if hasattr(item, 'field'):
|
||||
clause, item_data = process_filter(item)
|
||||
node_clauses.append(clause); data.update(item_data)
|
||||
else: node_clauses.append(f"({process_node(item, current_depth + 1)})")
|
||||
else:
|
||||
# Recurse into nested SearchQuery; only append if non-empty
|
||||
sub_clause = process_node(item, current_depth + 1)
|
||||
if sub_clause:
|
||||
node_clauses.append(f"({sub_clause})")
|
||||
if node_clauses:
|
||||
joiner = ' AND ' if 'and' in filter_attr else ' OR '
|
||||
clauses.append(f"({joiner.join(node_clauses)})")
|
||||
@@ -261,6 +265,18 @@ def sql_search_qry_part(
|
||||
except Exception as e:
|
||||
log.warning(f"Failed to resolve random ID for field {target_field}: {e}")
|
||||
|
||||
# site_domain: 'access_key' is a virtual field.
|
||||
# site_access_key (site-level) takes priority; fall back to site_domain_access_key
|
||||
# when site_access_key is not set (NULL or empty).
|
||||
if target_field == 'access_key' and table_name and 'site_domain' in table_name:
|
||||
sql_op = operator_map.get(f.op.lower())
|
||||
if not sql_op: raise HTTPException(status_code=400, detail=f"Unsupported operator: {f.op}")
|
||||
p1, p2 = get_param_name(), get_param_name()
|
||||
return (
|
||||
f"(site_access_key {sql_op} :{p1} OR "
|
||||
f"((site_access_key IS NULL OR site_access_key = '') AND site_domain_access_key {sql_op} :{p2}))"
|
||||
), {p1: f.value, p2: f.value}
|
||||
|
||||
if searchable_fields is not None and target_field not in searchable_fields:
|
||||
# Fallback check for original field just in case
|
||||
if f.field not in searchable_fields:
|
||||
|
||||
@@ -96,7 +96,7 @@ app = FastAPI(
|
||||
# debug = True,
|
||||
title = 'Aether API',
|
||||
description = 'One Sky IT\'s Aether API v3.0 using FastAPI.',
|
||||
version = '3.00.03',
|
||||
version = '3.00.10',
|
||||
operationsSorter = 'method',
|
||||
lifespan = lifespan,
|
||||
)
|
||||
|
||||
@@ -322,10 +322,9 @@ def create_update_event_badge_obj_v4(
|
||||
elif event_person_id := event_badge_obj.event_person_id: pass
|
||||
|
||||
if event_badge_id:
|
||||
if event_badge_dict_up_result := sql_update(data=event_badge_dict, table_name='event_badge', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Badge not updated. Event Badge ID: {event_badge_id}')
|
||||
log.debug(event_badge_dict_up_result)
|
||||
event_badge_dict_up_result = sql_update(data=event_badge_dict, table_name='event_badge', record_id=event_badge_id, rm_id_random=True)
|
||||
if event_badge_dict_up_result is False:
|
||||
log.warning(f'Event Badge update failed (DB error). Event Badge ID: {event_badge_id}')
|
||||
return False
|
||||
log.debug(event_badge_dict_up_result)
|
||||
else:
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import datetime
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, PrivateAttr, ValidationError, validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random, sql_insert, sql_select, sql_update
|
||||
from app.lib_general import log, logging
|
||||
|
||||
from app.methods.event_methods import load_event_obj
|
||||
|
||||
|
||||
# ### BEGIN ### API Event Methods ### load_event_obj_list() ###
|
||||
def load_event_obj_list(
|
||||
account_id: int|str,
|
||||
limit: int = 1000,
|
||||
model_as_dict: bool = False,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
inc_contact_1: bool = False,
|
||||
inc_contact_2: bool = False,
|
||||
inc_contact_3: bool = False,
|
||||
inc_event_abstract_list: bool = False,
|
||||
inc_event_badge_list: bool = False,
|
||||
inc_event_cfg: bool = False,
|
||||
inc_event_device_list: bool = False,
|
||||
inc_event_exhibit_list: bool = False,
|
||||
inc_event_file_list: bool = False,
|
||||
inc_event_location: bool = False, # For event_session child object
|
||||
inc_event_location_list: bool = False,
|
||||
inc_event_person_list: bool = False,
|
||||
inc_event_presentation_list: bool = False,
|
||||
inc_event_presenter_cat: bool = False, # For event_session child object
|
||||
inc_event_presenter_list: bool = False,
|
||||
inc_event_registration_cfg: bool = False,
|
||||
inc_event_registration_list: bool = False,
|
||||
inc_event_session_list: bool = False,
|
||||
inc_event_track: bool = False, # For event_session child object
|
||||
inc_event_track_list: bool = False,
|
||||
inc_location_address: bool = False,
|
||||
inc_poc_event_person: bool = False,
|
||||
inc_person: bool = False,
|
||||
inc_user: bool = False,
|
||||
) -> list|bool:
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
|
||||
else: return False
|
||||
|
||||
data = {}
|
||||
data['account_id'] = account_id
|
||||
|
||||
if enabled in ['enabled', 'disabled', 'all']:
|
||||
if enabled == 'enabled':
|
||||
data['enable'] = True
|
||||
sql_enabled = f'AND `tbl`.enable = :enable'
|
||||
elif enabled == 'disabled':
|
||||
data['enable'] = False
|
||||
sql_enabled = f'AND `tbl`.enable = :enable'
|
||||
elif enabled == 'all':
|
||||
sql_enabled = ''
|
||||
# else: tbl_obj['account'] = None
|
||||
|
||||
if limit:
|
||||
data['limit'] = limit
|
||||
sql_limit = f'LIMIT :limit'
|
||||
else:
|
||||
sql_limit = ''
|
||||
|
||||
sql = f"""
|
||||
SELECT `tbl`.id AS 'event_id', `tbl`.id_random AS 'event_id_random'
|
||||
FROM `event` AS `tbl`
|
||||
WHERE `tbl`.account_id = :account_id
|
||||
{sql_enabled}
|
||||
ORDER BY `tbl`.created_on DESC, `tbl`.updated_on DESC
|
||||
{sql_limit};
|
||||
"""
|
||||
|
||||
if event_rec_li_result := sql_select(data=data, sql=sql, as_list=True):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(event_rec_li_result)
|
||||
event_result_li = []
|
||||
for event_rec in event_rec_li_result:
|
||||
event_id = event_rec.get('event_id', None)
|
||||
if event_result := load_event_obj(
|
||||
event_id = event_id,
|
||||
limit = limit,
|
||||
model_as_dict = model_as_dict,
|
||||
enabled = enabled,
|
||||
# inc_location_address = inc_address,
|
||||
# inc_contact_1 = inc_contact,
|
||||
# inc_contact_2 = inc_contact,
|
||||
# inc_contact_3 = inc_contact,
|
||||
# inc_event_abstract_list = inc_event_abstract_list,
|
||||
# inc_event_badge_list = inc_event_badge_list,
|
||||
# inc_event_device_list = inc_event_device_list,
|
||||
inc_event_exhibit_list = inc_event_exhibit_list,
|
||||
inc_event_file_list = inc_event_file_list,
|
||||
inc_event_location_list = inc_event_location_list,
|
||||
inc_event_person_list = inc_event_person_list,
|
||||
inc_event_presentation_list = inc_event_presentation_list,
|
||||
inc_event_presenter_list = inc_event_presenter_list,
|
||||
inc_event_registration_list = inc_event_registration_list,
|
||||
inc_event_session_list = inc_event_session_list,
|
||||
inc_event_track_list = inc_event_track_list,
|
||||
# inc_person = inc_person,
|
||||
# inc_user = inc_user,
|
||||
):
|
||||
log.debug(event_result)
|
||||
event_result_li.append(event_result)
|
||||
else:
|
||||
log.debug(event_result)
|
||||
event_result_li.append(None)
|
||||
log.debug(event_result_li)
|
||||
else:
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(event_rec_li_result)
|
||||
event_result_li = []
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
|
||||
return event_result_li
|
||||
# ### END ### API Event Methods ### load_event_obj_list() ###
|
||||
@@ -3,7 +3,7 @@ import datetime
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, PrivateAttr, ValidationError, validator
|
||||
|
||||
from app.db_sql import get_account_id_w_for_type_id, redis_lookup_id_random, sql_insert, sql_select, sql_update
|
||||
from app.db_sql import get_account_id_w_for_type_id, redis_lookup_id_random, sql_insert, sql_select, sql_update, get_id_random
|
||||
from app.lib_general import log, logging, logger_reset
|
||||
|
||||
# from app.methods.event_abstract_methods import load_event_abstract_obj
|
||||
@@ -355,7 +355,7 @@ def create_update_event_person_obj_v4(
|
||||
fail_any: bool = False, # Fail if any thing goes wrong for sub objects
|
||||
return_outline: bool = False,
|
||||
) -> int|bool:
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
@@ -420,7 +420,19 @@ def create_update_event_person_obj_v4(
|
||||
if account_id:
|
||||
event_person_dict['account_id'] = account_id
|
||||
if event_id:
|
||||
event_person_dict['event_id'] = event_id
|
||||
# The model expects random-string IDs (eg. id_random). If we have an
|
||||
# integer internal ID, convert it to the random string form so the
|
||||
# Pydantic root_validator preserves it. This ensures `event_id` is
|
||||
# present when inserting a new `event_person` record.
|
||||
if isinstance(event_id, int):
|
||||
if idr := get_id_random(record_id=event_id, table_name='event'):
|
||||
event_person_dict['event_id_random'] = idr
|
||||
else:
|
||||
# Fallback: set the integer (will likely be removed by the model),
|
||||
# but allow downstream logic to attempt insertion.
|
||||
event_person_dict['event_id'] = event_id
|
||||
else:
|
||||
event_person_dict['event_id'] = event_id
|
||||
try:
|
||||
event_person_obj = Event_Person_Base(**event_person_dict)
|
||||
except ValidationError as e:
|
||||
@@ -434,7 +446,16 @@ def create_update_event_person_obj_v4(
|
||||
if account_id:
|
||||
event_person_obj.account_id = account_id
|
||||
if event_id:
|
||||
event_person_obj.event_id = event_id
|
||||
# If an integer internal ID was provided, convert to the random ID
|
||||
# string form for the Pydantic object so it is preserved when
|
||||
# serializing to the DB insert/update payload.
|
||||
if isinstance(event_id, int):
|
||||
if idr := get_id_random(record_id=event_id, table_name='event'):
|
||||
event_person_obj.event_id = idr
|
||||
else:
|
||||
event_person_obj.event_id = event_id
|
||||
else:
|
||||
event_person_obj.event_id = event_id
|
||||
log.debug(event_person_obj)
|
||||
|
||||
event_person_dict = event_person_obj.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'event_badge', 'event_person_profile', 'event_registration', 'created_on', 'updated_on', 'external_id_old'})
|
||||
@@ -453,11 +474,11 @@ def create_update_event_person_obj_v4(
|
||||
event_person_profile_id = event_person_obj.event_person_profile_id
|
||||
|
||||
if event_person_id:
|
||||
if event_person_dict_up_result := sql_update(data=event_person_dict, table_name='event_person', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Person not updated. Event Person ID: {event_person_id}')
|
||||
log.debug(event_person_dict_up_result)
|
||||
event_person_dict_up_result = sql_update(data=event_person_dict, table_name='event_person', record_id=event_person_id, rm_id_random=True)
|
||||
if event_person_dict_up_result is False:
|
||||
log.warning(f'Event Person update failed (DB error). Event Person ID: {event_person_id}')
|
||||
return False
|
||||
# None means 0 rows affected (record unchanged) — not an error, continue to sub-objects
|
||||
log.debug(event_person_dict_up_result)
|
||||
else:
|
||||
if event_person_dict_in_result := sql_insert(data=event_person_dict, table_name='event_person', rm_id_random=True, id_random_length=None): pass
|
||||
|
||||
@@ -154,11 +154,12 @@ def create_update_event_person_profile_obj_v4(
|
||||
contact_id = event_person_profile_obj.contact_id
|
||||
|
||||
if event_person_profile_id:
|
||||
if event_person_profile_dict_up_result := sql_update(data=event_person_profile_dict, table_name='event_person_profile', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Person Profile not updated. Event Person Profile ID: {event_person_profile_id}')
|
||||
event_person_profile_dict_up_result = sql_update(data=event_person_profile_dict, table_name='event_person_profile', record_id=event_person_profile_id, rm_id_random=True)
|
||||
if event_person_profile_dict_up_result is False:
|
||||
log.warning(f'Event Person Profile update failed (DB error). Event Person Profile ID: {event_person_profile_id}')
|
||||
log.debug(event_person_profile_dict_up_result)
|
||||
return False
|
||||
# None means 0 rows affected (record unchanged) — not an error
|
||||
log.debug(event_person_profile_dict_up_result)
|
||||
else:
|
||||
if event_person_profile_dict_in_result := sql_insert(data=event_person_profile_dict, table_name='event_person_profile', rm_id_random=True, id_random_length=8): pass
|
||||
|
||||
@@ -429,9 +429,9 @@ def create_update_event_presentation_obj_v4(
|
||||
event_presentation_dict = event_presentation_obj.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'event_presenter', 'event_presenter_list', 'created_on', 'updated_on'})
|
||||
|
||||
if event_presentation_id:
|
||||
if event_presentation_dict_up_result := sql_update(data=event_presentation_dict, table_name='event_presentation', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Presentation not updated. Event Presentation ID: {event_presentation_id}')
|
||||
event_presentation_dict_up_result = sql_update(data=event_presentation_dict, table_name='event_presentation', record_id=event_presentation_id, rm_id_random=True)
|
||||
if event_presentation_dict_up_result is False:
|
||||
log.warning(f'Event Presentation update failed (DB error). Event Presentation ID: {event_presentation_id}')
|
||||
log.debug(event_presentation_dict_up_result)
|
||||
return False
|
||||
log.debug(event_presentation_dict_up_result)
|
||||
|
||||
@@ -404,9 +404,9 @@ def create_update_event_presenter_obj_v4(
|
||||
event_presenter_dict = event_presenter_obj.dict(by_alias=False, exclude_defaults=False, exclude_unset=True, exclude={'created_on', 'updated_on'})
|
||||
|
||||
if event_presenter_id:
|
||||
if event_presenter_dict_up_result := sql_update(data=event_presenter_dict, table_name='event_presenter', rm_id_random=True): pass
|
||||
else:
|
||||
log.warning(f'Event Presenter not updated. Event Presenter ID: {event_presenter_id}')
|
||||
event_presenter_dict_up_result = sql_update(data=event_presenter_dict, table_name='event_presenter', record_id=event_presenter_id, rm_id_random=True)
|
||||
if event_presenter_dict_up_result is False:
|
||||
log.warning(f'Event Presenter update failed (DB error). Event Presenter ID: {event_presenter_id}')
|
||||
log.debug(event_presenter_dict_up_result)
|
||||
return False
|
||||
log.debug(event_presenter_dict_up_result)
|
||||
|
||||
154
app/methods/idaa_novi_verify_methods.py
Normal file
154
app/methods/idaa_novi_verify_methods.py
Normal file
@@ -0,0 +1,154 @@
|
||||
import datetime
|
||||
import json
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.lib_general import log, logger_reset
|
||||
|
||||
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
|
||||
_CACHE_TTL = datetime.timedelta(hours=4)
|
||||
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def _load_idaa_cfg() -> Optional[Dict]:
|
||||
"""Load IDAA site cfg_json. Returns parsed dict or None on failure."""
|
||||
from app.methods.site_methods import load_site_obj
|
||||
site = load_site_obj(site_id=IDAA_SITE_ID_RANDOM, model_as_dict=True)
|
||||
if not site:
|
||||
log.error("Could not load IDAA site record (id_random='%s').", IDAA_SITE_ID_RANDOM)
|
||||
return None
|
||||
cfg = site.get('cfg_json')
|
||||
if isinstance(cfg, str):
|
||||
try:
|
||||
cfg = json.loads(cfg)
|
||||
except Exception as e:
|
||||
log.error("Failed to parse IDAA cfg_json: %s", e)
|
||||
return None
|
||||
if not isinstance(cfg, dict):
|
||||
log.error("IDAA cfg_json is not a dict after parsing.")
|
||||
return None
|
||||
return cfg
|
||||
|
||||
|
||||
def _cache_key(uuid: str) -> str:
|
||||
return f'idaa:novi_member:{uuid}'
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
@logger_reset
|
||||
def verify_novi_member(uuid: str) -> Dict:
|
||||
"""
|
||||
Proxy GET /customers/{uuid} to Novi AMS and return normalized member data.
|
||||
|
||||
Returns a dict with one of:
|
||||
{'status': 200, 'verified': True, 'full_name': '...', 'email': '...'}
|
||||
{'status': 404, 'reason': '...'}
|
||||
{'status': 429, 'reason': '...'}
|
||||
{'status': 503, 'reason': '...'}
|
||||
|
||||
Redis cache key: idaa:novi_member:{uuid}, TTL 4 hours.
|
||||
Only 200 (verified) results are cached — 404 is never cached.
|
||||
"""
|
||||
from app.lib_redis_helpers import redis_client
|
||||
|
||||
cache_key = _cache_key(uuid)
|
||||
|
||||
# ── Cache hit ─────────────────────────────────────────────────────────
|
||||
cached_raw = redis_client.get(cache_key)
|
||||
if cached_raw:
|
||||
try:
|
||||
cached = json.loads(cached_raw)
|
||||
log.info("Novi verify cache hit: %s", uuid)
|
||||
return cached
|
||||
except Exception:
|
||||
pass # corrupt cache entry — fall through to Novi
|
||||
|
||||
# ── Load credentials ──────────────────────────────────────────────────
|
||||
cfg = _load_idaa_cfg()
|
||||
if not cfg:
|
||||
return {'status': 503, 'reason': 'IDAA site configuration unavailable.'}
|
||||
|
||||
base_url = cfg.get('novi_api_root_url', '').rstrip('/')
|
||||
api_key = cfg.get('novi_idaa_api_key', '')
|
||||
|
||||
if not base_url or not api_key:
|
||||
log.error("novi_api_root_url or novi_idaa_api_key missing from IDAA cfg_json.")
|
||||
return {'status': 503, 'reason': 'Novi credentials not configured.'}
|
||||
|
||||
headers = {'Authorization': f'Basic {api_key}', 'Accept': 'application/json'}
|
||||
|
||||
# ── Call Novi ─────────────────────────────────────────────────────────
|
||||
try:
|
||||
resp = requests.get(f'{base_url}/customers/{uuid}', headers=headers, timeout=10)
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
log.error("Novi unreachable: %s", e)
|
||||
return {'status': 503, 'reason': 'Novi API unreachable.'}
|
||||
except requests.exceptions.Timeout:
|
||||
log.error("Novi request timed out for UUID %s", uuid)
|
||||
return {'status': 503, 'reason': 'Novi API timed out.'}
|
||||
except Exception as e:
|
||||
log.exception("Unexpected error calling Novi for UUID %s: %s", uuid, e)
|
||||
return {'status': 503, 'reason': 'Unexpected error contacting Novi.'}
|
||||
|
||||
if resp.status_code == 429:
|
||||
log.warning("Novi rate limit hit for UUID %s", uuid)
|
||||
return {'status': 429, 'reason': 'Novi rate limit exceeded. Try again shortly.'}
|
||||
|
||||
if resp.status_code >= 500:
|
||||
log.error("Novi server error %s for UUID %s", resp.status_code, uuid)
|
||||
return {'status': 503, 'reason': f'Novi server error ({resp.status_code}).'}
|
||||
|
||||
if resp.status_code == 404:
|
||||
log.info("Novi returned 404 for UUID %s", uuid)
|
||||
return {'status': 404, 'reason': 'Member not found in Novi.'}
|
||||
|
||||
if resp.status_code != 200:
|
||||
log.error("Unexpected Novi status %s for UUID %s: %s", resp.status_code, uuid, resp.text[:200])
|
||||
return {'status': 503, 'reason': f'Unexpected Novi response ({resp.status_code}).'}
|
||||
|
||||
# ── Parse response ────────────────────────────────────────────────────
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
log.error("Novi returned non-JSON for UUID %s", uuid)
|
||||
return {'status': 503, 'reason': 'Novi returned an unparseable response.'}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
log.warning("Novi returned non-dict body for UUID %s", uuid)
|
||||
return {'status': 404, 'reason': 'Member not found in Novi (empty response).'}
|
||||
|
||||
# Empty-member anti-pattern: Novi 200 with no identity data
|
||||
email_raw = (data.get('Email') or '').strip()
|
||||
if not email_raw:
|
||||
log.info("Novi 200 with no Email for UUID %s — empty-member anti-pattern", uuid)
|
||||
return {'status': 404, 'reason': 'Member not found in Novi (no identity data).'}
|
||||
|
||||
email = email_raw.replace(' ', '+')
|
||||
|
||||
# Build display name: "FirstName LastName[0]." — fall back to Name field
|
||||
first = (data.get('FirstName') or '').strip()
|
||||
last = (data.get('LastName') or '').strip()
|
||||
if first and last:
|
||||
full_name = f'{first} {last[0]}.'
|
||||
elif first:
|
||||
full_name = first
|
||||
else:
|
||||
full_name = (data.get('Name') or '').strip() or 'Member'
|
||||
|
||||
result = {
|
||||
'status': 200,
|
||||
'verified': True,
|
||||
'full_name': full_name,
|
||||
'email': email,
|
||||
}
|
||||
|
||||
# ── Cache verified result ─────────────────────────────────────────────
|
||||
try:
|
||||
redis_client.setex(cache_key, _CACHE_TTL, json.dumps(result))
|
||||
except Exception as e:
|
||||
log.warning("Failed to cache Novi verify result for %s: %s", uuid, e)
|
||||
|
||||
return result
|
||||
@@ -147,8 +147,9 @@ def get_site_domain_rec_list(
|
||||
# ### BEGIN ### API Site Domain Methods ### lookup_site_domain_fqdn() ###
|
||||
def lookup_site_domain_fqdn(
|
||||
fqdn: str,
|
||||
# TODO: Accept access_key as an argument for validation (str|None)
|
||||
# access_key: Optional[str] = None,
|
||||
# Accept access_key as an argument for validation (str|None)
|
||||
access_key: Optional[str] = None,
|
||||
referrer: Optional[str] = None,
|
||||
enabled: str = 'enabled', # enabled, disabled, all
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
@@ -158,22 +159,37 @@ def lookup_site_domain_fqdn(
|
||||
|
||||
data = {}
|
||||
data['fqdn'] = fqdn
|
||||
# TODO: If access_key is provided, add it to the data dict for SQL parameterization
|
||||
# if access_key is not None:
|
||||
# data['access_key'] = access_key
|
||||
# If access_key is provided, add it to the data dict for SQL parameterization
|
||||
data['domain_access_key'] = access_key
|
||||
if referrer:
|
||||
data['required_referrer'] = referrer
|
||||
|
||||
sql_enabled, data['enable'] = sql_enable_part(table_name='site_domain', enabled=enabled) # Reasonably safe return str and bool
|
||||
sql_limit = sql_limit_offset_part(limit=limit, offset=offset) # Reasonably safe return str
|
||||
|
||||
# TODO: Add access_key to WHERE clause if provided, e.g.:
|
||||
# WHERE site_domain.fqdn = :fqdn AND (:access_key IS NULL OR site_domain.access_key = :access_key)
|
||||
# Build access key / referrer SQL similar to router.lookup_fqdn behavior
|
||||
if access_key and referrer:
|
||||
sql_access_key_referrer = """
|
||||
AND site_domain.domain_access_key = :domain_access_key
|
||||
AND site_domain.required_referrer = :required_referrer
|
||||
"""
|
||||
elif access_key:
|
||||
sql_access_key_referrer = """
|
||||
AND site_domain.domain_access_key = :domain_access_key
|
||||
AND (site_domain.required_referrer IS NULL OR site_domain.required_referrer = '')
|
||||
"""
|
||||
else:
|
||||
sql_access_key_referrer = """
|
||||
AND (site_domain.domain_access_key IS NULL OR site_domain.domain_access_key = '')
|
||||
AND (site_domain.required_referrer IS NULL OR site_domain.required_referrer = '')
|
||||
"""
|
||||
|
||||
sql = f"""
|
||||
SELECT `site_domain`.id AS 'site_domain_id', `site_domain`.id_random AS 'site_domain_id_random'
|
||||
FROM `v_site_domain` AS site_domain
|
||||
WHERE
|
||||
site_domain.fqdn = :fqdn
|
||||
-- TODO: Add access_key check here for stricter validation
|
||||
-- AND (:access_key IS NULL OR site_domain.access_key = :access_key)
|
||||
{sql_access_key_referrer}
|
||||
{sql_enabled}
|
||||
ORDER BY `site_domain`.fqdn ASC, `site_domain`.access_key ASC, `site_domain`.required_referrer ASC, `site_domain`.created_on DESC, `site_domain`.updated_on DESC
|
||||
{sql_limit};
|
||||
|
||||
@@ -36,6 +36,9 @@ class Archive_Content_Base(BaseModel):
|
||||
lu_media_type_id: Optional[int]
|
||||
lu_media_type: Optional[str]
|
||||
|
||||
external_id: Optional[str]
|
||||
code: Optional[str]
|
||||
|
||||
name: Optional[str]
|
||||
description: Optional[str]
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
header_background: Optional[str]
|
||||
|
||||
secondary_header_path: Optional[str] # Path to image file for back of badge and other sections
|
||||
background_image_path: Optional[str]
|
||||
|
||||
footer_path: Optional[str] # Path to image file
|
||||
footer_title: Optional[str]
|
||||
@@ -73,7 +74,8 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
script_src: Optional[str]
|
||||
passcode: Optional[str]
|
||||
|
||||
other_json: Optional[str]
|
||||
other_json: Optional[Json]
|
||||
cfg_json: Optional[Json]
|
||||
|
||||
enable: Optional[bool]
|
||||
hide: Optional[bool]
|
||||
@@ -96,18 +98,18 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('event_badge_template_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_badge_template_id'] = rid
|
||||
|
||||
|
||||
if e_rid := values.get('event_id_random'):
|
||||
values['event_id'] = e_rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population" (ensure no integers leak into the clean string fields)
|
||||
for k in ['id', 'event_badge_template_id', 'event_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -59,7 +59,7 @@ class Event_Base(BaseModel):
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if pep_rid := values.get('poc_event_person_id_random'): values['poc_event_person_id'] = pep_rid
|
||||
if pp_rid := values.get('poc_person_id_random'): values['poc_person_id'] = pp_rid
|
||||
@@ -68,7 +68,7 @@ class Event_Base(BaseModel):
|
||||
if c1_rid := values.get('contact_1_id_random'): values['contact_1_id'] = c1_rid
|
||||
if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid
|
||||
if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
# WE MUST NOT DELETE these if they are already integers during a POST operation
|
||||
# as they have been resolved by sanitize_payload.
|
||||
@@ -77,7 +77,7 @@ class Event_Base(BaseModel):
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
code: Optional[str] = Field(
|
||||
@@ -171,6 +171,7 @@ class Event_Base(BaseModel):
|
||||
|
||||
cfg_json: Optional[Union[Json, None]] # Store per event config options; Not currently used 2024-06-11
|
||||
data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields! Not currently used 2024-06-11
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering events. Updated using SQL triggers and a SQL function
|
||||
|
||||
enable: Optional[bool] # Also in Event_Cfg_Base model
|
||||
enable_from: Optional[datetime.datetime] = None
|
||||
@@ -288,7 +289,7 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['event_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if pep_rid := values.get('poc_event_person_id_random'): values['poc_event_person_id'] = pep_rid
|
||||
if pp_rid := values.get('poc_person_id_random'): values['poc_person_id'] = pp_rid
|
||||
@@ -297,14 +298,14 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
if c1_rid := values.get('contact_1_id_random'): values['contact_1_id'] = c1_rid
|
||||
if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid
|
||||
if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
code: Optional[str] = Field(
|
||||
@@ -396,6 +397,7 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
|
||||
cfg_json: Optional[Union[Json, None]] # Store per event config options; Not currently used 2024-06-11
|
||||
data_json: Optional[Union[Json, None]] # For key value data. Careful with overwriting existing fields! Not currently used 2024-06-11
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering events. Updated using SQL triggers and a SQL function
|
||||
|
||||
enable: Optional[bool] # Also in Event_Cfg_Base model
|
||||
enable_from: Optional[datetime.datetime] = None
|
||||
@@ -413,7 +415,7 @@ class Event_Meeting_Flat_Base(BaseModel):
|
||||
# --- IDAA Recovery Meetings: Convenience Data (Flat) ---
|
||||
# These fields are primarily for the flat "Meeting" view used by the IDAA mobile/web apps.
|
||||
# Note: We prioritize string IDs (id_random) for all external API consumers.
|
||||
|
||||
|
||||
address_id_random: Optional[str] = Field(None, **base_fields['address_id_random'])
|
||||
address_name: Optional[str]
|
||||
address_line_1: Optional[str]
|
||||
|
||||
@@ -125,6 +125,7 @@ class Event_Presenter_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering presenters. Updated using SQL triggers and a SQL function
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
@@ -190,7 +191,7 @@ class Event_Presenter_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('event_presenter_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_presenter_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid
|
||||
@@ -198,12 +199,12 @@ class Event_Presenter_Base(BaseModel):
|
||||
if es_rid := values.get('event_session_id_random'): values['event_session_id'] = es_rid
|
||||
if et_rid := values.get('event_track_id_random'): values['event_track_id'] = et_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_presenter_id', 'account_id', 'event_id', 'event_person_id', 'event_presentation_id', 'event_session_id', 'event_track_id', 'person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table
|
||||
@@ -313,6 +314,7 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering presenters. Updated using SQL triggers and a SQL function
|
||||
|
||||
person_external_id: Optional[str]
|
||||
person_external_sys_id: Optional[str]
|
||||
@@ -338,18 +340,18 @@ class Event_Presenter_Out_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('event_presenter_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_presenter_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
|
||||
if epr_rid := values.get('event_presentation_id_random'): values['event_presentation_id'] = epr_rid
|
||||
if es_rid := values.get('event_session_id_random'): values['event_session_id'] = es_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_presenter_id', 'account_id', 'event_id', 'event_presentation_id', 'event_session_id', 'person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -138,6 +138,9 @@ class Event_Session_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
default_qry_str: Optional[str] # Default query string used for searching and filtering sessions. Updated using SQL triggers and a SQL function
|
||||
event_presentation_li_qry_str: Optional[str] # Concatenated query string of presentation data for this session (from v_event_session_w_file_count)
|
||||
event_presenter_li_qry_str: Optional[str] # Concatenated query string of presenter data for this session (from v_event_session_w_file_count)
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
@@ -193,7 +196,7 @@ class Event_Session_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('event_session_id_random'):
|
||||
values['id'] = rid
|
||||
values['event_session_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if e_rid := values.get('event_id_random'):
|
||||
@@ -206,24 +209,24 @@ class Event_Session_Base(BaseModel):
|
||||
values['poc_event_person_id'] = pep_rid
|
||||
if pp_rid := values.get('poc_person_id_random'):
|
||||
values['poc_person_id'] = pp_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'event_session_id', 'account_id', 'event_id', 'event_location_id', 'event_track_id', 'poc_event_person_id', 'poc_person_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table
|
||||
fields_to_exclude_from_db: ClassVar[list] = [
|
||||
'account_id',
|
||||
'file_count', 'internal_use_count', 'event_file_id_li_json', 'file_count_all',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_name', 'event_start_datetime', 'event_end_datetime',
|
||||
'event_location_name', 'event_track_name',
|
||||
'event_abstract_list', 'event_badge_list', 'event_device_list',
|
||||
'event_file_list', 'event_file_internal_use_list', 'event_location',
|
||||
'event_location_list', 'event_person_list', 'event_presenter_cat',
|
||||
'event_presentation_list', 'event_presenter_list', 'event_track',
|
||||
'event_abstract_list', 'event_badge_list', 'event_device_list',
|
||||
'event_file_list', 'event_file_internal_use_list', 'event_location',
|
||||
'event_location_list', 'event_person_list', 'event_presenter_cat',
|
||||
'event_presentation_list', 'event_presenter_list', 'event_track',
|
||||
'poc_event_person', 'poc_person',
|
||||
'poc_person_external_id', 'poc_person_given_name', 'poc_person_family_name',
|
||||
'poc_person_full_name', 'poc_person_primary_email', 'poc_person_passcode'
|
||||
|
||||
@@ -73,7 +73,7 @@ class Journal_Entry_Base(BaseModel):
|
||||
|
||||
parent_id: Optional[str] = Field(None, **base_fields['journal_entry_id_random'])
|
||||
# parent_id_random: Optional[str]
|
||||
|
||||
|
||||
related_entry_id_random: Optional[List[str]]
|
||||
related_entry_id_li: Optional[List[int]] = Field(None, exclude=True)
|
||||
|
||||
@@ -102,6 +102,7 @@ class Journal_Entry_Base(BaseModel):
|
||||
notes: Optional[str]
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
default_qry_str: Optional[str] = None # Default query string used for searching and filtering journal entries
|
||||
|
||||
# Including other related objects
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
@@ -119,21 +120,21 @@ class Journal_Entry_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('journal_entry_id_random'):
|
||||
values['id'] = rid
|
||||
values['journal_entry_id'] = rid
|
||||
|
||||
|
||||
if j_rid := values.get('journal_id_random'):
|
||||
values['journal_id'] = j_rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
if p_rid := values.get('parent_id_random'):
|
||||
values['parent_id'] = p_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'journal_entry_id', 'journal_id', 'account_id', 'parent_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
# Fields that are part of the model (for reading) but should not be saved to the DB table
|
||||
|
||||
@@ -32,7 +32,7 @@ class Post_Base(BaseModel):
|
||||
# type_id: Optional[int]
|
||||
|
||||
# topic_id_random: Optional[str]
|
||||
# topic_id: Optional[int]
|
||||
topic_id: Optional[int]
|
||||
|
||||
type: Optional[str]
|
||||
|
||||
@@ -101,18 +101,18 @@ class Post_Base(BaseModel):
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['post_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid
|
||||
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population" or leakage of integers during API responses
|
||||
for k in ['id', 'post_id', 'account_id', 'person_id', 'user_id']:
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -42,6 +42,23 @@ class Site_Domain_Base(BaseModel):
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Convenience fields from v_site_domain view (joined from account/site)
|
||||
account_code: Optional[str] = None
|
||||
account_name: Optional[str] = None
|
||||
account_enable: Optional[bool] = None
|
||||
account_enable_from: Optional[datetime.datetime] = None
|
||||
account_enable_to: Optional[datetime.datetime] = None
|
||||
|
||||
site_enable_from: Optional[datetime.datetime] = None
|
||||
site_enable_to: Optional[datetime.datetime] = None
|
||||
site_domain_access_key: Optional[str] = None
|
||||
|
||||
logo_path: Optional[str] = None
|
||||
style_href: Optional[str] = None
|
||||
script_src: Optional[str] = None
|
||||
|
||||
google_tracking_id: Optional[str] = None
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@root_validator(pre=True)
|
||||
@@ -55,18 +72,18 @@ class Site_Domain_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('site_domain_id_random'):
|
||||
values['id'] = rid
|
||||
values['site_domain_id'] = rid
|
||||
|
||||
|
||||
if s_rid := values.get('site_id_random'):
|
||||
values['site_id'] = s_rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'site_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
@@ -98,7 +115,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
|
||||
enable: Optional[bool]
|
||||
|
||||
hide: Optional[bool] = None
|
||||
|
||||
|
||||
notes: Optional[str] = None
|
||||
created_on: Optional[datetime.datetime] = None
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
@@ -133,7 +150,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
|
||||
values['site_id'] = s_rid
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
|
||||
|
||||
for k in ['id', 'site_id', 'account_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
@@ -124,7 +124,7 @@ cms_obj_li = {
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'site_id',
|
||||
'id_random', 'account_id_random', 'site_id_random',
|
||||
'fqdn', 'access_key', 'site_access_key',
|
||||
'fqdn', 'access_key', 'site_access_key', 'site_domain_access_key',
|
||||
'enable', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -135,7 +135,8 @@ events_presentation_obj_li = {
|
||||
'poc_person_full_name',
|
||||
'public', 'public_hide', 'hide_event_launcher',
|
||||
'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on',
|
||||
'default_qry_str', 'event_location_name'
|
||||
'default_qry_str', 'event_location_name',
|
||||
'event_presentation_li_qry_str', 'event_presenter_li_qry_str'
|
||||
],
|
||||
},
|
||||
'event_track': {
|
||||
|
||||
@@ -33,8 +33,8 @@ other_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
|
||||
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
|
||||
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
|
||||
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
|
||||
'priority', 'group', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -50,8 +50,8 @@ other_obj_li = {
|
||||
'base_name': Sponsorship_Cfg_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
|
||||
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
|
||||
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -86,9 +86,9 @@ other_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
|
||||
'archive_type_id_random', 'archive_type', 'name', 'description',
|
||||
'filename', 'original_location', 'enable', 'hide', 'priority',
|
||||
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
|
||||
'archive_type_id_random', 'archive_type', 'name', 'description',
|
||||
'filename', 'original_location', 'enable', 'hide', 'priority',
|
||||
'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -108,7 +108,7 @@ other_obj_li = {
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'archive_id', 'hosted_file_id',
|
||||
'id_random', 'archive_content_id_random', 'account_id_random', 'archive_id_random',
|
||||
'archive_content_type', 'lu_media_type', 'name', 'description',
|
||||
'archive_content_type', 'lu_media_type', 'external_id', 'code', 'name', 'description',
|
||||
'filename', 'file_extension', 'original_location', 'original_url',
|
||||
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
@@ -137,9 +137,9 @@ other_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
|
||||
'hash_sha256', 'title', 'description', 'filename', 'extension',
|
||||
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
|
||||
'hash_sha256', 'title', 'description', 'filename', 'extension',
|
||||
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
|
||||
'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -157,8 +157,8 @@ other_obj_li = {
|
||||
'base_name': Hosted_File_Link_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
|
||||
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
|
||||
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
|
||||
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
|
||||
'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
@@ -226,8 +226,8 @@ other_obj_li = {
|
||||
'base_name': Grant_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
|
||||
'code', 'name', 'description', 'enable', 'hide', 'priority', 'sort',
|
||||
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
|
||||
'code', 'name', 'description', 'enable', 'hide', 'priority', 'sort',
|
||||
'group', 'notes', 'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -4,15 +4,15 @@ from typing import Dict, List, Optional, Set, Union
|
||||
from sqlalchemy import text
|
||||
import json
|
||||
import time
|
||||
import secrets
|
||||
# import secrets
|
||||
import jwt as pyjwt # Avoid conflict with app.lib_jwt
|
||||
|
||||
from app.db_connection import db
|
||||
# from app.db_connection import db
|
||||
from app.lib_general import sign_jwt, decode_jwt, log, logging
|
||||
from app.config import settings
|
||||
from app.db_sql import sql_insert, sql_update, sql_select, redis_lookup_id_random, get_id_random
|
||||
|
||||
from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
|
||||
# from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
|
||||
from app.routers.dependencies_v3 import DeprecationParams
|
||||
from app.models.api_models import Api_Base
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
@@ -21,10 +21,21 @@ router = APIRouter()
|
||||
|
||||
# --- Passcode Authentication ---
|
||||
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600, # 8 hours
|
||||
'manager': 24 * 3600, # 24 hours
|
||||
'administrator': 48 * 3600, # 48 hours
|
||||
'trusted': 48 * 3600, # 48 hours
|
||||
'public': 24 * 3600, # 24 hours
|
||||
'authenticated': 12 * 3600, # 12 hours
|
||||
}
|
||||
|
||||
class PasscodeAuthRequest(BaseModel):
|
||||
"""Request model for site-based passcode authentication."""
|
||||
site_id: str = Field(..., description="The random string ID of the site")
|
||||
passcode: str = Field(..., description="The passcode to verify")
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
|
||||
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||
async def authenticate_passcode(
|
||||
@@ -54,10 +65,11 @@ async def authenticate_passcode(
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||
|
||||
# 3. Verify Passcode and Resolve Role
|
||||
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||
matched_role = None
|
||||
for role, code in access_codes.items():
|
||||
if str(code) == str(passcode):
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
|
||||
@@ -70,22 +82,25 @@ async def authenticate_passcode(
|
||||
if account_id_int := record.get('account_id'):
|
||||
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||
|
||||
# 5. Mint JWT
|
||||
# 5. Mint JWT with complete role flags and per-role TTL
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'super': (matched_role == 'super'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode',
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
})
|
||||
}
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=3600 * 24, # 24 hour session
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**payload
|
||||
)
|
||||
|
||||
@@ -99,6 +114,8 @@ async def authenticate_passcode(
|
||||
|
||||
# --- JWT Request ---
|
||||
|
||||
# DEPRECATED — no V3 replacement needed; passcode→JWT is the V3 auth pattern (/api/authenticate_passcode).
|
||||
# No frontend references found. Safe to remove after confirming no live traffic. TODO: remove.
|
||||
@router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
|
||||
async def request_jwt(
|
||||
x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22),
|
||||
@@ -152,6 +169,7 @@ async def request_jwt(
|
||||
token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload)
|
||||
return mk_resp(data={ 'jwt': token })
|
||||
|
||||
# DEPRECATED — no active use identified. TODO: remove after confirming no live traffic.
|
||||
@router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
|
||||
async def get_api_temp_token(
|
||||
x_aether_api_key: Optional[str] = Header(None),
|
||||
@@ -168,7 +186,9 @@ async def get_api_temp_token(
|
||||
|
||||
# --- Jitsi Token ---
|
||||
|
||||
# NOTE: This is still actively used by IDAA for their video conferences using self hosted Jitsi. Thi is actually live. We do need to change the app secret once things have stabilized.
|
||||
# NOTE: Actively used by IDAA for video conferences on self-hosted Jitsi (jitsi.dgrzone.com).
|
||||
# JWT_APP_ID and JWT_APP_SECRET must match the values in the Jitsi server .env file.
|
||||
# TODO: Rotate JWT_APP_SECRET — update it here AND in /mnt/nfs_dgr_storage/env/dgr_zone_jitsi/.env (JWT_APP_SECRET) then restart prosody + jicofo.
|
||||
|
||||
JWT_APP_ID = "my_jitsi_app_id"
|
||||
JWT_APP_SECRET = "my_jitsi_app_secret-9876543210"
|
||||
@@ -187,14 +207,12 @@ class JitsiTokenRequest(BaseModel):
|
||||
@router.post("/jitsi_token")
|
||||
async def create_jitsi_jwt(request_data: JitsiTokenRequest = Body(...)):
|
||||
log.setLevel(logging.INFO)
|
||||
if not request_data.is_moderator:
|
||||
raise HTTPException(status_code=403, detail="JWT generation is only permitted for moderators.")
|
||||
|
||||
try:
|
||||
payload = {
|
||||
"aud": JWT_APP_ID, "iss": JWT_APP_ID, "sub": JITSI_DOMAIN,
|
||||
"room": request_data.room,
|
||||
"exp": int(time.time()) + 3600,
|
||||
"exp": int(time.time()) + 7200, # 2 hour expiry
|
||||
"config": request_data.config or {},
|
||||
"context": {
|
||||
"user": {
|
||||
|
||||
@@ -61,16 +61,16 @@ async def get_obj_schema(
|
||||
):
|
||||
"""
|
||||
Dynamic Schema Introspection.
|
||||
|
||||
|
||||
Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly.
|
||||
Returns:
|
||||
- Database column definitions (types, defaults, nullability).
|
||||
- Pydantic model field definitions (validation rules, aliases).
|
||||
|
||||
|
||||
This enables dynamic form generation without hardcoding schemas in the frontend.
|
||||
"""
|
||||
schema_info = get_object_schema_info(obj_type, view, variant)
|
||||
|
||||
|
||||
if "error" in schema_info:
|
||||
status_code = 400 if "not found" in schema_info["error"] else 500
|
||||
return mk_resp(data=False, status_code=status_code, response=response, status_message=schema_info["error"])
|
||||
@@ -87,7 +87,7 @@ async def validate_obj_payload(
|
||||
):
|
||||
"""
|
||||
Dry-Run Payload Validation.
|
||||
|
||||
|
||||
Verifies that a payload is valid according to the Pydantic model
|
||||
without performing any database operations.
|
||||
"""
|
||||
@@ -118,7 +118,7 @@ async def get_obj(
|
||||
):
|
||||
"""
|
||||
Retrieve a Single Object.
|
||||
|
||||
|
||||
1. Resolves the public `id_random` (string) to the internal `id` (integer).
|
||||
2. Performs a SQL SELECT.
|
||||
3. Enforces Multi-Tenant access checks.
|
||||
@@ -149,10 +149,10 @@ async def get_obj(
|
||||
if account.auth_method == 'guest' or (account.account_id is None and not account.super):
|
||||
reason = account.auth_error or "Account context required."
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message=reason)
|
||||
|
||||
|
||||
if not check_account_access(sql_result, account, obj_name):
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied. Record belongs to another account.")
|
||||
|
||||
|
||||
# Pass inc_hosted_file to the Pydantic model if applicable
|
||||
if obj_name == 'event_file' and inc_hosted_file:
|
||||
sql_result['inc_hosted_file'] = True
|
||||
@@ -181,7 +181,7 @@ async def get_obj_li(
|
||||
):
|
||||
"""
|
||||
List Objects (Pagination & Filtering).
|
||||
|
||||
|
||||
Supports:
|
||||
- Standard filtering (enabled/hidden).
|
||||
- Advanced filtering via JSON Payload (`jp`) param (Search, Fulltext, AND/OR queries).
|
||||
@@ -199,7 +199,7 @@ async def get_obj_li(
|
||||
and_like_dict_obj = None
|
||||
or_like_dict_obj = None
|
||||
and_in_dict_li_obj = None
|
||||
|
||||
|
||||
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
|
||||
if jp_obj:
|
||||
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
|
||||
@@ -213,7 +213,7 @@ async def get_obj_li(
|
||||
obj_name = obj_type_l1
|
||||
if obj_name not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.")
|
||||
|
||||
|
||||
obj_cfg = obj_type_kv_li[obj_name]
|
||||
|
||||
if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id):
|
||||
@@ -232,7 +232,7 @@ async def get_obj_li(
|
||||
|
||||
order_by_li = filter_order_by(order_by_li, base_name, table_name)
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
|
||||
|
||||
if not obj_cfg.get('public_read', False):
|
||||
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name)
|
||||
|
||||
@@ -278,10 +278,10 @@ async def get_obj_li(
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
|
||||
|
||||
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
|
||||
|
||||
return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict())
|
||||
|
||||
if sql_result:
|
||||
@@ -308,7 +308,7 @@ async def search_obj_li(
|
||||
):
|
||||
"""
|
||||
Search Objects (POST).
|
||||
|
||||
|
||||
Advanced search endpoint using `SearchQuery` body.
|
||||
- Security: Guests can access specific objects (e.g., site_domain) if permitted.
|
||||
- Filtering: Supports dynamic AND/OR filters built from the frontend.
|
||||
@@ -343,6 +343,31 @@ async def search_obj_li(
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
searchable_fields = obj_cfg.get('searchable_fields')
|
||||
|
||||
# site_domain access-key enforcement:
|
||||
# - site_access_key (site-level) takes priority; site_domain_access_key used as fallback.
|
||||
# - A domain is public only if site_domain_access_key is NULL/empty (and site_access_key is also unset).
|
||||
# - Falsy access_key values (empty string, None) are stripped — treated as "no key".
|
||||
# - When a key IS provided, lib_sql_search handles the SQL expansion (see process_filter).
|
||||
if obj_name == 'site_domain':
|
||||
# Sanity check: drop access_key filters with falsy values
|
||||
if search_query.and_filters:
|
||||
search_query.and_filters = [
|
||||
f for f in search_query.and_filters
|
||||
if not (isinstance(f, SearchFilter) and f.field == 'access_key' and not f.value)
|
||||
]
|
||||
key_fields = {'access_key', 'site_access_key', 'site_domain_access_key'}
|
||||
has_key_filter = any(
|
||||
isinstance(f, SearchFilter) and f.field in key_fields
|
||||
for f in (search_query.and_filters or [])
|
||||
)
|
||||
if not has_key_filter:
|
||||
if search_query.and_filters is None:
|
||||
search_query.and_filters = []
|
||||
for col in ('site_access_key', 'site_domain_access_key'):
|
||||
search_query.and_filters.append(SearchQuery.parse_obj({
|
||||
'or': [{'field': col, 'op': 'is_null'}, {'field': col, 'op': 'eq', 'value': ''}]
|
||||
}))
|
||||
|
||||
if for_obj_type == 'account' and for_obj_id:
|
||||
if not account.super and for_obj_id != account.account_id_random:
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.")
|
||||
@@ -388,10 +413,10 @@ async def search_obj_li(
|
||||
if sql_result is False:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
|
||||
|
||||
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
|
||||
status_code = 400 if db_err.category == "database_schema" else 500
|
||||
|
||||
|
||||
return mk_resp(data=False, status_code=status_code, response=response, status_message="Search failed due to database error.", details=db_err.dict())
|
||||
|
||||
if sql_result:
|
||||
@@ -414,7 +439,7 @@ async def post_obj(
|
||||
):
|
||||
"""
|
||||
Create Object.
|
||||
|
||||
|
||||
1. Injects `account_id` for ownership.
|
||||
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields.
|
||||
- If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
|
||||
@@ -462,9 +487,13 @@ async def post_obj(
|
||||
|
||||
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
|
||||
# to the DB without conflicting with Vision ID string constraints in the model.
|
||||
# Guard: skip if the model explicitly excludes account_id from DB writes (e.g. event_badge,
|
||||
# event_device — the column does not exist in those tables).
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
data_to_insert['account_id'] = account.account_id
|
||||
excluded = getattr(input_model, 'fields_to_exclude_from_db', [])
|
||||
if 'account_id' not in excluded:
|
||||
data_to_insert['account_id'] = account.account_id
|
||||
|
||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||
new_obj_id = sql_insert_result
|
||||
@@ -496,7 +525,7 @@ async def patch_obj(
|
||||
):
|
||||
"""
|
||||
Update Object (Partial).
|
||||
|
||||
|
||||
1. Resolves ID and checks access permissions.
|
||||
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields.
|
||||
- If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
|
||||
@@ -557,7 +586,7 @@ async def delete_obj(
|
||||
):
|
||||
"""
|
||||
Delete Object.
|
||||
|
||||
|
||||
Supports:
|
||||
- Soft Delete: `method='hide'` or `method='disable'`.
|
||||
- Hard Delete: `method='delete'`.
|
||||
|
||||
@@ -313,15 +313,6 @@ async def post_child_obj(
|
||||
if not table_name_insert or not input_model or not table_name_select or not output_model:
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||
|
||||
if not account.super and account.auth_method != 'bypass' and account.account_id:
|
||||
if 'account_id' in input_model.__fields__:
|
||||
obj_data['account_id'] = account.account_id
|
||||
|
||||
obj_data[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||
|
||||
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
||||
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
try:
|
||||
validated_obj = input_model(**obj_data)
|
||||
except ValidationError as e:
|
||||
@@ -332,8 +323,18 @@ async def post_child_obj(
|
||||
|
||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||
|
||||
# Re-inject parent FK after model serialization. Some model root_validators strip
|
||||
# integer IDs (a Vision ID anti-leakage guard) which would drop the FK from the dict.
|
||||
# Sanitize AFTER serialization so that:
|
||||
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||
# 2. ID resolution (string → integer) happens on the dict going to the DB,
|
||||
# avoiding the root_validator's integer-stripping anti-leakage guard.
|
||||
# (Matches the flat V3 POST pattern in api_crud_v3.py.)
|
||||
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
# Re-inject parent FK last — overrides anything sanitize_payload or the model may have
|
||||
# set — ensuring the child is always linked to the correct parent.
|
||||
# Note: account_id is intentionally NOT injected here. Child objects in the nested
|
||||
# endpoint inherit account context from their parent via the FK relationship; they do
|
||||
# not carry their own account_id column (e.g. event_badge, journal_entry).
|
||||
data_to_insert[f'{parent_obj_type}_id'] = resolved_parent_id
|
||||
|
||||
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
|
||||
|
||||
78
app/routers/api_v3_actions_email.py
Normal file
78
app/routers/api_v3_actions_email.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Aether API V3 - Email Action Router
|
||||
-------------------------------------
|
||||
Handles transactional email sending.
|
||||
|
||||
Routes:
|
||||
POST /send — send a transactional email
|
||||
|
||||
Replaces: POST /util/email/send (legacy — see util_email.py)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.lib_email import send_email
|
||||
from app.lib_general_v3 import AccountContext, get_account_context
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class EmailSendRequest(BaseModel):
|
||||
from_email: str = Field(..., description="Sender email address")
|
||||
from_name: Optional[str] = None
|
||||
to_email: str = Field(..., description="Recipient email address")
|
||||
to_name: Optional[str] = None
|
||||
cc_email: Optional[str] = None
|
||||
cc_name: Optional[str] = None
|
||||
bcc_email: Optional[str] = None
|
||||
bcc_name: Optional[str] = None
|
||||
subject: str = Field(..., description="Email subject line")
|
||||
body_html: str = Field(..., description="HTML email body")
|
||||
body_text: Optional[str] = None
|
||||
|
||||
|
||||
@router.post('/send', response_model=Resp_Body_Base)
|
||||
async def action_email_send(
|
||||
req: EmailSendRequest,
|
||||
test: bool = Query(False, description="Simulate send without delivering"),
|
||||
account_ctx: AccountContext = Depends(get_account_context),
|
||||
response: Response = Response,
|
||||
):
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
success = send_email(
|
||||
from_email=req.from_email,
|
||||
from_name=req.from_name,
|
||||
to_email=req.to_email,
|
||||
to_name=req.to_name,
|
||||
cc_email=req.cc_email or '',
|
||||
cc_name=req.cc_name or '',
|
||||
bcc_email=req.bcc_email or '',
|
||||
bcc_name=req.bcc_name or '',
|
||||
subject=req.subject,
|
||||
body_text=req.body_text,
|
||||
body_html=req.body_html,
|
||||
test=test,
|
||||
)
|
||||
|
||||
if success:
|
||||
status_code = 200
|
||||
status_message = f'Email sent to <{req.to_email}>.'
|
||||
else:
|
||||
status_code = 400
|
||||
status_message = f'Email failed to send to <{req.to_email}>.'
|
||||
|
||||
log.info(status_message)
|
||||
resp_data = {
|
||||
'from_email': req.from_email,
|
||||
'to_email': req.to_email,
|
||||
'subject': req.subject[:40],
|
||||
}
|
||||
return mk_resp(data=resp_data, status_code=status_code, response=response, status_message=status_message)
|
||||
41
app/routers/api_v3_actions_idaa.py
Normal file
41
app/routers/api_v3_actions_idaa.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.idaa_novi_verify_methods import verify_novi_member
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get('/novi_member/{uuid}', response_model=Resp_Body_Base)
|
||||
async def get_novi_member_verification(
|
||||
uuid: str,
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Proxy Novi AMS member lookup server-to-server.
|
||||
Returns verified member identity or an appropriate error code.
|
||||
"""
|
||||
if delay.sleep_time_s > 0:
|
||||
await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
result = verify_novi_member(uuid)
|
||||
status = result.get('status', 503)
|
||||
|
||||
if status == 200:
|
||||
return mk_resp(data={
|
||||
'verified': result['verified'],
|
||||
'full_name': result['full_name'],
|
||||
'email': result['email'],
|
||||
})
|
||||
|
||||
if status == 404:
|
||||
return mk_resp(data=False, status_code=404, status_message=result.get('reason', 'Member not found.'))
|
||||
|
||||
if status == 429:
|
||||
return mk_resp(data=False, status_code=429, status_message=result.get('reason', 'Novi rate limit exceeded.'))
|
||||
|
||||
return mk_resp(data=False, status_code=503, status_message=result.get('reason', 'Novi API unavailable.'))
|
||||
@@ -271,7 +271,7 @@ async def action_new_auth_key(
|
||||
@router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
|
||||
async def action_email_auth_key_url(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
root_url: Optional[str] = Query(None, min_length=10, max_length=200),
|
||||
root_url: str = Query(..., min_length=10, max_length=200),
|
||||
key_param_name: str = Query('auth_key', min_length=2, max_length=30),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import datetime
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Query, Response, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
|
||||
from app.lib_general import log, logging, common_route_params, Common_Route_Params
|
||||
from app.config import settings
|
||||
from app.db_sql import sql_insert, sql_update, sql_insert_or_update, sql_select, sql_delete, get_id_random, redis_lookup_id_random
|
||||
|
||||
from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
|
||||
|
||||
from app.methods.event_presenter_methods import get_event_presenter_url_list
|
||||
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ### BEGIN ### API Event Reports ### event_id_rpt_presenter_links() ###
|
||||
# Updated 2022-04-12
|
||||
@router.get('/event/{event_id}/rpt_presenter_links', response_model=Resp_Body_Base)
|
||||
async def event_id_rpt_presenter_links(
|
||||
event_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
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)
|
||||
|
||||
|
||||
if order_line_rec_list_result := get_order_line_rec_list(
|
||||
for_obj_type = obj_type,
|
||||
for_obj_id = obj_id,
|
||||
from_datetime = from_datetime,
|
||||
to_datetime = to_datetime,
|
||||
product_for_type = prod_type,
|
||||
status = status,
|
||||
full_detail = full_detail,
|
||||
# enabled = enabled,
|
||||
limit = limit,
|
||||
):
|
||||
order_line_result_list = []
|
||||
data_dict_list_for_export = []
|
||||
for order_line_rec in order_line_rec_list_result:
|
||||
if not full_detail:
|
||||
if load_order_line_result := load_order_obj_line(
|
||||
order_line_id = order_line_rec.get('order_line_id', None),
|
||||
by_alias = by_alias,
|
||||
exclude_unset = exclude_unset,
|
||||
# model_as_dict = model_as_dict,
|
||||
):
|
||||
order_line_result_list.append(load_order_line_result)
|
||||
else:
|
||||
order_line_result_list.append(None)
|
||||
else: # Uses a different view: v_order_line_full_detail
|
||||
if load_order_line_result := load_order_obj_line_full_detail(
|
||||
order_line_rec = order_line_rec,
|
||||
by_alias = by_alias,
|
||||
exclude_unset = exclude_unset,
|
||||
model_as_dict = False,
|
||||
):
|
||||
if create_export:
|
||||
data_dict = load_order_line_result.dict(by_alias=by_alias, exclude_unset=exclude_unset)
|
||||
data_dict_list_for_export.append(data_dict)
|
||||
order_line_result_list.append(load_order_line_result)
|
||||
else:
|
||||
order_line_result_list.append(None)
|
||||
response_data = order_line_result_list
|
||||
elif isinstance(order_line_rec_list_result, list) or order_line_rec_list_result is None: # Empty list or None
|
||||
log.info('No results')
|
||||
return mk_resp(data=None, status_code=404, response=response) # Not Found
|
||||
else:
|
||||
log.warning('Likely bad request')
|
||||
return mk_resp(data=False, status_code=400, response=response) # Bad Request
|
||||
|
||||
if create_export:
|
||||
# column_name_li = ['order_id_random', 'order_line_id_random', '', 'product_name', 'quantity', 'amount', 'dollar_amount', 'person_email']
|
||||
|
||||
# column_name_li = ['order_line_id_random', 'order_id_random', 'product_id_random', 'product_type', 'product_name', 'product_unit_price', 'product_recurring', 'curr_product_id_random', 'curr_product_type', 'curr_product_type_name', 'curr_product_name', 'name', 'quantity', 'amount', 'dollar_amount', 'recurring', 'message', 'person_id_random', 'person_given_name', 'person_family_name', 'person_full_name', 'person_full_name_override', 'person_contact_email', 'person_contact_cc_email', 'person_contact_phone_mobile', 'person_contact_phone_home', 'person_contact_phone_office', 'person_contact_phone_land', 'person_contact_phone_fax', 'person_contact_phone_other', 'person_contact_address_name', 'person_contact_address_organization_name', 'person_contact_address_line_1', 'person_contact_address_line_2', 'person_contact_address_line_3', 'person_contact_address_city', 'person_contact_address_country_subdivision_code', 'person_contact_address_state_province', 'person_contact_address_postal_code', 'person_contact_address_country_alpha_2_code', 'person_contact_address_country_name', 'person_contact_address_country', 'order_status', 'order_created_on', 'order_updated_on', 'created_on', 'updated_on']
|
||||
|
||||
column_name_li = [
|
||||
'event_presenter_id_random',
|
||||
'event_id_random',
|
||||
'events_session_id_random',
|
||||
'events_presentation_id_random',
|
||||
'event_presenter_given_name',
|
||||
'event_presenter_family_name',
|
||||
'event_presenter_email',
|
||||
'event_presenter_created_on', 'event_presenter_updated_on'
|
||||
]
|
||||
|
||||
|
||||
# column_name_li = []
|
||||
datetime_format='%Y-%m-%d_%H%M'
|
||||
|
||||
# current_datetime = datetime.datetime.now() # Servers timezone (Eastern)
|
||||
current_datetime_utc = datetime.datetime.utcnow()
|
||||
current_datetime_utc = current_datetime_utc.strftime(datetime_format)
|
||||
filename = f'order_line_list_{current_datetime_utc}'
|
||||
if result := create_export_file(data_dict_list=data_dict_list_for_export, column_name_li=column_name_li, subdir_path='order_line', filename=filename, export_type='Excel'):
|
||||
tmp_file_path = result
|
||||
else:
|
||||
log.error('Something went wrong while creating or saving the export file')
|
||||
tmp_file_path = result
|
||||
else: tmp_file_path = None
|
||||
|
||||
return mk_resp(data=response_data, tmp_file_path=tmp_file_path, response=response)
|
||||
# ### END ### API Event Reports ### get_obj_id_order_line_list() ###
|
||||
@@ -1,480 +0,0 @@
|
||||
import datetime
|
||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Path, Query, Response, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
|
||||
from app.lib_general import *
|
||||
from app.config import settings
|
||||
from app.db_sql import *
|
||||
|
||||
from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
|
||||
|
||||
from app.methods.order_methods import create_order_obj, update_order_obj, get_order_rec_list, load_order_obj, save_order_obj
|
||||
from app.methods.order_line_methods import create_order_obj_line, update_order_obj_line, load_order_obj_line
|
||||
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.models.order_models_v3 import Order_Base
|
||||
from app.models.order_line_models_v3 import Order_Line_Base
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### post_order_obj() ###
|
||||
# Updated 2022-01-18
|
||||
@router.post('/v3/order', response_model=Resp_Body_Base)
|
||||
@router.post('/v3/person/{person_id}/order', response_model=Resp_Body_Base)
|
||||
async def post_order_obj(
|
||||
order_obj: Order_Base,
|
||||
person_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
inc_address: bool = False,
|
||||
inc_contact: bool = False,
|
||||
inc_order_line_list: bool = True,
|
||||
inc_person: bool = False,
|
||||
return_obj: bool = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
if person_id := redis_lookup_id_random(record_id_random=person_id, table_name='person'): pass
|
||||
# elif person_id is None: pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The person ID was invalid or not found.')
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if order_id := create_order_obj(
|
||||
account_id = commons.x_account_id,
|
||||
person_id = person_id,
|
||||
order_dict_obj = order_obj,
|
||||
): pass
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Something failed while processing the data.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_id,
|
||||
inc_address = inc_address,
|
||||
inc_contact = inc_contact,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
inc_person = inc_person,
|
||||
).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset):
|
||||
log.info('Loading successful. Returning result')
|
||||
log.debug(load_order_obj_result)
|
||||
return mk_resp(data=load_order_obj_result, response=commons.response)
|
||||
elif isinstance(load_order_obj_result, list) or load_order_obj_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
else:
|
||||
order_id_random = get_id_random(record_id=order_id, table_name='order')
|
||||
data = {}
|
||||
data['order_id'] = order_id
|
||||
data['order_id_random'] = order_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Order Routers ### post_order_obj() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### patch_order_obj() ###
|
||||
# Updated 2022-01-18
|
||||
@router.patch('/v3/order/{order_id}', response_model=Resp_Body_Base)
|
||||
# @router.patch('/v3/person/{person_id}/order/{order_id}', response_model=Resp_Body_Base)
|
||||
async def patch_order_obj(
|
||||
order_obj: Order_Base,
|
||||
order_id: str = Path(min_length=11, max_length=22),
|
||||
# person_id: str = Query(None, min_length=11, max_length=22),
|
||||
|
||||
inc_address: bool = False,
|
||||
inc_contact: bool = False,
|
||||
inc_order_line_list: bool = True,
|
||||
inc_person: bool = False,
|
||||
return_obj: Optional[bool] = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
order_id_random = order_id # This is used later for the response data
|
||||
# person_id_random = person_id # This is used later for the response data
|
||||
|
||||
if order_id := redis_lookup_id_random(record_id_random=order_id, table_name='order'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order ID was invalid or not found.')
|
||||
|
||||
# if person_id := redis_lookup_id_random(record_id_random=person_id, table_name='person'): pass
|
||||
# elif person_id is None: pass
|
||||
# else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The person ID was invalid or not found.')
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if update_order_obj_result := update_order_obj(
|
||||
order_id = order_id,
|
||||
order_dict_obj = order_obj,
|
||||
# person_id = person_id,
|
||||
): pass
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Something failed while processing the data.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_id,
|
||||
inc_address = inc_address,
|
||||
inc_contact = inc_contact,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
inc_person = inc_person,
|
||||
).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset):
|
||||
log.info('Loading successful. Returning result')
|
||||
log.debug(load_order_obj_result)
|
||||
return mk_resp(data=load_order_obj_result, response=commons.response)
|
||||
elif isinstance(load_order_obj_result, list) or load_order_obj_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
else:
|
||||
data = {}
|
||||
data['order_id'] = order_id
|
||||
data['order_id_random'] = order_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Order Routers ### patch_order_obj() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### patch_order_obj_add_line() ###
|
||||
# Updated 2022-01-18
|
||||
@router.patch('/v3/order/{order_id}/line/add', response_model=Resp_Body_Base)
|
||||
async def patch_order_obj_add_line(
|
||||
order_line_obj: Order_Line_Base,
|
||||
order_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
# inc_order: bool = False,
|
||||
inc_order_line_list: bool = True,
|
||||
return_obj: Optional[bool] = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
order_id_random = order_id # This is used later for the response data
|
||||
|
||||
if order_id := redis_lookup_id_random(record_id_random=order_id, table_name='order'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order ID was invalid or not found.')
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if order_line_id := add_order_obj_line(
|
||||
order_id = order_id,
|
||||
order_line_dict_obj = order_line_obj,
|
||||
): pass
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Something failed while processing the data.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_id,
|
||||
# inc_address = inc_address,
|
||||
# inc_contact = inc_contact,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
# inc_person = inc_person,
|
||||
).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset):
|
||||
log.info('Loading successful. Returning result')
|
||||
log.debug(load_order_obj_result)
|
||||
return mk_resp(data=load_order_obj_result, response=commons.response)
|
||||
elif isinstance(load_order_obj_result, list) or load_order_obj_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
else:
|
||||
order_line_id = order_line_add_result
|
||||
order_line_id_random = get_id_random(record_id=order_line_id, table_name='order_line')
|
||||
data = {}
|
||||
data['order_id'] = order_id
|
||||
data['order_id_random'] = order_id_random
|
||||
data['order_line_id'] = order_line_id
|
||||
data['order_line_id_random'] = order_line_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Order Routers ### patch_order_obj_add_line() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### patch_order_obj_update_line() ###
|
||||
# Updated 2022-01-18
|
||||
@router.patch('/v3/order/{order_id}/line/{order_line_id}/update', response_model=Resp_Body_Base)
|
||||
async def patch_order_obj_update_line(
|
||||
order_obj: Order_Line_Base,
|
||||
order_id: str = Path(min_length=11, max_length=22),
|
||||
order_line_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
# inc_order: bool = False,
|
||||
inc_order_line_list: bool = True,
|
||||
return_obj: Optional[bool] = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
order_id_random = order_id # This is used later for the response data
|
||||
order_line_id_random = order_line_id # This is used later for the response data
|
||||
|
||||
if order_id := redis_lookup_id_random(record_id_random=order_id, table_name='order'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order ID was invalid or not found.')
|
||||
|
||||
if order_line_id := redis_lookup_id_random(record_id_random=order_line_id, table_name='order_line'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order line ID was invalid or not found.')
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if update_order_obj_line_result := update_order_obj_line(
|
||||
order_line_id = order_line_id,
|
||||
order_line_dict_obj = order_line_obj,
|
||||
): pass
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Something failed while processing the data.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_id,
|
||||
# inc_address = inc_address,
|
||||
# inc_contact = inc_contact,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
# inc_person = inc_person,
|
||||
).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset):
|
||||
log.info('Loading successful. Returning result')
|
||||
log.debug(order_dict)
|
||||
return mk_resp(data=order_dict, response=commons.response)
|
||||
elif isinstance(load_order_obj_result, list) or load_order_obj_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
else:
|
||||
data = {}
|
||||
data['order_id'] = order_id
|
||||
data['order_id_random'] = order_id_random
|
||||
data['order_line_id'] = order_line_id
|
||||
data['order_line_id_random'] = order_line_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Order Routers ### patch_order_obj_update_line() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### patch_order_obj_remove_line() ###
|
||||
# Updated 2022-01-18
|
||||
@router.patch('/v3/order/{order_id}/line/{order_line_id}/remove', response_model=Resp_Body_Base)
|
||||
async def patch_order_obj_remove_line(
|
||||
order_obj: Order_Line_Base,
|
||||
order_id: str = Path(min_length=11, max_length=22),
|
||||
order_line_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
# inc_order: bool = False,
|
||||
inc_order_line_list: bool = True,
|
||||
return_obj: Optional[bool] = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
order_id_random = order_id # This is used later for the response data
|
||||
order_line_id_random = order_line_id # This is used later for the response data
|
||||
|
||||
if order_id := redis_lookup_id_random(record_id_random=order_id, table_name='order'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order ID was invalid or not found.')
|
||||
|
||||
if order_line_id := redis_lookup_id_random(record_id_random=order_line_id, table_name='order_line'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order line ID was invalid or not found.')
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if remove_order_obj_line_result := remove_order_obj_line(
|
||||
order_line_id = order_line_id,
|
||||
): pass
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Something failed while processing the data.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_id,
|
||||
# inc_address = inc_address,
|
||||
# inc_contact = inc_contact,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
# inc_person = inc_person,
|
||||
).dict(by_alias=commons.by_alias, exclude_unset=commons.exclude_unset):
|
||||
log.info('Loading successful. Returning result')
|
||||
log.debug(order_dict)
|
||||
return mk_resp(data=order_dict, response=commons.response)
|
||||
elif isinstance(load_order_obj_result, list) or load_order_obj_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
else:
|
||||
data = {}
|
||||
data['order_id'] = order_id
|
||||
data['order_id_random'] = order_id_random
|
||||
data['order_line_id'] = order_line_id
|
||||
data['order_line_id_random'] = order_line_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Order Routers ### patch_order_obj_remove_line() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### get_order_obj_li() ###
|
||||
# Updated 2022-01-18
|
||||
@router.get('/v3/{for_obj_type}/{for_obj_id}/order/list', response_model=Resp_Body_Base)
|
||||
async def get_order_obj_li(
|
||||
for_obj_type: str = Path(min_length=2, max_length=50),
|
||||
for_obj_id: str = Path(min_length=11, max_length=22),
|
||||
order_status: str = 'complete',
|
||||
order_checkout_status: str = 'complete',
|
||||
from_datetime: datetime.datetime = None,
|
||||
to_datetime: datetime.datetime = None,
|
||||
|
||||
inc_address: bool = False,
|
||||
inc_contact: bool = False,
|
||||
inc_order_cfg: bool = False,
|
||||
inc_order_line_list: bool = False,
|
||||
inc_person: bool = False,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if obj_type in ['account', 'person']: pass
|
||||
else: return mk_resp(data=False, status_code=400, response=response, status_message='The object type passed was invalid or not found. Expecting "account" or "person".') # Bad Request
|
||||
|
||||
if obj_type_id := redis_lookup_id_random(record_id_random=for_obj_id, table_name=for_obj_type): pass
|
||||
else: return mk_resp(data=False, status_code=404, response=commons.response) # Not Found
|
||||
|
||||
if get_order_rec_list_result := get_order_rec_list(
|
||||
for_obj_type = for_obj_type,
|
||||
for_obj_id = for_obj_id,
|
||||
from_datetime = from_datetime,
|
||||
to_datetime = to_datetime,
|
||||
status = order_status,
|
||||
# checkout_status = order_checkout_status,
|
||||
enabled = commons.enabled,
|
||||
limit = commons.limit,
|
||||
offset = commons.offset,
|
||||
):
|
||||
order_obj_list = []
|
||||
for order_rec in get_order_rec_list_result:
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_rec.get('order_id'),
|
||||
inc_address = inc_address,
|
||||
inc_contact = inc_contact,
|
||||
inc_order_cfg = inc_order_cfg,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
inc_person = inc_person,
|
||||
enabled = commons.enabled,
|
||||
limit = commons.limit,
|
||||
by_alias = commons.by_alias,
|
||||
exclude_unset = commons.exclude_unset,
|
||||
# model_as_dict = model_as_dict,
|
||||
):
|
||||
log.debug(load_order_obj_result)
|
||||
order_obj_list.append(load_order_obj_result)
|
||||
else:
|
||||
order_obj_list.append(None)
|
||||
log.info('Loading successful. Returning result')
|
||||
log.debug(order_obj_list)
|
||||
return mk_resp(data=order_obj_list, response=commons.response)
|
||||
elif isinstance(get_order_rec_list_result, list) or get_order_rec_list_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# ### END ### API Order Routers ### get_order_obj_li() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routes ### get_order_obj() ###
|
||||
# NOTE 2021-08-09: Use with rework of order_cart
|
||||
# Updated 2022-12-18
|
||||
@router.get('/v3/order/{order_id}', response_model=Resp_Body_Base)
|
||||
async def get_order_obj(
|
||||
order_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
inc_address: bool = False,
|
||||
inc_contact: bool = False,
|
||||
inc_order_cfg: bool = False,
|
||||
inc_order_line_list: bool = False,
|
||||
inc_person: bool = False,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if order_id := redis_lookup_id_random(record_id_random=order_id, table_name='order'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order ID was invalid or not found.')
|
||||
|
||||
if load_order_obj_result := load_order_obj(
|
||||
order_id = order_id,
|
||||
inc_address = inc_address,
|
||||
inc_contact = inc_contact,
|
||||
inc_order_cfg = inc_order_cfg,
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
inc_person = inc_person,
|
||||
limit = commons.limit,
|
||||
enabled = commons.enabled,
|
||||
by_alias = commons.by_alias,
|
||||
exclude_unset = commons.exclude_unset,
|
||||
# model_as_dict = model_as_dict,
|
||||
):
|
||||
log.debug(load_order_obj_result)
|
||||
order_dict = load_order_obj_result.dict(by_alias=commons.by_alias, exclude_unset=False) # NOTE NOTE NOTE NOTE exclude_unset is forced to False for now. Will return more fields than is ideal. Need to create another Order_Line_Base. Probably Order_Line_OUT_Base
|
||||
log.info('Loading successful. Returning result')
|
||||
return mk_resp(data=order_dict, response=commons.response)
|
||||
elif isinstance(load_order_obj_result, list) or load_order_obj_result is None: # Empty list or None
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# ### END ### API Order Routes ### get_order_obj() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order ### get_person_id_order_cart() ###
|
||||
# NOTE 2021-08-09: Use with rework of order_cart. The most recent (hopefully only one) "open" order for a person.
|
||||
# Updated 2022-12-18
|
||||
@router.get('/v3/person/{person_id}/order/cart', response_model=Resp_Body_Base)
|
||||
async def get_person_id_order_cart(
|
||||
person_id: str = Path(min_length=11, max_length=22),
|
||||
enabled: str = 'enabled',
|
||||
inc_order_line_list: bool = False,
|
||||
inc_order_cfg: bool = False,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if person_id := redis_lookup_id_random(record_id_random=person_id, table_name='person'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The person ID was invalid or not found.')
|
||||
|
||||
# Query to get the one "open" order status for a person ID
|
||||
|
||||
return False
|
||||
# ### END ### API Order ### get_person_id_order_cart() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Order Routers ### delete_order_obj() ###
|
||||
# Updated 2022-01-18
|
||||
@router.delete('/v3/order/{order_id}', response_model=Resp_Body_Base)
|
||||
async def delete_order_obj(
|
||||
order_id: str = Path(min_length=11, max_length=22),
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
if order_id := redis_lookup_id_random(record_id_random=order_id, table_name='order'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The order ID was invalid or not found.')
|
||||
|
||||
obj_type = 'order'
|
||||
result = delete_obj_template(
|
||||
obj_type = obj_type,
|
||||
obj_id = obj_id,
|
||||
)
|
||||
return result
|
||||
# ### END ### API Order Routers ### delete_order_obj() ###
|
||||
@@ -16,9 +16,9 @@ from app.methods.event_person_methods import create_event_person_obj, create_upd
|
||||
# from app.methods.event_presenter_methods import create_update_event_presenter_obj_v4, get_event_presenter_rec_list, load_event_presenter_obj
|
||||
from app.methods.hosted_file_methods import load_hosted_file_obj, save_file
|
||||
|
||||
from app.models.event_models import Event_Base
|
||||
#from app.models.event_models import Event_Base
|
||||
# from app.models.event_location_models import Event_Location_Base
|
||||
from app.models.event_person_models import Event_Person_Base
|
||||
#from app.models.event_person_models import Event_Person_Base
|
||||
# from app.models.event_presentation_models import Event_Presentation_Base
|
||||
# from app.models.event_presenter_models import Event_Presenter_Base
|
||||
# from app.models.event_session_models import Event_Session_Base
|
||||
@@ -432,6 +432,11 @@ async def event_id_badge_import(
|
||||
event_badge_id = event_person_result.get('event_badge_id')
|
||||
event_person_profile_id = event_person_result.get('event_person_profile_id')
|
||||
log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}')
|
||||
# Don't touch enable on update — a manually disabled record is effectively
|
||||
# blacklisted and should survive repeated re-imports of the same file.
|
||||
event_person_data.pop('enable', None)
|
||||
event_person_data.get('event_badge', {}).pop('enable', None)
|
||||
event_person_data.get('event_person_profile', {}).pop('enable', None)
|
||||
if create_event_person_obj_result := create_update_event_person_obj_v4(
|
||||
event_person_dict_obj = event_person_data,
|
||||
event_person_id = event_person_id,
|
||||
@@ -472,4 +477,579 @@ async def event_id_badge_import(
|
||||
if return_detail:
|
||||
return mk_resp(data=event_badge_person_li, status_message=f'Importing badges from file. Found {len(person_li)} badges.', response=commons.response)
|
||||
else:
|
||||
return mk_resp(data=event_badge_person_summary_li, status_message=f'Checked for badges from file. Found {len(event_badge_person_li)} badges.', response=commons.response)
|
||||
return mk_resp(data=event_badge_person_summary_li, status_message=f'Checked for badges from file. Found {len(event_badge_person_li)} badges.', response=commons.response)
|
||||
|
||||
|
||||
# ### BEGIN ### Zoom Events CSV Badge Import ### event_id_badge_import_zoom_csv() ###
|
||||
# Accepts a Zoom Events registrant CSV export and upserts event_person records.
|
||||
# Zoom CSV format: fixed columns (First name, Last name, Registrant email, Ticket name,
|
||||
# Unique identifier, etc.) plus per-ticket-type custom fields using the pattern
|
||||
# "FieldLabel_*_TicketTypeName". Delimiter is auto-detected (Zoom exports vary).
|
||||
# Updated 2026-04-06
|
||||
|
||||
# Notes specific to Axonius 2026
|
||||
|
||||
# SELECT id, badge_type, badge_type_code
|
||||
# FROM event_badge
|
||||
# WHERE badge_type = 'In-Person Attendee';
|
||||
|
||||
# UPDATE event_badge
|
||||
# SET badge_type_code = 'attendee'
|
||||
# WHERE badge_type = 'In-Person Attendee';
|
||||
|
||||
# SELECT id, badge_type, badge_type_code
|
||||
# FROM event_badge
|
||||
# WHERE badge_type = 'Adapt26 Sponsor';
|
||||
|
||||
# UPDATE event_badge
|
||||
# SET badge_type_code = '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:
|
||||
"""
|
||||
Extracts a per-ticket-type field value from a Zoom CSV row.
|
||||
Tries the exact ticket match first, then falls back to the first non-empty value
|
||||
across all variants of that field prefix.
|
||||
"""
|
||||
exact_key = f'{field_prefix}_*_{ticket_name}'
|
||||
if val := str(record.get(exact_key, '')).strip():
|
||||
return val
|
||||
for key, val in record.items():
|
||||
if key.startswith(f'{field_prefix}_*_') and str(val).strip():
|
||||
return str(val).strip()
|
||||
return ''
|
||||
|
||||
|
||||
@router.post('/event/{event_id}/badge/import/zoom_csv', response_model=Resp_Body_Base)
|
||||
async def event_id_badge_import_zoom_csv(
|
||||
event_id: str = Path(min_length=11, max_length=22),
|
||||
file: UploadFile = File(...),
|
||||
|
||||
begin_at: int = 0,
|
||||
end_at: int = 20000,
|
||||
|
||||
return_detail: bool = False,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
"""
|
||||
Import event badges from a Zoom Events registrant CSV export.
|
||||
|
||||
Zoom exports fixed columns (First name, Last name, Registrant email, Ticket name,
|
||||
Unique identifier) plus per-ticket-type custom fields in the format
|
||||
"FieldLabel_*_TicketTypeName". The 'Unique identifier' column is used as the
|
||||
external_registration_id. Delimiter is auto-detected.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Zoom CSV layout: row 1 = "Report generated" metadata, row 2 = blank, row 3 = headers
|
||||
# Delimiter is auto-detected (Zoom exports vary between comma and tab)
|
||||
df = pandas.read_csv(
|
||||
full_file_path,
|
||||
sep=None,
|
||||
engine='python',
|
||||
skiprows=2,
|
||||
na_filter=False,
|
||||
dtype=str,
|
||||
)
|
||||
|
||||
df_dict = df.to_dict(orient='records')
|
||||
log.info(f'Zoom CSV total record count: {len(df_dict)}')
|
||||
|
||||
loop_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
|
||||
|
||||
# Force use of Registrant email as the external_id for Zoom CSV imports.
|
||||
# Many Zoom exports (for this group) have a useless "Unique identifier"
|
||||
# column that contains "N/A" for every row — rely on email instead.
|
||||
email = str(record.get('Registrant email', '')).strip()
|
||||
if not email:
|
||||
log.warning('Row missing registrant email — skipping.')
|
||||
continue
|
||||
external_id = email
|
||||
|
||||
# Sanitize the Unique identifier value and only use it as the
|
||||
# external_registration_id if it appears meaningful. Treat common
|
||||
# placeholders like 'N/A'/'NA'/'UNKNOWN' as missing.
|
||||
unique_id_raw = str(record.get('Unique identifier', '')).strip()
|
||||
if unique_id_raw and unique_id_raw.upper() not in ('N/A', 'NA', 'UNKNOWN'):
|
||||
external_registration_id = unique_id_raw
|
||||
else:
|
||||
external_registration_id = None
|
||||
|
||||
ticket_name = str(record.get('Ticket name', '')).strip()
|
||||
given_name = str(record.get('First name', '')).strip()
|
||||
family_name = str(record.get('Last name', '')).strip()
|
||||
display_name = str(record.get('Display name', '')).strip()
|
||||
|
||||
# Per-ticket-type custom fields
|
||||
organization = _zoom_ticket_field(record, 'Organization', ticket_name)
|
||||
professional_title = _zoom_ticket_field(record, 'Job title', ticket_name)
|
||||
phone = (_zoom_ticket_field(record, 'Phone', ticket_name)
|
||||
or _zoom_ticket_field(record, 'Phone number', ticket_name))
|
||||
address_line_1 = (_zoom_ticket_field(record, 'Address line 1', ticket_name)
|
||||
or _zoom_ticket_field(record, 'Address', ticket_name))
|
||||
address_line_2 = _zoom_ticket_field(record, 'Address line 2', ticket_name)
|
||||
address_line_3 = _zoom_ticket_field(record, 'Address line 3', ticket_name)
|
||||
city = _zoom_ticket_field(record, 'City', ticket_name)
|
||||
state_province = _zoom_ticket_field(record, 'State/Province', ticket_name)
|
||||
state_province_abb = _zoom_ticket_field(record, 'State/Province Abb', ticket_name)
|
||||
postal_code = (_zoom_ticket_field(record, 'Postal code', ticket_name)
|
||||
or _zoom_ticket_field(record, 'Zip code', ticket_name)
|
||||
or _zoom_ticket_field(record, 'Zip/Postal Code', ticket_name))
|
||||
country = _zoom_ticket_field(record, 'Country/Region', ticket_name)
|
||||
country_alpha_2_code = _zoom_ticket_field(record, 'Country Alpha 2 Code', ticket_name)
|
||||
country_subdivision_code = _zoom_ticket_field(record, 'Country Subdivision Code', ticket_name)
|
||||
# location, full_address, location_long, location_short are computed by DB triggers
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# TEMPORARY: Axonius-specific mapping for certain ticket / badge labels
|
||||
# to internal `badge_type_code` values. Remove after the event (~2 weeks).
|
||||
normalized_ticket = ticket_name.strip().lower()
|
||||
badge_type_code = None
|
||||
if 'sponsor' in normalized_ticket:
|
||||
badge_type_code = 'sponsor'
|
||||
elif 'attend' in normalized_ticket or 'attendee' in normalized_ticket:
|
||||
badge_type_code = 'attendee'
|
||||
if badge_type_code:
|
||||
log.info(f"Axonius mapping applied: '{ticket_name}' -> '{badge_type_code}'")
|
||||
|
||||
# Parse marketing consent column (if present) and map to badge fields.
|
||||
# Expected values: "Opt-in" => agree_to_tc=True, allow_tracking=True
|
||||
# "Opt-out" => agree_to_tc=False, allow_tracking=False
|
||||
# "N/A" => None/NULL
|
||||
marketing_raw = None
|
||||
for _k in ('Agree to receive marketing communication?', 'Agree to receive marketing communication', 'Agree to TC', 'agree_to_tc'):
|
||||
if _k in record and str(record.get(_k)).strip() != '':
|
||||
marketing_raw = str(record.get(_k)).strip()
|
||||
break
|
||||
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
if marketing_raw is not None:
|
||||
m = marketing_raw.strip()
|
||||
m_low = m.lower()
|
||||
if m_low in ('n/a', 'na'):
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
elif m_low in ('opt-in', 'optin', 'opt in'):
|
||||
agree_to_tc_val = True
|
||||
allow_tracking_val = True
|
||||
elif m_low in ('opt-out', 'optout', 'opt out'):
|
||||
agree_to_tc_val = False
|
||||
allow_tracking_val = False
|
||||
else:
|
||||
if m_low in ('yes', 'y', 'true', '1'):
|
||||
agree_to_tc_val = True
|
||||
allow_tracking_val = True
|
||||
elif m_low in ('no', 'n', 'false', '0'):
|
||||
agree_to_tc_val = False
|
||||
allow_tracking_val = False
|
||||
else:
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
|
||||
# Need to deal with this special field/column for Axonius
|
||||
# "Agree to receive marketing communication?"
|
||||
|
||||
event_person_data = {
|
||||
'account_id': account_id,
|
||||
'event_id': event_id,
|
||||
'enable': True,
|
||||
'external_id': external_id,
|
||||
'external_registration_id': external_registration_id,
|
||||
'event_person_profile': {
|
||||
'event_id': event_id,
|
||||
'enable': True,
|
||||
'given_name': given_name,
|
||||
'family_name': family_name,
|
||||
'full_name': display_name or f'{given_name} {family_name}'.strip(),
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'address_line_1': address_line_1,
|
||||
'address_line_2': address_line_2,
|
||||
'address_line_3': address_line_3,
|
||||
'city': city,
|
||||
'state_province': state_province,
|
||||
'state_province_abb': state_province_abb,
|
||||
'postal_code': postal_code,
|
||||
'country': country,
|
||||
'country_alpha_2_code': country_alpha_2_code,
|
||||
'country_subdivision_code': country_subdivision_code,
|
||||
'professional_title': professional_title,
|
||||
'affiliations': organization,
|
||||
},
|
||||
'event_badge': {
|
||||
# 'event_id': event_id,
|
||||
'enable': True,
|
||||
'external_id': external_id,
|
||||
'external_registration_id': external_registration_id,
|
||||
'given_name': given_name,
|
||||
'family_name': family_name,
|
||||
'full_name': display_name or f'{given_name} {family_name}'.strip(),
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'address_line_1': address_line_1,
|
||||
'address_line_2': address_line_2,
|
||||
'address_line_3': address_line_3,
|
||||
'city': city,
|
||||
'state_province': state_province,
|
||||
'state_province_abb': state_province_abb,
|
||||
'postal_code': postal_code,
|
||||
'country': country,
|
||||
'country_alpha_2_code': country_alpha_2_code,
|
||||
'country_subdivision_code': country_subdivision_code,
|
||||
'professional_title': professional_title,
|
||||
'affiliations': organization,
|
||||
# TEMPORARY: Axonius export does not include a badge template id.
|
||||
# Default to the Axonius group's badge template `RKYp2HcQm9o (21)`.
|
||||
# This is a temporary hardcode — remove or replace when mapping is provided.
|
||||
'event_badge_template_id': 21,
|
||||
'event_badge_template_id_random': 'RKYp2HcQm9o',
|
||||
'badge_type': ticket_name,
|
||||
'badge_type_code': badge_type_code,
|
||||
'agree_to_tc': agree_to_tc_val,
|
||||
'allow_tracking': allow_tracking_val,
|
||||
},
|
||||
}
|
||||
|
||||
# Look up existing event_person by event_id + external_id (should be 0 or 1).
|
||||
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 multiple rows are returned that's an integrity problem — log it and
|
||||
# use the first row for the update to avoid creating duplicates.
|
||||
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.')
|
||||
|
||||
# Record the processed input for response summary after DB ops.
|
||||
event_badge_person_li.append(event_person_data)
|
||||
event_badge_person_summary_li.append(event_person_summary)
|
||||
|
||||
if return_detail:
|
||||
return mk_resp(data=event_badge_person_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_li)} records.', response=commons.response)
|
||||
else:
|
||||
return mk_resp(data=event_badge_person_summary_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_summary_li)} records.', response=commons.response)
|
||||
|
||||
|
||||
# ### BEGIN ### Splash (Cvent) XLSX Badge Import ### event_id_badge_import_splash_xlsx() ###
|
||||
# Accepts a Splash (Cvent) registrant XLSX export and inserts/updates event_person records.
|
||||
# Splash exports fixed columns: Full Name, Email, Time of RSVP, Status, plus custom
|
||||
# fields prefixed with "Custom: ". Email is used as external_id. Full Name is split
|
||||
# on the last space into given_name/family_name and also stored directly as full_name.
|
||||
# Updated 2026-06-02
|
||||
|
||||
@router.post('/event/{event_id}/badge/import/splash_xlsx', response_model=Resp_Body_Base)
|
||||
async def event_id_badge_import_splash_xlsx(
|
||||
event_id: str = Path(min_length=11, max_length=22),
|
||||
file: UploadFile = File(...),
|
||||
|
||||
begin_at: int = 0,
|
||||
end_at: int = 20000,
|
||||
|
||||
import_status_filter: str = 'Attending', # set to '' to import all statuses
|
||||
|
||||
return_detail: bool = False,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
"""
|
||||
Import event badges from a Splash (Cvent) registrant XLSX export.
|
||||
|
||||
Splash exports fixed columns (Full Name, Email, Time of RSVP, Status) plus
|
||||
custom fields prefixed with "Custom: ". Email is used as external_id.
|
||||
Full Name is split on the last space into given_name/family_name and also
|
||||
stored directly as full_name. Pass import_status_filter='' to import all
|
||||
statuses (default is 'Attending').
|
||||
"""
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
account_id = commons.x_account_id
|
||||
|
||||
event_id_random = event_id
|
||||
if event_id := redis_lookup_id_random(record_id_random=event_id, table_name='event'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
link_to_type = 'event'
|
||||
link_to_id = event_id
|
||||
|
||||
file_info = await save_file(
|
||||
file=file,
|
||||
account_id=account_id,
|
||||
link_to_type=link_to_type,
|
||||
link_to_id=link_to_id,
|
||||
)
|
||||
if file_info['saved']:
|
||||
log.info('File saved')
|
||||
else:
|
||||
log.error('Something may have gone wrong while saving the uploaded file?')
|
||||
return mk_resp(data=None, status_code=500, response=commons.response)
|
||||
|
||||
hosted_files_path = settings.FILES_PATH['hosted_files_root']
|
||||
subdirectory_dest = os.path.join(hosted_files_path, file_info.get('subdirectory_path'))
|
||||
hash_filename = file_info.get('hash_sha256') + '.file'
|
||||
full_file_path = pathlib.Path(os.path.join(subdirectory_dest, hash_filename))
|
||||
|
||||
if not full_file_path.exists():
|
||||
log.warning(f'Not found at full file path: {full_file_path}')
|
||||
return mk_resp(data=None, status_code=500, response=commons.response)
|
||||
|
||||
df = pandas.read_excel(full_file_path, dtype=str, na_filter=False)
|
||||
|
||||
df_dict = df.to_dict(orient='records')
|
||||
log.info(f'Splash XLSX total record count: {len(df_dict)}')
|
||||
|
||||
loop_count = 0
|
||||
skipped_count = 0
|
||||
event_badge_person_li = []
|
||||
event_badge_person_summary_li = []
|
||||
|
||||
log.setLevel(logging.DEBUG)
|
||||
for record in df_dict:
|
||||
log.info(f'Loop Count: {loop_count}')
|
||||
loop_count += 1
|
||||
if loop_count <= begin_at: continue
|
||||
if loop_count > end_at: break
|
||||
|
||||
# Status filter — skip rows that don't match when a filter is set.
|
||||
if import_status_filter:
|
||||
status = str(record.get('Status', '')).strip()
|
||||
if status != import_status_filter:
|
||||
log.info(f'Skipping row with status "{status}" (filter: "{import_status_filter}")')
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
email = str(record.get('Email', '')).strip()
|
||||
if not email:
|
||||
log.warning('Row missing Email — skipping.')
|
||||
skipped_count += 1
|
||||
continue
|
||||
external_id = email
|
||||
|
||||
full_name = str(record.get('Full Name', '')).strip()
|
||||
given_name, family_name = _split_full_name(full_name)
|
||||
|
||||
professional_title = str(record.get('Custom: Job Title', '')).strip()
|
||||
organization = str(record.get('Custom: Company Name', '')).strip()
|
||||
country = str(record.get('Custom: Country', '')).strip()
|
||||
state_province = str(record.get('Custom: State', '')).strip()
|
||||
dietary_restrictions = str(record.get('Custom: Please note any dietary restrictions or preferences.', '')).strip()
|
||||
|
||||
# "Custom: Opt-In" → agree_to_tc / allow_tracking
|
||||
opt_in_raw = str(record.get('Custom: Opt-In', '')).strip().lower()
|
||||
if opt_in_raw in ('yes', 'y', 'true', '1', 'opt-in', 'opt_in'):
|
||||
agree_to_tc_val = True
|
||||
allow_tracking_val = True
|
||||
elif opt_in_raw in ('no', 'n', 'false', '0', 'opt-out', 'opt_out'):
|
||||
agree_to_tc_val = False
|
||||
allow_tracking_val = False
|
||||
else:
|
||||
agree_to_tc_val = None
|
||||
allow_tracking_val = None
|
||||
|
||||
event_person_summary = {
|
||||
'event_id': event_id,
|
||||
'event_id_random': event_id_random,
|
||||
'external_id': external_id,
|
||||
'given_name': given_name,
|
||||
'family_name': family_name,
|
||||
'email': email,
|
||||
}
|
||||
|
||||
event_person_data = {
|
||||
'account_id': account_id,
|
||||
'event_id': event_id,
|
||||
'enable': True,
|
||||
'external_id': external_id,
|
||||
'event_person_profile': {
|
||||
'event_id': event_id,
|
||||
'enable': True,
|
||||
'given_name': given_name,
|
||||
'family_name': family_name,
|
||||
'full_name': full_name,
|
||||
'email': email,
|
||||
'professional_title': professional_title,
|
||||
'affiliations': organization,
|
||||
'country': country,
|
||||
'state_province': state_province,
|
||||
},
|
||||
'event_badge': {
|
||||
'enable': True,
|
||||
'external_id': external_id,
|
||||
'given_name': given_name,
|
||||
'family_name': family_name,
|
||||
'full_name': full_name,
|
||||
'email': email,
|
||||
'professional_title': professional_title,
|
||||
'affiliations': organization,
|
||||
'country': country,
|
||||
'state_province': state_province,
|
||||
'other_1': dietary_restrictions,
|
||||
# TEMPORARY: Axonius DC event badge template mu_7SRuJYum (23).
|
||||
'event_badge_template_id': 23,
|
||||
'event_badge_template_id_random': 'mu_7SRuJYum',
|
||||
'badge_type_code': 'attendee',
|
||||
'agree_to_tc': agree_to_tc_val,
|
||||
'allow_tracking': allow_tracking_val,
|
||||
},
|
||||
}
|
||||
|
||||
sql_select_event_person = """
|
||||
SELECT id AS event_person_id, id_random AS event_person_id_random,
|
||||
external_id AS event_person_external_id,
|
||||
event_badge_id AS event_badge_id,
|
||||
event_person_profile_id AS event_person_profile_id
|
||||
FROM `event_person`
|
||||
WHERE event_person.event_id = :event_id
|
||||
AND event_person.external_id = :external_id
|
||||
/*LIMIT 2*/;
|
||||
"""
|
||||
|
||||
event_person_result = sql_select(sql=sql_select_event_person, data=event_person_summary)
|
||||
if event_person_result:
|
||||
if isinstance(event_person_result, list):
|
||||
log.error(f'Found more than one Event Person with external_id={external_id}. Count: {len(event_person_result)}')
|
||||
event_person_result = event_person_result[0]
|
||||
|
||||
event_person_id = event_person_result.get('event_person_id')
|
||||
event_badge_id = event_person_result.get('event_badge_id')
|
||||
event_person_profile_id = event_person_result.get('event_person_profile_id')
|
||||
log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}')
|
||||
|
||||
# Don't touch enable on update — a manually disabled record is effectively
|
||||
# blacklisted and should survive repeated re-imports of the same file.
|
||||
event_person_data.pop('enable', None)
|
||||
event_person_data.get('event_badge', {}).pop('enable', None)
|
||||
event_person_data.get('event_person_profile', {}).pop('enable', None)
|
||||
updated_id = create_update_event_person_obj_v4(
|
||||
event_person_dict_obj=event_person_data,
|
||||
event_person_id=event_person_id,
|
||||
account_id=account_id,
|
||||
event_id=event_id,
|
||||
event_badge_id=event_badge_id,
|
||||
event_person_profile_id=event_person_profile_id,
|
||||
)
|
||||
if updated_id:
|
||||
log.warning(f'Event Person updated. ID: {updated_id}')
|
||||
else:
|
||||
log.warning(f'Event Person not updated. ID: {event_person_id}')
|
||||
else:
|
||||
log.info('No Event Person found. Creating new...')
|
||||
result_id = create_update_event_person_obj_v4(
|
||||
event_person_dict_obj=event_person_data,
|
||||
account_id=account_id,
|
||||
event_id=event_id,
|
||||
)
|
||||
if result_id:
|
||||
log.warning(f'Event Person created. ID: {result_id}')
|
||||
else:
|
||||
log.warning('Event Person not created.')
|
||||
|
||||
event_badge_person_li.append(event_person_data)
|
||||
event_badge_person_summary_li.append(event_person_summary)
|
||||
|
||||
processed = len(event_badge_person_li)
|
||||
if return_detail:
|
||||
return mk_resp(data=event_badge_person_li, status_message=f'Splash XLSX import complete. Processed {processed} records, skipped {skipped_count}.', response=commons.response)
|
||||
else:
|
||||
return mk_resp(data=event_badge_person_summary_li, status_message=f'Splash XLSX import complete. Processed {processed} records, skipped {skipped_count}.', response=commons.response)
|
||||
@@ -28,6 +28,21 @@ from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _clean_datetime(value) -> str | None:
|
||||
"""Normalize datetime strings from CSV imports (handles \xa0 from Excel, 12-hour format)."""
|
||||
if not value:
|
||||
return None
|
||||
cleaned = str(value).replace('\xa0', ' ').strip()
|
||||
if not cleaned:
|
||||
return None
|
||||
for fmt in ('%m/%d/%Y %I:%M %p', '%m/%d/%Y %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M'):
|
||||
try:
|
||||
return datetime.datetime.strptime(cleaned, fmt).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
continue
|
||||
return cleaned
|
||||
|
||||
|
||||
# No longer needed? 2024-08-15
|
||||
# Based on the program import template the clients are given.
|
||||
# Ideally the import file should only contain records with new External IDs. Old records will be checked and only updated if needed.
|
||||
@@ -332,7 +347,10 @@ router = APIRouter()
|
||||
# ### BEGIN ### Event Importing ### event_importing_program_data() ###
|
||||
# Based on the program import template the clients are given.
|
||||
# Create and update locations, sessions, presentations, and presenters as needed.
|
||||
# Updated 2024-03-25
|
||||
# Careful with how date and time fields are combined
|
||||
# This should work: =TEXT(G2,"M/D/YYYY")&" "&TEXT(H2,"H:MM AM/PM")
|
||||
# Simply adding the fields (=D264+E264) sort of works. This produces non breaking spaces but clean up on import.
|
||||
# Updated 2026-05-15
|
||||
@router.post('/event/{event_id}/importing/program_data', response_model=Resp_Body_Base)
|
||||
async def event_importing_program_data(
|
||||
event_id: str = Path(min_length=11, max_length=22),
|
||||
@@ -656,13 +674,8 @@ async def event_importing_program_data(
|
||||
if record.get('session_description'):
|
||||
event_session_data['description'] = record.get('session_description', '').strip()
|
||||
|
||||
event_session_data['start_datetime'] = record.get('session_start_datetime', '').strip()
|
||||
# event_session_start_datetime = record.get('event_session_start_date', '') + ' ' + record.get('event_session_start_time', '')
|
||||
# event_session_data['start_datetime'] = event_session_start_datetime
|
||||
|
||||
event_session_data['end_datetime'] = record.get('session_end_datetime', '').strip()
|
||||
# event_session_end_datetime = record.get('event_session_end_date', '') + ' ' + record.get('event_session_end_time', '')
|
||||
# event_session_data['end_datetime'] = event_session_end_datetime
|
||||
event_session_data['start_datetime'] = _clean_datetime(record.get('session_start_datetime'))
|
||||
event_session_data['end_datetime'] = _clean_datetime(record.get('session_end_datetime'))
|
||||
|
||||
event_session_data['sort'] = record.get('session_sort')
|
||||
|
||||
@@ -736,19 +749,11 @@ async def event_importing_program_data(
|
||||
if record.get('presentation_description'):
|
||||
event_presentation_data['description'] = record.get('presentation_description', '').strip()
|
||||
|
||||
if record.get('presentation_start_datetime'):
|
||||
event_presentation_data['start_datetime'] = record.get('presentation_start_datetime', '').strip()
|
||||
data['presentation_start_datetime'] = event_presentation_data['start_datetime']
|
||||
else:
|
||||
event_presentation_data['start_datetime'] = None
|
||||
data['presentation_start_datetime'] = None
|
||||
event_presentation_data['start_datetime'] = _clean_datetime(record.get('presentation_start_datetime'))
|
||||
data['presentation_start_datetime'] = event_presentation_data['start_datetime']
|
||||
|
||||
if record.get('presentation_end_datetime'):
|
||||
event_presentation_data['end_datetime'] = record.get('presentation_end_datetime', '').strip()
|
||||
data['presentation_end_datetime'] = event_presentation_data['end_datetime']
|
||||
else:
|
||||
event_presentation_data['end_datetime'] = None
|
||||
data['presentation_end_datetime'] = None
|
||||
event_presentation_data['end_datetime'] = _clean_datetime(record.get('presentation_end_datetime'))
|
||||
data['presentation_end_datetime'] = event_presentation_data['end_datetime']
|
||||
|
||||
if record.get('presentation_abstract_code'):
|
||||
event_presentation_data['abstract_code'] = record.get('presentation_abstract_code', '').strip()
|
||||
|
||||
@@ -5,7 +5,8 @@ from app.routers import (
|
||||
data_store,
|
||||
event_badge_importing,
|
||||
event_importing,
|
||||
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3,
|
||||
api_v3_actions_email,
|
||||
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_idaa, api_v3_actions_user, lookup_v3,
|
||||
user,
|
||||
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
||||
)
|
||||
@@ -23,7 +24,7 @@ def setup_routers(app: FastAPI):
|
||||
|
||||
app.include_router(api.router, prefix='/api', tags=['API'])
|
||||
# app.include_router(flask_cfg.router, prefix='/flask_cfg', tags=['Flask CFG'], dependencies=[Depends(DeprecationParams)])
|
||||
app.include_router(importing.router, prefix='/importing', tags=['Importing'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(importing.router, prefix='/importing', tags=['Importing'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(sql.router, tags=['SQL']) # LEGACY (disabled) - raw SQL select endpoint, testing only
|
||||
# app.include_router(account.router, tags=['Account'], dependencies=[Depends(DeprecationParams)])
|
||||
|
||||
@@ -50,7 +51,9 @@ def setup_routers(app: FastAPI):
|
||||
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_idaa.router, prefix='/v3/action/idaa', tags=['IDAA Actions (V3)'])
|
||||
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)'])
|
||||
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
|
||||
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
|
||||
|
||||
@@ -62,13 +65,14 @@ def setup_routers(app: FastAPI):
|
||||
# app.include_router(qr.router, tags=['QR'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)])
|
||||
app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
|
||||
app.include_router(util_email.router, tags=['Utility: Email'])
|
||||
# app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(util_email.router, tags=['Utility: Email']) # LEGACY (disabled) - superseded by /v3/action/email/send
|
||||
# app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3
|
||||
# app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3
|
||||
app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3'])
|
||||
|
||||
app.include_router(e_confex.router, prefix='/e/confex', tags=['External Service: Confex'])
|
||||
app.include_router(e_cvent.router, prefix='/e/cvent', tags=['External Service: Cvent'])
|
||||
app.include_router(e_impexium.router, prefix='/e/impexium', tags=['External Service: Impexium'])
|
||||
app.include_router(e_stripe.router, prefix='/e/stripe', tags=['External Service: Stripe'])
|
||||
# ALERT: Temporarily commenting these out until needed for external service integrations. They can be re-enabled as needed.
|
||||
# app.include_router(e_confex.router, prefix='/e/confex', tags=['External Service: Confex'])
|
||||
# app.include_router(e_cvent.router, prefix='/e/cvent', tags=['External Service: Cvent'])
|
||||
# app.include_router(e_impexium.router, prefix='/e/impexium', tags=['External Service: Impexium'])
|
||||
# app.include_router(e_stripe.router, prefix='/e/stripe', tags=['External Service: Stripe'])
|
||||
|
||||
@@ -77,18 +77,20 @@ async def patch_site_domain_obj(
|
||||
@router.get('/site/domain/fqdn/{fqdn}', response_model=Resp_Body_Base)
|
||||
async def lookup_site_domain_obj(
|
||||
fqdn: str,
|
||||
# x_account_id: str = Header(...),
|
||||
# response: Response = Response,
|
||||
|
||||
commons: Common_Route_Params_Min = Depends(common_route_params_min),
|
||||
# x_account_id: str = Header(...),
|
||||
# response: Response = Response,
|
||||
access_key: Optional[str] = Query(None, min_length=4, max_length=50),
|
||||
referrer: Optional[str] = Query(None, min_length=8, max_length=150),
|
||||
commons: Common_Route_Params_Min = Depends(common_route_params_min),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
|
||||
# Updated 2021-12-13
|
||||
# Updated 2021-12-13
|
||||
if site_domain_rec_list_result := lookup_site_domain_fqdn(
|
||||
fqdn = fqdn,
|
||||
access_key = access_key,
|
||||
referrer = referrer,
|
||||
enabled = commons.enabled,
|
||||
limit = commons.limit,
|
||||
offset = commons.offset
|
||||
|
||||
@@ -37,11 +37,73 @@ Finalized Jan 15, 2026, to ensure boot stability.
|
||||
- **POST Based**: Complex filtering is handled via `POST /search` with a JSON body containing `and`, `or`, and `not` logic.
|
||||
- **Hybrid Filtering**: (Proposed) Query parameters should append simple standard filters (e.g., `?enabled=true`) to the complex body logic.
|
||||
|
||||
### Response Views (Proposed)
|
||||
- Implement a `view` parameter (e.g., `?view=rich`) to allow clients to request joined data without using legacy `use_alt_tbl` flags.
|
||||
### Field Evolution Checklist
|
||||
When a table or view gains, loses, or renames fields, keep the API contract and search registry in sync:
|
||||
|
||||
## 4. Stability Rules
|
||||
1. Update the Pydantic model in `app/models/` first so CRUD serialization matches the new shape.
|
||||
2. Update the SQL view or table projection so `GET` and `SEARCH` responses actually return the field.
|
||||
3. Update `searchable_fields` in `app/object_definitions/` only for fields that should be searchable.
|
||||
4. Add write-only, virtual, or view-only fields to `fields_to_exclude_from_db` when they must not be persisted.
|
||||
5. Run the schema/search E2E tests that cover the object type before handing the change off.
|
||||
6. **Restart the Docker API containers** (`docker compose restart ae_api`) — Python file changes inside containers are not picked up until restart.
|
||||
|
||||
For `archive_content`, the public field set now includes `external_id` and `code`, and future additions should follow the same order of operations.
|
||||
|
||||
#### Alt-view fields (fields only in `tbl_alt`)
|
||||
Some objects have a richer alternate SQL view (`tbl_alt`) that adds JOINed/computed columns absent from the default view (`tbl_default`). For example, `event_session` uses `v_event_session_w_file_count` as its alt view (triggered by `?view=alt` on search, or `?inc_file_count=true` on GET).
|
||||
|
||||
- Fields from `tbl_alt` **must still be declared in the Pydantic model** and in `searchable_fields` — Pydantic strips undeclared fields, and the search whitelist rejects unknown field names regardless of the view.
|
||||
- When adding such a field, add a comment noting which view provides it (e.g., `# from v_event_session_w_file_count`).
|
||||
- Searching by an alt-view field on the default endpoint returns `400 Unknown column` — this is correct behaviour. Clients must pass `?view=alt` to use those fields in a search.
|
||||
- **Known alt-view fields restored May 2026:** `event_presentation_li_qry_str`, `event_presenter_li_qry_str` (event_session); `account_name`, `account_code`, and related convenience fields (site_domain).
|
||||
|
||||
### Response Views (`?view=` parameter)
|
||||
- The nested search router (`api_crud_v3_nested.py`) already supports `?view=<key>` to switch between registered views. `view=default` uses `tbl_default`; `view=alt` uses `tbl_alt`; additional named views can be added to the object registry as `tbl_<name>` / `mdl_<name>`.
|
||||
- Flat search (`api_crud_v3.py`) does not yet support `?view=` — it always uses `tbl_default`.
|
||||
|
||||
## 4. V3 Dependency Injection Reference
|
||||
|
||||
All V3 endpoints use granular, composable `Depends()` from `app/lib_general_v3.py`:
|
||||
|
||||
| Dependency | Purpose |
|
||||
|---|---|
|
||||
| `get_account_context` → `AccountContext` | Resolves `account_id` with precedence: Header → Query Token → Bypass Header. Raises 403 on guest/missing context. |
|
||||
| `PaginationParams` | Standardizes `limit` and `offset`. |
|
||||
| `StatusFilterParams` | Handles `enabled` and `hidden` filtering. |
|
||||
| `SerializationParams` | Controls Pydantic serialization (`by_alias`, `exclude_unset`). |
|
||||
| `DelayParams` | Optional latency simulation (`?delay=N`) via `await asyncio.sleep()`. |
|
||||
|
||||
`AccountContext` also carries `administrator`, `manager`, and `super` flags, populated by a deferred DB lookup when a JWT is present. These flags control whether account isolation is bypassed for support tasks.
|
||||
|
||||
## 5. Security and Data Isolation
|
||||
|
||||
### Fail-Closed Strategy
|
||||
If `account_id` or auth context is missing, the API defaults to a blocking filter (`account_id IS NULL`) — it does NOT fall back to returning all records. Never relax this.
|
||||
|
||||
### Multi-Tenant Isolation
|
||||
- **Forced account filtering**: `apply_forced_account_filter` injects an `account_id` WHERE clause into every list/search query for non-super users.
|
||||
- **Post-retrieval verification**: Single-object GET, PATCH, DELETE include a secondary ownership check (`check_account_access`). A mismatch returns 403.
|
||||
- **Hierarchical verification**: Nested endpoints verify parent ownership before allowing operations on children.
|
||||
- **Creation guard**: On POST, the user's `account_id` is automatically forced onto the new record.
|
||||
|
||||
### IDAA Privacy Baseline
|
||||
No IDAA object (Events, Files, Posts, Meetings) is public by default. All routes require `x-account-id` context. The sole exception is `site_domain` (used for site bootstrapping). This is a **Sev-1 class constraint** — violating it has happened before.
|
||||
|
||||
### Bypass / Admin Access
|
||||
- `x-no-account-id: bypass` → grants super access, resolves to `account_id=1` (One Sky IT Demo). Use only in internal/development utilities; do not expand its use.
|
||||
- JWT query parameter (`?jwt=...`) is supported for download links and share URLs where custom headers cannot be provided.
|
||||
|
||||
## 6. FastAPI and Pydantic Gotchas
|
||||
|
||||
- **`response: Response` injection**: Use it as a direct type hint in function signature. `Depends(Response)` is not valid and causes router initialization failures.
|
||||
- **Parameter order**: In function signatures, arguments without defaults must come before `Depends()` arguments.
|
||||
- **`asyncio.sleep()` not `time.sleep()`**: Blocking the event loop in an async endpoint causes worker timeouts and `502 Bad Gateway` under load.
|
||||
- **Pydantic V1 only**: Do not use V2-only features (`computed_field`, `model_validator`, etc.). The migration is a separate planned project — see strategic goals in `TODO__Agents.md`.
|
||||
- **`obj_type_kv_li` in `ae_obj_types_def.py`**: Supports both modern keys (`tbl`, `mdl`) and legacy keys (`table_name`, `base_name`). Legacy V2 endpoints depend on the legacy keys — do not remove them until V1/V2 are fully retired.
|
||||
|
||||
## 7. Stability Rules
|
||||
|
||||
1. **Baby Step Testing**: Restart Docker and verify root health after *every* modular change.
|
||||
2. **Avoid Shadowing**: Never name a module part of the `app.` package the same as a common instance variable (e.g., avoid `app.middleware` package if you use `app = FastAPI()`).
|
||||
3. **Deferred Imports**: Use `from app.db_sql import ...` *inside* functions in library modules to prevent circular dependency traps.
|
||||
4. **Model changes require container restart**: Editing Python files on the host does not hot-reload inside Docker. Always run `docker compose restart ae_api` after model or object-definition changes, then re-run E2E tests.
|
||||
|
||||
420
documentation/BOOTSTRAP__AI_Agent_Quickstart.md
Normal file
420
documentation/BOOTSTRAP__AI_Agent_Quickstart.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Aether API — AI Agent Bootstrap / Quickstart
|
||||
> **Read this first.** This doc is the fast path to being productive on this project.
|
||||
> It covers the rules, patterns, and gotchas that matter most.
|
||||
> Deep dives are in the linked docs at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## 1. What This Project Is
|
||||
|
||||
**Aether** is an event management platform built by One Sky IT (Scott Idem).
|
||||
This repo is the backend: **FastAPI + MariaDB**, running inside Docker.
|
||||
|
||||
The frontend (`aether_app_sveltekit/`) talks to this API exclusively via the V3 REST API.
|
||||
There is **no standalone dev server** — the API runs only in Docker.
|
||||
|
||||
**Key clients:**
|
||||
- **Conference organizers** — Presentation management, badges, sessions
|
||||
- **Exhibitors** — Leads capture
|
||||
- **IDAA** — International Doctors in Alcoholics Anonymous (strictly private medical/recovery community)
|
||||
|
||||
**Stack at a glance:**
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Framework | FastAPI + Pydantic V1 (upgrade deferred) |
|
||||
| Database | MariaDB via SQLAlchemy 1.4 (upgrade deferred) |
|
||||
| Cache | Redis |
|
||||
| Auth | Custom headers: `x-aether-api-key` + `x-account-id` |
|
||||
| Container | Docker / Gunicorn — source is volume-mounted (no rebuild for Python changes, but **restart required**) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Rules — Read Before Touching Any Code
|
||||
|
||||
### Privacy (Sev-1 class failures if violated)
|
||||
- **IDAA content is ALWAYS private.** All routes under `/idaa/` require authentication.
|
||||
A previous agent accidentally exposed IDAA bulletin board data publicly.
|
||||
This is the single most serious class of mistake on this project.
|
||||
When in doubt — it's private. Always verify with `Depends(get_account_context)`.
|
||||
- **Journals** are private personal data. Always authenticated.
|
||||
|
||||
### File Safety
|
||||
- **Never use `rm`** to delete files. Move to `~/tmp/gemini_trash` instead.
|
||||
- **Never commit `.env`** files, API keys, or passwords of any kind.
|
||||
- Third-party credentials (Novi API key, Mailman credentials) live in the **MariaDB `site.cfg_json` column**, not in `.env` or code.
|
||||
|
||||
### Before Every Commit
|
||||
1. `python3 -m py_compile <changed_file>` — syntax check
|
||||
2. Restart Docker and verify the API starts clean: `docker compose restart ae_api`
|
||||
3. Check logs: `docker compose logs -f ae_api` (look for startup errors or import failures)
|
||||
4. Run the relevant E2E or unit test suite
|
||||
|
||||
### Before Starting Any Task
|
||||
- Read `documentation/TODO__Agents.md` — active tasks, known bugs, and what was recently changed and why.
|
||||
- Check `tests/README.md` — which test suite covers the area you're about to touch.
|
||||
|
||||
### Docker Restart is Mandatory After Python Changes
|
||||
The API source is volume-mounted, so file edits appear instantly inside the container — but
|
||||
**Gunicorn does NOT hot-reload**. You MUST run:
|
||||
```bash
|
||||
docker compose restart ae_api
|
||||
```
|
||||
after any Python file change before testing. This is the #1 cause of "my change didn't take effect."
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment & Commands Cheat Sheet
|
||||
|
||||
```bash
|
||||
# Start full stack
|
||||
cd ~/OSIT_dev/aether_container_env && docker compose up -d
|
||||
|
||||
# Restart API after any Python change
|
||||
docker compose restart ae_api
|
||||
|
||||
# Follow API logs
|
||||
docker compose logs -f ae_api
|
||||
|
||||
# Shell into the container
|
||||
docker compose exec ae_api bash
|
||||
|
||||
# Run unit tests (from project root)
|
||||
./environment/bin/python3 -m pytest tests/unit/ -v
|
||||
|
||||
# Run a single E2E test
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
|
||||
|
||||
# Check a specific Python file compiles
|
||||
./environment/bin/python3 -m py_compile app/methods/my_new_methods.py
|
||||
```
|
||||
|
||||
**Development API:** `https://dev-api.oneskyit.com`
|
||||
**Dev API secret key:** `nT0jPeiCfxSifkiDZur9jA`
|
||||
**Standard test Agent API key:** `PMM4n50teUCaOMMTN8qOJA`
|
||||
|
||||
**Dev DB via phpMyAdmin:** `http://localhost:8081`
|
||||
|
||||
---
|
||||
|
||||
## 4. V3 Action Router Pattern
|
||||
|
||||
When adding a new action endpoint (not CRUD), follow this structure:
|
||||
|
||||
### File layout
|
||||
```
|
||||
app/methods/my_feature_methods.py ← business logic
|
||||
app/routers/api_v3_actions_my_feature.py ← route handler (thin)
|
||||
```
|
||||
|
||||
Then register in `app/routers/registry.py`:
|
||||
```python
|
||||
from app.routers import api_v3_actions_my_feature
|
||||
# ...
|
||||
app.include_router(api_v3_actions_my_feature.router, prefix='/v3/action/my_feature', tags=['My Feature (V3 Actions)'])
|
||||
```
|
||||
|
||||
### Standard route handler pattern
|
||||
```python
|
||||
from fastapi import APIRouter, Depends
|
||||
from app.lib_general_v3 import AccountContext, get_account_context, DelayParams
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
from app.methods.my_feature_methods import my_business_logic
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get('/my_endpoint/{obj_id}', response_model=Resp_Body_Base)
|
||||
async def get_my_endpoint(
|
||||
obj_id: str,
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
if delay.sleep_time_s > 0:
|
||||
await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
result = my_business_logic(obj_id)
|
||||
status = result.get('status', 503)
|
||||
|
||||
if status == 200:
|
||||
return mk_resp(data=result['data'])
|
||||
|
||||
return mk_resp(data=False, status_code=status, status_message=result.get('reason', 'Error.'))
|
||||
```
|
||||
|
||||
### Auth dependency
|
||||
`Depends(get_account_context)` is the **standard V3 gate**:
|
||||
- Requires a valid `x-aether-api-key`
|
||||
- Requires `x-account-id` OR a valid JWT session OR bypass mode
|
||||
- Raises 403 if `auth_method == 'guest'` or no account context can be resolved
|
||||
- For IDAA/private routes: this is the minimum gate. Never relax it.
|
||||
|
||||
---
|
||||
|
||||
## 5. Loading Site-Based Credentials (the `_load_idaa_cfg` Pattern)
|
||||
|
||||
Third-party credentials (Novi API key, Mailman credentials, etc.) are stored in MariaDB
|
||||
in the `site.cfg_json` column for the relevant site record. Do NOT store them in `.env`.
|
||||
|
||||
The IDAA site record is:
|
||||
- `id_random = '58_gJESdlUh'` (site id=17)
|
||||
- Fields: `novi_api_root_url`, `novi_idaa_api_key`, `mailman_base_url`, `mailman_username`, `mailman_password`, `novi_mailman_sync`
|
||||
|
||||
Pattern to load them (use deferred import to avoid circular deps):
|
||||
```python
|
||||
IDAA_SITE_ID_RANDOM = '58_gJESdlUh'
|
||||
|
||||
def _load_idaa_cfg() -> dict:
|
||||
from app.db_sql import load_site_obj
|
||||
site = load_site_obj(IDAA_SITE_ID_RANDOM)
|
||||
if not site:
|
||||
raise RuntimeError('IDAA site record not found')
|
||||
cfg = site.get('cfg_json') or {}
|
||||
if isinstance(cfg, str):
|
||||
import json
|
||||
cfg = json.loads(cfg)
|
||||
return cfg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Redis Cache Pattern
|
||||
|
||||
```python
|
||||
import json
|
||||
import datetime
|
||||
from app.lib_redis_helpers import redis_client
|
||||
|
||||
_CACHE_TTL = datetime.timedelta(hours=4)
|
||||
|
||||
def _cache_key(uuid: str) -> str:
|
||||
return f'idaa:novi_member:{uuid}'
|
||||
|
||||
# Reading
|
||||
raw = redis_client.get(_cache_key(uuid))
|
||||
if raw:
|
||||
return json.loads(raw)
|
||||
|
||||
# Writing (only on success — never cache error states)
|
||||
redis_client.setex(_cache_key(uuid), _CACHE_TTL, json.dumps(result))
|
||||
```
|
||||
|
||||
**Key naming convention:** `{module}:{object_type}:{identifier}` — e.g. `idaa:novi_member:{uuid}`
|
||||
|
||||
**Never cache:**
|
||||
- 404 responses — the member may have just joined; next call should hit the source
|
||||
- 429 / 503 errors — transient failures should not poison future callers
|
||||
|
||||
**Cache key scoping:** If the underlying data source is the same regardless of caller
|
||||
(e.g. Novi credentials are hardcoded to the IDAA site — same UUID always returns same data),
|
||||
drop `account_id` from the key. Per-caller scoping wastes Redis space and halves hit rate.
|
||||
|
||||
---
|
||||
|
||||
## 7. The `@logger_reset` Decorator — Unit Test Gotcha
|
||||
|
||||
Business logic methods use `@logger_reset` from `app.lib_general`:
|
||||
|
||||
```python
|
||||
from app.lib_general import logger_reset
|
||||
|
||||
@logger_reset
|
||||
def my_method(arg):
|
||||
...
|
||||
```
|
||||
|
||||
**In unit tests, this decorator MUST be mocked as a passthrough.**
|
||||
If `app.lib_general` is replaced with a plain `MagicMock()`, the decorator becomes a
|
||||
MagicMock, which when applied to a function replaces it with `MagicMock()()` — the decorated
|
||||
function is gone and every call returns garbage.
|
||||
|
||||
```python
|
||||
# WRONG — logger_reset becomes a MagicMock and swallows the function:
|
||||
sys.modules['app.lib_general'] = MagicMock()
|
||||
|
||||
# CORRECT — make it a passthrough decorator:
|
||||
mock_lib_general = MagicMock()
|
||||
mock_lib_general.logger_reset = lambda f: f
|
||||
sys.modules['app.lib_general'] = mock_lib_general
|
||||
```
|
||||
|
||||
This is the #1 cause of unit tests returning `<MagicMock name='...'>` instead of real dicts.
|
||||
|
||||
---
|
||||
|
||||
## 8. Field Evolution Checklist
|
||||
|
||||
When a table or view gains, loses, or renames fields — **do all of these in order**:
|
||||
|
||||
1. Update the Pydantic model in `app/models/`
|
||||
2. Update the SQL view or table projection so GET/SEARCH return the field
|
||||
3. Update `searchable_fields` in `app/object_definitions/` (only for searchable fields)
|
||||
4. Add virtual/view-only fields to `fields_to_exclude_from_db` if they must not be persisted
|
||||
5. Run the relevant schema/search E2E tests
|
||||
6. **Restart Docker:** `docker compose restart ae_api`
|
||||
|
||||
#### Alt-view fields (in `tbl_alt` only)
|
||||
Some objects have a richer alternate SQL view triggered by `?view=alt`. These fields
|
||||
**must still be declared in the Pydantic model and `searchable_fields`** even if they only
|
||||
appear in the alt view — Pydantic strips undeclared fields silently.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deferred Imports in Library Modules
|
||||
|
||||
To avoid circular dependency traps, **never import from `app.db_sql` or other app modules
|
||||
at the top level of a library module**. Use deferred imports inside functions:
|
||||
|
||||
```python
|
||||
# WRONG — causes circular import at startup:
|
||||
from app.db_sql import load_site_obj
|
||||
|
||||
def my_func():
|
||||
return load_site_obj('abc')
|
||||
|
||||
# CORRECT:
|
||||
def my_func():
|
||||
from app.db_sql import load_site_obj
|
||||
return load_site_obj('abc')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Pydantic / SQLAlchemy Version Pins — Do Not Remove
|
||||
|
||||
Current intentional pins:
|
||||
- `pydantic==1.*`
|
||||
- `SQLAlchemy==1.4.52`
|
||||
|
||||
A Pydantic V2 and SQLAlchemy 2.0 migration is planned but not started. Until then,
|
||||
**do not upgrade these packages** — V2 touches every model definition and the migration
|
||||
is a dedicated project.
|
||||
|
||||
---
|
||||
|
||||
## 11. Mistakes Agents Have Made on This Project
|
||||
|
||||
These are real incidents — know them before you start.
|
||||
|
||||
1. **IDAA data exposed publicly** — an agent removed an auth guard from the bulletin board
|
||||
router. Consequence: private IDAA recovery community data was publicly accessible.
|
||||
Always verify `Depends(get_account_context)` is present on every IDAA route.
|
||||
|
||||
2. **"My code change has no effect"** — Python file was edited but Docker was not restarted.
|
||||
The API runs Gunicorn inside Docker with no hot-reload. `docker compose restart ae_api`
|
||||
is required after every Python change.
|
||||
|
||||
3. **`@logger_reset` mock swallows functions in unit tests** — see Section 7 above.
|
||||
Symptom: `assert result['status'] == 200` fails with `TypeError: 'MagicMock' is not subscriptable`.
|
||||
|
||||
4. **`pytest` / `pytest-asyncio` not installed after venv rebuild** — these are dev-only
|
||||
dependencies not in `requirements.txt`. After any OS Python update (e.g., Arch Linux
|
||||
upgrading to a new Python minor), rebuild the venv and reinstall:
|
||||
```bash
|
||||
./environment/bin/pip install pytest pytest-asyncio
|
||||
```
|
||||
|
||||
5. **Global `db` connection used instead of context manager** — `lib_sql_core.py` has a
|
||||
global `db = engine.connect()` that is a fragile single connection, not a pool.
|
||||
For new methods, prefer `engine.connect()` as a context manager. See `TODO__Agents.md`
|
||||
→ "[P3 full]" task for the planned migration.
|
||||
|
||||
6. **`bypass` mode hardcodes `account_id=1`** — `x-no-account-id: bypass` resolves to
|
||||
`account_id=1` (One Sky IT Demo). Lookup overrides from the Demo account can leak into
|
||||
bypass sessions. Do not expand bypass usage without documenting the allowlist case.
|
||||
|
||||
7. **Caching error states in Redis** — caching 404 or 5xx responses poisons the cache.
|
||||
A member who just joined Novi would be denied for 4 hours if their 404 was cached.
|
||||
Only cache verified success (200) results.
|
||||
|
||||
8. **Not running `docker compose restart ae_api` between model changes and E2E tests** —
|
||||
the E2E suite hits the live API, which is still running the old code until restarted.
|
||||
Tests will pass or fail against stale behavior and the results are meaningless.
|
||||
|
||||
---
|
||||
|
||||
## 12. Test Patterns
|
||||
|
||||
### Unit tests (fast, no DB/network)
|
||||
```bash
|
||||
./environment/bin/python3 -m pytest tests/unit/ -v
|
||||
```
|
||||
- Mock all DB/Redis/HTTP at the top of the file before importing the module under test
|
||||
- `@logger_reset` must be mocked as a passthrough (see Section 7)
|
||||
- Always run from the **project root** — scripts use `sys.path.append(os.getcwd())`
|
||||
|
||||
### E2E tests (live API at dev-api.oneskyit.com)
|
||||
```bash
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
|
||||
```
|
||||
- Require the Docker stack to be running with a working DB connection
|
||||
- Use standard output format: `[✅ PASS]` / `[❌ FAIL]`
|
||||
- Verify IDs in responses are **strings** (not integers) — ID Vision compliance
|
||||
|
||||
### Which tests to run
|
||||
| Change type | Required suites |
|
||||
|---|---|
|
||||
| Model / ID Vision changes | `test_e2e_v3_demo_parity.py`, vision parity tests |
|
||||
| Search / filter changes | `test_e2e_v3_search_engine.py` |
|
||||
| Auth / account context changes | `test_e2e_v3_security_audit.py`, `test_e2e_v3_auth_security.py` |
|
||||
| Any router or registry change | `test_e2e_v3_security_audit.py` |
|
||||
| Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py` |
|
||||
| IDAA Novi verify changes | `tests/unit/test_unit_idaa_novi_verify.py` |
|
||||
|
||||
---
|
||||
|
||||
## 13. Source Layout (Quick Reference)
|
||||
|
||||
```
|
||||
app/
|
||||
main.py — FastAPI app, lifespan, CORS
|
||||
config.py — Pydantic Settings (from .env via Docker)
|
||||
routers/
|
||||
registry.py — All router registrations live here
|
||||
api_crud_v3.py — Generic V3 CRUD (flat)
|
||||
api_crud_v3_nested.py — Generic V3 CRUD (nested/parent-owned)
|
||||
api_v3_actions_*.py — Action endpoints (one file per domain)
|
||||
dependencies_v3.py — Shared FastAPI Depends() helpers
|
||||
models/ — Pydantic V1 models (one file per domain)
|
||||
object_definitions/ — Per-object searchable_fields, field metadata
|
||||
ae_obj_types_def.py — Object type registry (all Aether types defined here)
|
||||
methods/ — Business logic (one file per feature/domain)
|
||||
lib_api_crud_v3.py — Generic CRUD handler (all object types share this)
|
||||
lib_schema_v3.py — Dynamic schema/field resolution per object type
|
||||
lib_general_v3.py — AccountContext, get_account_context, DelayParams
|
||||
lib_general.py — logger_reset and general utilities
|
||||
db_sql.py — Import facade (always import from here)
|
||||
lib_sql_core.py — SQLAlchemy engine, global db connection (Source of Truth)
|
||||
lib_sql_crud.py — sql_insert, sql_select, sql_update, etc.
|
||||
lib_redis_helpers.py — redis_client global instance
|
||||
|
||||
tests/
|
||||
unit/ — Isolated logic tests (mock everything, no DB)
|
||||
integration/ — Requires local MariaDB/Redis
|
||||
e2e/ — Network tests against live dev API
|
||||
tools/ — Admin utilities (stress test, registry generator)
|
||||
mock_config_helper.py — Mock app.config.settings — use in all unit tests
|
||||
README.md — Which tests cover what; when to run them
|
||||
|
||||
documentation/
|
||||
TODO__Agents.md — Active tasks + session notes ← always read first
|
||||
ARCH__V3_DEVELOPMENT_STANDARDS.md — Master V3 standards doc
|
||||
ARCH__V3_CORE.md — Module architecture (lifespan, DB layers, logging)
|
||||
GUIDE__AE_API_V3_for_Frontend.md — Frontend integration guide (keep current)
|
||||
GUIDE__DEVELOPMENT.md — Commit SOP, verification checklist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. Reading Order for Deeper Dives
|
||||
|
||||
| What you need | Read |
|
||||
|---|---|
|
||||
| Active tasks + known bugs | `documentation/TODO__Agents.md` ← always first |
|
||||
| V3 standards and strategy | `documentation/ARCH__V3_DEVELOPMENT_STANDARDS.md` |
|
||||
| Module architecture | `documentation/ARCH__V3_CORE.md` |
|
||||
| Frontend API integration guide | `documentation/GUIDE__AE_API_V3_for_Frontend.md` |
|
||||
| WebSocket integration | `documentation/GUIDE__AE_API_V3_for_Frontend_websockets.md` |
|
||||
| Commit / verification SOP | `documentation/GUIDE__DEVELOPMENT.md` |
|
||||
| Which tests to run | `tests/README.md` |
|
||||
| Object type registry | `app/ae_obj_types_def.py` |
|
||||
| Shared agent docs | `~/agents_sync/aether/docs/UNIFIED_AGENT_ARCH.md` |
|
||||
@@ -19,8 +19,15 @@ Required for any non-public data (Journals, Badges, Users, etc.).
|
||||
* **Header:** `x-account-id: <account_id>`
|
||||
2. **Administrative Bypass**: For authorized scripts needing global access.
|
||||
* **Header:** `x-no-account-id: bypass`
|
||||
* **Scope:** Narrow escape hatch only. Keep it limited to allowlisted bootstrap/public/global-default paths and prefer `x-account-id` or JWT-backed requests everywhere else.
|
||||
3. **Token Access**: Provide a **JWT** in the query string.
|
||||
* **Query Param:** `?jwt=<token>`
|
||||
4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
|
||||
* `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
|
||||
* Only explicit `x-no-account-id: bypass` should strip account context.
|
||||
|
||||
> [!NOTE]
|
||||
> The `x-no-account-id` path should continue to shrink over time. If you need a new use, document why `x-account-id` or JWT cannot cover it and mark the use as temporary unless it is a hard bootstrap/global-default requirement.
|
||||
|
||||
> [!CAUTION]
|
||||
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
|
||||
@@ -44,6 +51,37 @@ When the frontend first loads and doesn't know the `account_id`, it performs a "
|
||||
* Returns 200 + a list containing the `account_id` (random string ID) and `site_id` (random string ID).
|
||||
* ** デザイン Choice:** If the domain is not found, it returns **200 OK with an empty list `[]`**. It is NOT a 404.
|
||||
|
||||
> **Access Key Support**
|
||||
>
|
||||
> Some client deployments restrict their domain via an access key passed in the browser URL (e.g. `?key=abc123`). The frontend reads this param and forwards it as `access_key` in the POST body.
|
||||
>
|
||||
> **How to pass the key:**
|
||||
> ```json
|
||||
> {
|
||||
> "and": [
|
||||
> { "field": "fqdn", "op": "eq", "value": "client.example.com" },
|
||||
> { "field": "access_key", "op": "eq", "value": "abc123" }
|
||||
> ]
|
||||
> }
|
||||
> ```
|
||||
> If `key` is absent, empty, or falsy — **omit `access_key` from the payload entirely**. Do not send `"access_key": ""`.
|
||||
>
|
||||
> **Server behavior:**
|
||||
> - `site_access_key` (site-level key) takes priority. If set, all domains under that site require it.
|
||||
> - `site_domain_access_key` (domain-level key) is used as fallback when `site_access_key` is not set.
|
||||
> - A domain is **public** only when **both** key columns are NULL/empty.
|
||||
> - Falsy `access_key` values are ignored server-side as a safety net.
|
||||
> - Match → `200` with the record. No match → `200` with empty list `[]`.
|
||||
> - Do **not** use `access_code_kv_json` for this — that field is for UI features only.
|
||||
>
|
||||
> | Browser URL | `access_key` in payload | Result |
|
||||
> |---|---|---|
|
||||
> | `https://dev-demo.oneskyit.com` | *(omit)* | ✅ Returns record (public) |
|
||||
> | `https://client.example.com/?key=correct` | `"correct"` | ✅ Returns record |
|
||||
> | `https://client.example.com/` | *(omit)* | ❌ Empty (key required) |
|
||||
> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty (wrong key) |
|
||||
> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty (key required) |
|
||||
>
|
||||
---
|
||||
|
||||
## 3. Standard CRUD Patterns
|
||||
@@ -68,6 +106,22 @@ Modify data in the system.
|
||||
* **Header:** `x-ae-ignore-extra-fields: true`
|
||||
* **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database.
|
||||
|
||||
#### `*_json` field serialization — do NOT pre-stringify in route/component code
|
||||
|
||||
The frontend API wrappers (`src/lib/ae_api/api_post__crud_obj.ts` for V3, `src/lib/api/api.ts` for legacy CRUD) automatically serialize any field whose name ends in `_json` (e.g. `cfg_json`, `data_json`) before sending. They pretty-print with 2-space indent via an internal `serialize_json_field_pretty()` helper.
|
||||
|
||||
**Pass `*_json` fields as plain JS objects from routes and components.** The serialization layer handles the rest.
|
||||
|
||||
```ts
|
||||
// ✅ Correct — pass as plain object; V3 wrapper serializes it
|
||||
await update_ae_obj__site({ site_id, data_kv: { cfg_json: { jitsi_token_endpoint: url } } });
|
||||
|
||||
// ❌ Wrong — double-encodes the JSON string (the wrapper would stringify an already-stringified value)
|
||||
await update_ae_obj__site({ site_id, data_kv: { cfg_json: JSON.stringify({ jitsi_token_endpoint: url }) } });
|
||||
```
|
||||
|
||||
The V3 wrapper (`api_post__crud_obj.ts`) only serializes when `typeof value === 'object'`, so it will not double-encode a plain string. The legacy wrapper (`api.ts`) stringifies unconditionally, so pre-stringifying there **will** produce double-encoded JSON. In both cases, the right answer is to pass the raw object and let the layer handle it.
|
||||
|
||||
### D. ID Fields in Responses (Vision ID Convention)
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -262,6 +316,108 @@ Frontend guidance:
|
||||
|
||||
---
|
||||
|
||||
## 8. Email Send Action
|
||||
|
||||
Send a transactional email via the Aether API.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/v3/action/email/send`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{
|
||||
"from_email": "noreply@example.com",
|
||||
"from_name": "Example App",
|
||||
"to_email": "user@example.com",
|
||||
"to_name": "Alice Smith",
|
||||
"subject": "Your login link",
|
||||
"body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
|
||||
"body_text": "Visit ... to log in.",
|
||||
"cc_email": null,
|
||||
"bcc_email": null
|
||||
}
|
||||
```
|
||||
|
||||
**Query params:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `test` | bool | `false` | Simulate send without delivering |
|
||||
|
||||
**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.
|
||||
|
||||
> **Replaces:** `POST /util/email/send` (disabled as of May 2026).
|
||||
|
||||
---
|
||||
|
||||
## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)
|
||||
|
||||
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
|
||||
|
||||
- **Endpoint:** `POST /event/{event_id}/badge/import/zoom_csv`
|
||||
- **Auth:** include `x-aether-api-key` (if required) and account context via `x-account-id: <ACCOUNT_ID>`. Admin bypass (`x-no-account-id: bypass`) or `?jwt=<token>` are accepted per site policy.
|
||||
- **Request:** `multipart/form-data` with single file field `file` (Zoom CSV). Query params:
|
||||
- `begin_at` (int, default `0`)
|
||||
- `end_at` (int, default `20000`)
|
||||
- `return_detail` (bool, default `false`)
|
||||
- Delimiter is auto-detected; Zoom CSV layout: row 1 = metadata, row 2 = blank, row 3 = headers (the backend skips the first two rows).
|
||||
|
||||
Behavior / notes:
|
||||
- The handler forces `Registrant email` to be used as the `external_id`. `Unique identifier` is used as `external_registration_id` only when it is meaningful (placeholders like `N/A`, `NA`, `UNKNOWN` are ignored).
|
||||
- Per-ticket custom fields are parsed (Organization, Job title, Phone, Address lines, City, State/Province, Postal/Zip, Country, etc.).
|
||||
- Marketing-consent values are mapped to `agree_to_tc` and `allow_tracking`.
|
||||
- TEMP AXONIUS MAPPING: the import temporarily defaults `event_badge_template_id` to `21` and `event_badge_template_id_random` to `RKYp2HcQm9o`. Ticket-name → `badge_type_code` mapping is applied for some labels (e.g., contains "sponsor" → `sponsor`; contains "attend"/"attendee" → `attendee`). This mapping is temporary (April 2026) — surface this to staff.
|
||||
- Rows missing `Registrant email` are skipped.
|
||||
- The server upserts via existing backend methods and creates/updates `event_person`, `event_person_profile`, and `event_badge` records as needed.
|
||||
|
||||
Frontend guidance:
|
||||
- UI must be staff-only and should validate an `event_id` is selected.
|
||||
- For large files, use `begin_at`/`end_at` to process in chunks.
|
||||
- Prefer `return_detail=false` for large imports to reduce payload size.
|
||||
|
||||
Common errors:
|
||||
- `403` — missing/invalid account context or API key.
|
||||
- `404` — event not found.
|
||||
- `500` — file save or processing error.
|
||||
|
||||
Example curl (replace placeholders):
|
||||
```bash
|
||||
curl -v -X POST "https://api.example.com/event/<EVENT_ID>/badge/import/zoom_csv?begin_at=0&end_at=20000&return_detail=false" \
|
||||
-H "x-aether-api-key: <API_KEY>" \
|
||||
-H "x-account-id: <ACCOUNT_ID>" \
|
||||
-F "file=@/path/to/zoom_export.csv"
|
||||
```
|
||||
|
||||
Sample success (summary mode, `return_detail=false`):
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"event_id": "xK9mP3qRtL2",
|
||||
"event_id_random": "xK9mP3qRtL2",
|
||||
"external_id": "alice@example.com",
|
||||
"given_name": "Alice",
|
||||
"family_name": "Smith",
|
||||
"email": "alice@example.com"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"status_code": 200,
|
||||
"status_name": "OK",
|
||||
"success": true,
|
||||
"data_type": "list",
|
||||
"data_list_count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sample success (detailed, `return_detail=true`) — `data` contains full `event_person` objects with nested `event_badge` (may include temporary `event_badge_template_id`: `21` and `event_badge_template_id_random`: `RKYp2HcQm9o`).
|
||||
|
||||
Paste this section into the guide as a temporary Axonius-specific note (April 2026). Consider linking staff to a sample Zoom CSV for QA.
|
||||
|
||||
---
|
||||
|
||||
## 7. User Actions (`/v3/action/user/`)
|
||||
|
||||
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
|
||||
@@ -303,7 +459,7 @@ or:
|
||||
|
||||
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
|
||||
|
||||
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found.
|
||||
**Errors:** `400` missing credentials, `403` wrong password / account disabled / account not yet enabled / account expired, `404` user not found.
|
||||
|
||||
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
|
||||
|
||||
@@ -323,7 +479,7 @@ Check a user's current password without changing it.
|
||||
```
|
||||
or use `"username"` instead of `"user_id"` to look up by username within the account.
|
||||
|
||||
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found.
|
||||
**Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
|
||||
|
||||
---
|
||||
|
||||
@@ -376,10 +532,19 @@ Generate a new auth key and email a one-time login link to the user's email addr
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `root_url` | `string` | `null` | Base URL the login link is built from. |
|
||||
| `root_url` | `string` | *(required)* | Base URL the login link is built from. Must be provided — if omitted the link in the email will be malformed (`None?...`). |
|
||||
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
|
||||
|
||||
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`).
|
||||
> [!IMPORTANT]
|
||||
> `root_url` is **required in practice**. The FastAPI query param accepts `null` but the email builder does not guard against it — omitting it produces a broken link in the email.
|
||||
|
||||
**Magic link URL format (default `key_param_name`):**
|
||||
```
|
||||
{root_url}?user_id={user_id_random}&auth_key={auth_key}&valid_email=True
|
||||
```
|
||||
The frontend at `root_url` should read these query params and call `POST /v3/action/user/authenticate` with `{ "user_id": "...", "auth_key": "..." }`. Note that `valid_email=True` is **always** injected — authenticating via a magic link automatically marks the user's email as verified.
|
||||
|
||||
**Response:** `data: true` on success (email sent). `404` if user not found. `500` if delivery failed — common causes: account email not configured, user `enable = false`, or `allow_auth_key = false`.
|
||||
|
||||
---
|
||||
|
||||
@@ -405,7 +570,7 @@ Results are automatically scoped to the `x-account-id` provided in the request.
|
||||
|
||||
---
|
||||
|
||||
## 9. Event Exhibit Tracking Export (Leads Export)
|
||||
## 10. Event Exhibit Tracking Export (Leads Export)
|
||||
|
||||
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
|
||||
|
||||
@@ -473,10 +638,67 @@ const url = URL.createObjectURL(blob);
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting 403 Forbidden
|
||||
## 12. IDAA: Server-Side Novi Member Verification
|
||||
|
||||
Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/v3/action/idaa/novi_member/{uuid}`
|
||||
- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`)
|
||||
|
||||
### Request
|
||||
|
||||
| Parameter | Location | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) |
|
||||
|
||||
### Response on success (`200 OK`)
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"verified": true,
|
||||
"full_name": "Alice S.",
|
||||
"email": "alice+member@idaa.org"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent.
|
||||
- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`).
|
||||
|
||||
### Error responses
|
||||
|
||||
| Status | Meaning | Frontend action |
|
||||
|---|---|---|
|
||||
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
|
||||
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
|
||||
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
|
||||
|
||||
### Migration from direct Novi call
|
||||
|
||||
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
|
||||
|
||||
| Direct Novi result | This endpoint returns | Frontend state |
|
||||
|---|---|---|
|
||||
| `200` with identity data | `200` | `verified` |
|
||||
| `200` with no identity data | `404` | `denied` |
|
||||
| `404` | `404` | `denied` |
|
||||
| `429` | `429` | `'rate_limited'` |
|
||||
| Network error / Novi 5xx | `503` | `'api_error'` |
|
||||
|
||||
### Caching
|
||||
|
||||
Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting 403 Forbidden
|
||||
|
||||
If you receive a 403 on a valid ID:
|
||||
1. Verify `x-aether-api-key` is correct.
|
||||
2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
|
||||
3. Verify the record actually belongs to the account ID you are sending.
|
||||
4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
|
||||
5. Confirm the frontend is not treating `params.key` as an implicit bypass and stripping `x-account-id`.
|
||||
6. If list/search endpoints work but `GET /v3/crud/{obj_type}/{id}` still returns 403, this is likely endpoint-level policy (e.g., requires stronger auth like JWT) rather than a transport/header bug.
|
||||
|
||||
386
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
386
documentation/PROJECT__AE_Site_Passcode_Security.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# PROJECT: Site Passcode Security — API-Verified Auth
|
||||
|
||||
**Last updated:** 2026-04-10
|
||||
**Status:** Backend work in progress — frontend pending backend completion
|
||||
**Priority:** High — passcodes for trusted/administrator access currently in localStorage plaintext
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When a user loads the Aether frontend, the site bootstrap response includes `access_code_kv_json` — a JSON object containing all passcodes for all access levels (administrator, trusted, public, authenticated). The frontend stores this verbatim in `$ae_loc.site_access_code_kv`, which is persisted in localStorage.
|
||||
|
||||
**Result:** Anyone with DevTools → Application → Local Storage can see every passcode for every access level on any Aether site. For public/authenticated this is low risk, but for trusted and administrator this is a real exposure — these passcodes can grant control over event data, badge printing, edit mode, etc.
|
||||
|
||||
The passcode check (`handle_check_access_type_passcode` in `e_app_access_type.svelte`) is entirely local — it reads the cached values and compares directly. No API call is made. The backend already has a `/authenticate_passcode` endpoint that verifies server-side, but it needs the fixes described below before the frontend can rely on it.
|
||||
|
||||
### Source of Truth
|
||||
|
||||
`site.access_code_kv_json` is the single source of truth for all passcodes. The `v_site_domain` DB view joins this field from the site table — there is no separate copy. Both the bootstrap response and `/authenticate_passcode` read from the same data.
|
||||
|
||||
---
|
||||
|
||||
## Threat Model
|
||||
|
||||
| Threat | Current | After Fix |
|
||||
|---|---|---|
|
||||
| Attacker inspects localStorage | Sees all passcodes in plaintext | Sees a JWT (opaque, no passcode) |
|
||||
| Attacker uses stolen trusted passcode | Trivial if they have localStorage access | Still possible if they enter the passcode — unavoidable |
|
||||
| Attacker replays an old passcode after it changes | Works forever (cached value never refreshes) | Fails — API verifies against current DB value |
|
||||
| Attacker tampers with `access_type` in localStorage | Grants apparent permission but API calls still fail | Same — `access_type` is still persisted separately |
|
||||
| Passcode reuse across sessions | Works indefinitely | JWT TTL enforces session expiry per role |
|
||||
| Offline / API-unavailable entry | Works (local cache) | **Blocked** — requires API to verify |
|
||||
|
||||
### The fundamental constraint
|
||||
|
||||
Passcode-based access is inherently weaker than username/password login with a hashed credential. The system's security model layers passcode access below user login, and API calls themselves are still gated by `x-aether-api-key` + `x-account-id`. The passcode primarily controls **what the frontend shows** and some API-level permission gates for trusted routes.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Solution: API-Verified Passcode + JWT Session
|
||||
|
||||
### Core idea
|
||||
|
||||
1. **Never send passcodes to the client.** The frontend stops reading/storing `access_code_kv_json` from the bootstrap response.
|
||||
2. **Passcode entry triggers an API call** to `/authenticate_passcode`. API verifies server-side against the DB.
|
||||
3. **On success, the API returns a JWT** — the JWT contains the role, account context, and expiry.
|
||||
4. **Store the JWT in `$ae_loc.jwt`** (already a field, already wired into `$ae_api`).
|
||||
5. **On page reload**, check the JWT's `eat` (expires-at) claim locally (base64 decode, no signature verification needed client-side). If expired, drop to anonymous. If valid, `access_type` is already persisted in `$ae_loc`.
|
||||
|
||||
### Session restore on reload
|
||||
|
||||
- `access_type` still persists in localStorage (no change here)
|
||||
- The JWT is the **proof** that the access was legitimately granted and is still valid
|
||||
- On page load: decode JWT payload (base64 the middle segment), check `eat` vs `Date.now()/1000`
|
||||
- If JWT expired → reset `access_type` to anonymous, clear JWT
|
||||
- If JWT valid → no action needed, `access_type` is already correct
|
||||
|
||||
This gives session expiry without a network call on every page load.
|
||||
|
||||
---
|
||||
|
||||
## TTL Per Role — Decided
|
||||
|
||||
| Access Level | JWT TTL | Notes |
|
||||
|---|---|---|
|
||||
| `super` | 8 hours | Highest privilege |
|
||||
| `manager` | 24 hours | |
|
||||
| `administrator` | 48 hours | |
|
||||
| `trusted` | 48 hours | Onsite staff — covers multi-day events |
|
||||
| `public` | 24 hours | |
|
||||
| `authenticated` | 12 hours | |
|
||||
| `anonymous` | N/A | No passcode |
|
||||
|
||||
---
|
||||
|
||||
## Caching Decision
|
||||
|
||||
**No passcode caching.** Every passcode entry makes one API call. The JWT handles session persistence — no passcode ever touches localStorage. Performance impact is only at the moment of entry (~50–150ms), which is acceptable for a once-per-session action.
|
||||
|
||||
---
|
||||
|
||||
## Backend Changes Required
|
||||
|
||||
**Note:** The backend fixes described below have been implemented and tested in the `aether_api_fastapi` repository (the `/authenticate_passcode` endpoint now uses explicit role priority, returns a full passcode JWT with `auth_type: 'passcode'`, applies per-role TTLs, and validates passcode length). Frontend changes can proceed once the backend deployment with these fixes is available.
|
||||
|
||||
**Phase 2 status:** Not started — removing `access_code_kv_json` from the public site model remains pending.
|
||||
|
||||
**File:** `aether_api_fastapi/app/routers/api.py`
|
||||
|
||||
The `/authenticate_passcode` endpoint exists and is structurally correct but has four issues that must be fixed before the frontend migrates to using it.
|
||||
|
||||
### Fix 1: Passcode matching must use explicit priority order
|
||||
|
||||
**Current (wrong):**
|
||||
```python
|
||||
for role, code in access_codes.items(): # dict insertion order — not guaranteed
|
||||
if str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
matched_role = None
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
```
|
||||
|
||||
This ensures that if a config mistake causes two roles to share a passcode, the higher-privilege role always wins. It also makes the intent explicit and independent of JSON storage order.
|
||||
|
||||
### Fix 2: JWT payload must include all six role flags
|
||||
|
||||
**Current (incomplete):**
|
||||
```python
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'super': (matched_role == 'super'),
|
||||
# trusted / public / authenticated missing
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode', # distinguishes from user login JWTs
|
||||
'site_id': site_id,
|
||||
'role': matched_role # canonical role string — frontend uses this
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The `auth_type: 'passcode'` marker is critical — it allows the frontend and any future backend consumers to distinguish a passcode JWT from a user login JWT.
|
||||
|
||||
### Fix 3: Per-role TTL
|
||||
|
||||
**Current:**
|
||||
```python
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=3600 * 24, # hardcoded 24h for all roles
|
||||
**payload
|
||||
)
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600, # 8 hours
|
||||
'manager': 24 * 3600, # 24 hours
|
||||
'administrator': 48 * 3600, # 48 hours
|
||||
'trusted': 48 * 3600, # 48 hours
|
||||
'public': 24 * 3600, # 24 hours
|
||||
'authenticated': 12 * 3600, # 12 hours
|
||||
}
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**payload
|
||||
)
|
||||
```
|
||||
|
||||
### Fix 4: Add minimum length validation to `passcode` field
|
||||
|
||||
**Current:**
|
||||
```python
|
||||
passcode: str = Field(..., description="The passcode to verify")
|
||||
```
|
||||
|
||||
**Required:**
|
||||
```python
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
```
|
||||
|
||||
This matches the frontend's 5-character trigger and prevents empty/trivial submissions.
|
||||
|
||||
### Complete corrected endpoint (for reference)
|
||||
|
||||
```python
|
||||
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
|
||||
|
||||
ROLE_TTL = {
|
||||
'super': 8 * 3600,
|
||||
'manager': 24 * 3600,
|
||||
'administrator': 48 * 3600,
|
||||
'trusted': 48 * 3600,
|
||||
'public': 24 * 3600,
|
||||
'authenticated': 12 * 3600,
|
||||
}
|
||||
|
||||
class PasscodeAuthRequest(BaseModel):
|
||||
"""Request model for site-based passcode authentication."""
|
||||
site_id: str = Field(..., description="Random string ID of the site")
|
||||
passcode: str = Field(..., min_length=5, description="The passcode to verify")
|
||||
|
||||
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
|
||||
async def authenticate_passcode(
|
||||
auth_req: PasscodeAuthRequest,
|
||||
response: Response = Response,
|
||||
):
|
||||
"""
|
||||
Passcode-to-JWT Endpoint.
|
||||
Verifies a passcode against site.access_code_kv_json (single source of truth —
|
||||
v_site_domain joins from the same site record).
|
||||
Returns a signed JWT with the site's account context, full role flags, and
|
||||
a per-role TTL. The jwt.json_str.auth_type='passcode' field distinguishes
|
||||
this token from a user login JWT.
|
||||
"""
|
||||
site_id = auth_req.site_id
|
||||
passcode = auth_req.passcode
|
||||
|
||||
# 1. Look up the site record
|
||||
search_data = {'id_random': site_id}
|
||||
if record := sql_select(table_name='site', data=search_data):
|
||||
# 2. Parse access codes
|
||||
access_codes_raw = record.get('access_code_kv_json')
|
||||
access_codes = {}
|
||||
if access_codes_raw:
|
||||
try:
|
||||
access_codes = json.loads(access_codes_raw) if isinstance(access_codes_raw, str) else access_codes_raw
|
||||
except Exception as e:
|
||||
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
|
||||
|
||||
# 3. Verify passcode in explicit priority order (highest privilege wins)
|
||||
matched_role = None
|
||||
for role in ROLE_PRIORITY:
|
||||
code = access_codes.get(role)
|
||||
if code and str(code) == str(passcode):
|
||||
matched_role = role
|
||||
break
|
||||
|
||||
if matched_role:
|
||||
log.info(f"Auth Success: Verified '{matched_role}' passcode for site {site_id}")
|
||||
|
||||
# 4. Resolve account context
|
||||
account_id_random = record.get('account_id_random')
|
||||
if not account_id_random:
|
||||
if account_id_int := record.get('account_id'):
|
||||
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
|
||||
|
||||
# 5. Mint JWT with complete role flags and per-role TTL
|
||||
payload = {
|
||||
'account_id': account_id_random,
|
||||
'super': (matched_role == 'super'),
|
||||
'manager': (matched_role == 'manager'),
|
||||
'administrator': (matched_role == 'administrator'),
|
||||
'trusted': (matched_role == 'trusted'),
|
||||
'public': (matched_role == 'public'),
|
||||
'authenticated': (matched_role == 'authenticated'),
|
||||
'json_str': json.dumps({
|
||||
'auth_type': 'passcode',
|
||||
'site_id': site_id,
|
||||
'role': matched_role
|
||||
})
|
||||
}
|
||||
|
||||
token = sign_jwt(
|
||||
secret_key=settings.JWT_KEY,
|
||||
ttl=ROLE_TTL[matched_role],
|
||||
**payload
|
||||
)
|
||||
|
||||
return mk_resp(
|
||||
data={'jwt': token, 'account_id': account_id_random, 'role': matched_role},
|
||||
response=response
|
||||
)
|
||||
else:
|
||||
log.warning(f"Auth Failed: Invalid passcode for site {site_id}")
|
||||
return mk_resp(data=False, status_code=401, response=response, status_message="Invalid passcode.")
|
||||
else:
|
||||
log.warning(f"Auth Failed: Site {site_id} not found.")
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Site not found.")
|
||||
```
|
||||
|
||||
### Backend Phase 2 (follow-up — not blocking frontend)
|
||||
|
||||
**Remove `access_code_kv_json` from the `Site_Domain_Base` response model** (`site_domain_models.py`). This ensures passcodes are never sent to the client even if future code reads from the bootstrap. Requires confirming no other endpoint consumers rely on `access_code_kv_json` being in the base response before making this change.
|
||||
|
||||
---
|
||||
|
||||
## Frontend Changes Required
|
||||
|
||||
**These depend on the backend fixes above being deployed first.**
|
||||
|
||||
### 1a. `src/lib/app_components/e_app_access_type.svelte`
|
||||
|
||||
Replace `handle_check_access_type_passcode` entirely. The new version:
|
||||
|
||||
- Is `async`
|
||||
- Adds `auth_pending: boolean = $state(false)` and `auth_error: string | null = $state(null)`
|
||||
- Uses a direct `fetch` call (NOT `post_object` — avoids triggering the session-expired banner on a 401)
|
||||
- On success: sets `$ae_loc.access_type = data.role`, stores `$ae_loc.jwt = data.jwt`, triggers `process_permission_check` as before
|
||||
- On 401: shows inline error, clears `entered_passcode`, resets `checked_passcode = null` to allow retry
|
||||
- On network error: shows inline connection error
|
||||
- Clears `auth_error` when `entered_passcode` changes
|
||||
|
||||
API call shape:
|
||||
```http
|
||||
POST /authenticate_passcode
|
||||
Content-Type: application/json
|
||||
x-aether-api-key: <from $ae_api.headers['x-aether-api-key']>
|
||||
Body: { site_id: $ae_loc.site_id, passcode: entered_passcode }
|
||||
```
|
||||
|
||||
Add to template (near the passcode input):
|
||||
```svelte
|
||||
{#if auth_pending}
|
||||
<Loader size="1em" class="animate-spin text-gray-400" />
|
||||
{/if}
|
||||
{#if auth_error}
|
||||
<span class="text-error-500 text-xs">{auth_error}</span>
|
||||
{/if}
|
||||
```
|
||||
|
||||
### 1b. `src/routes/+layout.ts`
|
||||
|
||||
**Stop caching passcodes from bootstrap** — remove line ~394:
|
||||
```ts
|
||||
// ae_loc_init['site_access_code_kv'] = json_data.access_code_kv_json || {};
|
||||
```
|
||||
|
||||
**Add passcode JWT expiry check** — after the block around line 84 where `ae_loc_json.jwt` is read, add:
|
||||
```ts
|
||||
// Enforce passcode JWT TTL on page load.
|
||||
// Decodes the JWT payload (base64, no secret needed) and resets access to anonymous if expired.
|
||||
// User login JWTs (auth_type !== 'passcode') are left untouched.
|
||||
if (ae_loc_json?.jwt) {
|
||||
try {
|
||||
const parts = ae_loc_json.jwt.split('.');
|
||||
if (parts.length === 3) {
|
||||
const jwt_payload = JSON.parse(atob(parts[1]));
|
||||
const json_str = typeof jwt_payload.json_str === 'string'
|
||||
? JSON.parse(jwt_payload.json_str)
|
||||
: jwt_payload.json_str;
|
||||
if (json_str?.auth_type === 'passcode' && jwt_payload.eat < Date.now() / 1000) {
|
||||
// Passcode JWT has expired — revoke access
|
||||
ae_loc_json.jwt = null;
|
||||
ae_loc_json.access_type = 'anonymous';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed JWT — leave untouched, let existing handling deal with it
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1c. `src/lib/stores/ae_stores__auth_loc_defaults.ts` (cleanup)
|
||||
|
||||
Remove `site_access_code_kv` from the `AuthLocState` interface and the `auth_loc_defaults` object. The field is unused after 1a. Confirm no other component reads from it first (current grep: only `e_app_access_type.svelte` uses it — confirmed).
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- Users with existing localStorage will still have `site_access_code_kv` cached — this is harmless after the frontend stops reading it. No forced cache clear needed.
|
||||
- Existing persisted `access_type` is unaffected — users keep their current session level until their JWT expires or they manually clear storage.
|
||||
- The `$ae_loc.jwt` field is already used by the user login flow. The `auth_type: 'passcode'` marker in `json_str` ensures the expiry logic only targets passcode sessions, not user login sessions.
|
||||
|
||||
---
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Repo | Change |
|
||||
| --- | --- | --- |
|
||||
| `app/routers/api.py` | `aether_api_fastapi` | **Backend — do first.** Priority ordering, full JWT payload, per-role TTL, min_length on passcode |
|
||||
| `app/models/site_domain_models.py` | `aether_api_fastapi` | Phase 2: remove `access_code_kv_json` from public model |
|
||||
| `src/lib/app_components/e_app_access_type.svelte` | `aether_app_sveltekit` | Replace local check with async API call; loading/error UI |
|
||||
| `src/routes/+layout.ts` | `aether_app_sveltekit` | Stop caching passcodes; add JWT expiry check |
|
||||
| `src/lib/stores/ae_stores__auth_loc_defaults.ts` | `aether_app_sveltekit` | Cleanup: remove `site_access_code_kv` |
|
||||
| `documentation/AE__Permissions_and_Security.md` | `aether_app_sveltekit` | Update passcode auth section to reflect new flow |
|
||||
@@ -12,6 +12,16 @@
|
||||
- [x] **Config Refactor:** Switch `app/config.py` to `pydantic-settings` to use direct Env Vars (Stop mounting config files).
|
||||
- [x] **Locking:** Generate a `requirements.lock` for bit-identical builds.
|
||||
|
||||
## 🔌 DB Connection Hardening (April 2026 Audit)
|
||||
> Identified during pre-show review. Issues 1 and 2 likely explain observed random connection lags.
|
||||
|
||||
- [x] **[P1] Remove zombie `db_connection.py` import** — `app/routers/api.py` imports `db` from `app/db_connection.py`, creating a parasitic second SQLAlchemy engine at startup that is never updated by `reconnect_db()` after bootstrap. The imported `db` is only used in a commented-out line (`api.py:268`). Fix: remove the import; delete or archive `db_connection.py`.
|
||||
- [x] **[P1] Fix retry mechanism in `sql_update` / `run_sql_select`** — On `OperationalError`, both call `sql_connect()` → `reconnect_db()` which calls `engine.dispose()`, nuking the entire connection pool mid-flight. Under concurrent requests this kills other in-flight connections. Fix: remove the `sql_connect()` retry call; SQLAlchemy's `pool_pre_ping=True` already handles stale connections — just open a fresh `engine.connect()` for the retry without disposing the pool.
|
||||
- [x] **[P2] Add retry logic to `sql_insert` and `sql_select`** — Added `OperationalError` retry (single fresh connection attempt) to `sql_insert`, `sql_select`, and `sql_insert_or_update`. `IntegrityError` (duplicate key, FK violation) correctly bypasses retry and returns `None` — retrying the same data would fail again.
|
||||
- [x] **[P3] Guard `db = engine.connect()` in `lib_sql_core.py` with try/except** — Wrapped in try/except; sets `db = None` on failure so Docker startup race no longer crashes the worker.
|
||||
- [ ] **[P3 full]** Migrate `lib_schema_v3.py:39` and `lib_api_crud_v3.py:166` off the global `db` to `engine.connect()` context managers, then remove the global `db` entirely.
|
||||
- [x] **[P4] Expose `pool_size` / `max_overflow` as env vars** — `create_ae_engine()` calls `settings.DB.get('pool_size', 10)` but `settings.DB` property doesn't include those keys, so they're always hardcoded 10/20. Add `AE_DB_POOL_SIZE` / `AE_DB_POOL_MAX_OVERFLOW` to `config.py`.
|
||||
|
||||
## 📋 Feature Tasks
|
||||
- [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed.
|
||||
- [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects.
|
||||
@@ -24,10 +34,48 @@
|
||||
- [x] Whitelist `account_id` in all Event search definitions.
|
||||
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
|
||||
- [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete.
|
||||
- [x] **Restore alt-view convenience fields lost in v1→v3 migration (May 2026):** `site_domain` (`account_name`, `account_code`, `account_enable`, `account_enable_from/to`, `site_enable_from/to`, `site_domain_access_key`, `logo_path`, `style_href`, `script_src`, `google_tracking_id`) and `event_session` (`event_presentation_li_qry_str`, `event_presenter_li_qry_str`). Fields added to Pydantic models and `searchable_fields`. Alt-view fields require `?view=alt` for search.
|
||||
- [ ] Verify SQL Views join in all required `_random` IDs for performance.
|
||||
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
|
||||
- [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`.
|
||||
|
||||
## 🔌 IDAA: Server-Side Novi Verification (Mini Project)
|
||||
> **Status: P1–P4 Complete (May 2026).** Endpoint live at `GET /v3/action/idaa/novi_member/{uuid}`. P5 (frontend migration) is the remaining step.
|
||||
> Rationale and frontend integration notes: `aether_app_sveltekit/documentation/CLIENT__IDAA_and_customized_mods.md` → "Planned: Server-Side Novi Verification"
|
||||
|
||||
**Goal:** Proxy the Novi member-verification call server-to-server (FastAPI → Novi) so members' browser IPs are no longer in the call path.
|
||||
|
||||
- [x] **[P1] New router:** `app/routers/api_v3_actions_idaa.py`
|
||||
- Route: `GET /v3/action/idaa/novi_member/{uuid}`
|
||||
- Required auth: `Depends(get_account_context)` — valid API key + any account context (x-account-id, JWT, or bypass). This is the standard V3 gate.
|
||||
- Reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json` via `_load_idaa_cfg()` (same as Mailman bridge)
|
||||
- Calls Novi: `GET {novi_api_root_url}/customers/{uuid}` with `Authorization: Basic {api_key}`
|
||||
- Normalize email: `.replace(' ', '+')` (Novi quirk — see Novi-Mailman bridge notes)
|
||||
- Build display name: `"{FirstName} {LastName[0]}."` format, fall back to `Name` field
|
||||
- Returns `{ "verified": true, "full_name": "...", "email": "..." }` on success
|
||||
- Returns `404` if Novi 200 with no identity data (empty-member anti-pattern)
|
||||
- Returns `429` if Novi rate limits; `503` if Novi unreachable or 5xx
|
||||
- Business logic in `app/methods/idaa_novi_verify_methods.py`
|
||||
|
||||
- [x] **[P2] Redis cache:**
|
||||
- Key: `idaa:novi_member:{uuid}` — TTL 4 hours
|
||||
- Note: `account_id` dropped from key — Novi credentials are hardcoded to the IDAA site; same UUID always returns the same data regardless of caller, so per-caller scoping wastes Redis space and halves hit rate.
|
||||
- Cache only verified (200) results — do NOT cache 404 (member may have just joined)
|
||||
- Uses `redis_client` from `lib_redis_helpers.py` directly
|
||||
|
||||
- [x] **[P3] Register in registry:** Added to `routers/registry.py` at `/v3/action/idaa` tag `IDAA Actions (V3)`. Confirmed live — endpoint appears in `/openapi.json`.
|
||||
|
||||
- [x] **[P4] Tests:** `tests/unit/test_unit_idaa_novi_verify.py` — 9 tests, all passing.
|
||||
- Mock Novi responses (200/empty-200/404/429/503/unreachable)
|
||||
- Verify Redis cache is set on 200, hit bypasses Novi call
|
||||
- Verify email normalization (space → +)
|
||||
- Verify display name format (5 cases)
|
||||
|
||||
- [x] **[P5] Coordinate with Frontend Agent** — **Complete (2026-05-19)**
|
||||
- Frontend replaced direct `fetch()` to Novi in `+layout.svelte:verify_novi_uuid()`
|
||||
- Response codes mapped: 200 → verified, 404 → denied, 429 → `'rate_limited'`, 503 → auto-retried (3s, once) then `'api_error'`
|
||||
- 503 auto-retry added same session to match network-error retry path
|
||||
|
||||
## 🛡️ Security & Privacy Baseline (IDAA)
|
||||
- **Status:** **ENFORCED**.
|
||||
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.
|
||||
@@ -51,6 +99,7 @@
|
||||
- **Webhook approach abandoned** — cron is simpler; Novi webhook payload format is unknown and Novi hasn't been configured to send webhooks.
|
||||
- **Remaining:** Set production group→list mappings in `cfg_json`, configure cron schedule, rotate Mailman `restadmin` password.
|
||||
- [ ] **Lookup System Batch 2:** Migration of `post_topic`, `user_status`, `file_purpose`.
|
||||
- [ ] **Post Topic Storage Review:** Revisit whether IDAA BB posts should keep the lookup-based `topic_id`/`topic_name` shape or eventually flatten to saved topic text if the list stays fixed.
|
||||
- [ ] **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval.
|
||||
|
||||
## 📝 Session Notes (March 11, 2026)
|
||||
|
||||
@@ -7,7 +7,7 @@ This directory contains the automated and manual test scripts for the Aether Fas
|
||||
- **`unit/`**: Isolated logic tests. These use heavy mocking to bypass database and network requirements. Fast and safe to run in any environment.
|
||||
- **`integration/`**: Local environment tests. These verify component interactions, often requiring a connection to the local MariaDB/Redis instance.
|
||||
- **`e2e/` (End-to-End)**: Network-based API tests. these use the `requests` library to call the live API endpoints at `https://dev-api.oneskyit.com`.
|
||||
- **`tools/`**: Utility scripts for administrative tasks like registry generation or Docker exploration.
|
||||
- **`tools/`**: Utility scripts for administrative tasks like registry generation, Docker exploration, and performance stress testing.
|
||||
- **`archive/`**: Legacy or deprecated scripts kept for historical reference.
|
||||
|
||||
## 📜 Standardized E2E Suite (`tests/e2e/`)
|
||||
@@ -19,6 +19,8 @@ These consolidated scripts are the primary verification tool for the V3 API.
|
||||
| `test_e2e_v3_search_engine.py` | **Primary Search**: Basic operators, Registry fields, Nested search, and Filter bypass. |
|
||||
| `test_e2e_v3_security_audit.py` | **Core Security**: Verifies multi-tenant isolation, cross-account write blocking, and ID Vision compliance. |
|
||||
| `test_e2e_v3_auth_security.py` | **Primary Auth**: Site bootstrap, Passcode-to-JWT, and permission boundaries. |
|
||||
| `test_e2e_v3_user_action_routes.py` | **V3 User Actions**: Sign-in (username+password and auth-key flow), verify password, change password, new auth key, email magic link, and auth guards. |
|
||||
| `test_e2e_v3_user_auth_routes.py` | **Legacy User Routes**: Tests the pre-V3 `/user/*` endpoints (change_password, new_auth_key, verify_password, lookup, email_auth_key_url, authenticate). |
|
||||
| `test_e2e_v3_actions_file_lifecycle.py` | **Primary Actions**: Upload, Download (ID/Hash/Streaming), and physical Deletion. |
|
||||
| `test_e2e_v3_data_store_lookup.py` | **V3 Parity**: Verifies code-based lookups and latency simulation. |
|
||||
| `test_e2e_redis_extensive.py` | **Redis Stress**: Benchmarks bidirectional ID caching across thousands of records. |
|
||||
@@ -31,6 +33,7 @@ These consolidated scripts are the primary verification tool for the V3 API.
|
||||
| `test_e2e_v3_action_novi_mailman.py` | **Novi-Mailman Bridge — Connections**: Verifies Novi AMS and Mailman 3 API credentials are valid (IDAA). Run first before the lists test. |
|
||||
| `test_e2e_v3_action_novi_mailman_lists.py` | **Novi-Mailman Bridge — List Operations**: Full member lifecycle — read roster, subscribe, verify, unsubscribe — against `mm3@idaa.org`, `mm3@dgrzone.com`, `mm3@oneskyit.com`. |
|
||||
| `test_e2e_v3_action_event_exhibit_tracking_export.py` | **Exhibit Leads Export**: Auth/permission guards, CSV column structure, XLSX bytes, and `return_file` mode for the V3 tracking export action. |
|
||||
| `test_e2e_v3_action_idaa_novi_verify.py` | **IDAA Novi Member Verify**: Auth guard, 200 verified, 404 not-found, 429 rate-limit, 503 unreachable, Redis cache hit, email normalization. (not yet written — add when endpoint is stable) |
|
||||
| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. |
|
||||
| `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. |
|
||||
| `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. |
|
||||
@@ -38,6 +41,28 @@ These consolidated scripts are the primary verification tool for the V3 API.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tools (`tests/tools/`)
|
||||
|
||||
| Script | Description |
|
||||
| :--- | :--- |
|
||||
| `stress_list_queries.py` | **Read-only concurrency stress test.** Fires N worker threads making R sequential requests across all V3 list endpoints. Reports per-endpoint p50/p95/max latency and error counts. CLI: `--workers` (default 10), `--requests` (default 5), `--limit` (default 20), `--base-url` (default dev API). Exit code 1 on any error. |
|
||||
| `tool_generate_registry.py` | Generates the object type registry from source definitions. |
|
||||
| `tool_mcp_docker_explorer.py` | Explores running Docker containers via the MCP bridge. |
|
||||
|
||||
**Stress test quick reference:**
|
||||
```bash
|
||||
# Baseline (10 workers, 5 rounds, 400 total requests)
|
||||
./environment/bin/python3 tests/tools/stress_list_queries.py
|
||||
|
||||
# Heavy load (35 workers, 5 rounds, 1400 total requests)
|
||||
./environment/bin/python3 tests/tools/stress_list_queries.py --workers 35 --requests 5
|
||||
|
||||
# Target a different environment
|
||||
./environment/bin/python3 tests/tools/stress_list_queries.py --base-url https://api.oneskyit.com --workers 5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Shared Helpers
|
||||
|
||||
- **`mock_config_helper.py`**: A critical utility that mocks `app.config.settings` before other modules are imported. Use this in unit tests.
|
||||
@@ -54,8 +79,10 @@ Tests exist to be used — run the relevant suite whenever you touch backend cod
|
||||
| Nested router (`api_crud_v3_nested.py`) changes | `test_e2e_v3_demo_parity.py` |
|
||||
| Search / filter changes | `test_e2e_v3_search_engine.py` |
|
||||
| Auth / account context changes | `test_e2e_v3_security_audit.py`, `test_e2e_v3_auth_security.py` |
|
||||
| User action route changes (sign-in, password, magic link) | `test_e2e_v3_user_action_routes.py` |
|
||||
| File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` |
|
||||
| Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` |
|
||||
| IDAA Novi member verify changes | `tests/unit/test_unit_idaa_novi_verify.py`, `test_e2e_v3_action_idaa_novi_verify.py` (e2e pending) |
|
||||
| Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.py` |
|
||||
| Any backend change before frontend hand-off | All of the above |
|
||||
|
||||
@@ -86,6 +113,16 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
|
||||
```
|
||||
|
||||
### Running unit tests with pytest
|
||||
```bash
|
||||
./environment/bin/python3 -m pytest tests/unit/ -v
|
||||
```
|
||||
|
||||
`pytest` and `pytest-asyncio` are dev-only dependencies (not in `requirements.txt`). After rebuilding the venv (e.g. following an OS Python update), reinstall them:
|
||||
```bash
|
||||
./environment/bin/pip install pytest pytest-asyncio
|
||||
```
|
||||
|
||||
### Path Requirements
|
||||
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Set up project root for imports
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
# 1. Initialize Mock Config Helper BEFORE other imports
|
||||
import tests.mock_config_helper
|
||||
from app.config import settings
|
||||
|
||||
# Now set some REAL values for DB connection so it actually works
|
||||
import os
|
||||
settings.DB_SERVER = "vpn-db.oneskyit.com"
|
||||
settings.DB_USER = "aether_dev"
|
||||
settings.DB_PASS = "$1sky.AE_dev.2023"
|
||||
settings.DB_NAME = "aether_dev"
|
||||
settings.DB_PORT = 3306
|
||||
settings.REDIS = {"server": "127.0.0.1", "port": 6379}
|
||||
settings.FILES_PATH = {"hosted_files_root": "/home/scott/tmp/gemini_trash"} # Dummy
|
||||
|
||||
from app.methods.event_file_methods import load_event_file_obj
|
||||
from app.db_sql import get_id_random
|
||||
|
||||
print("--- Testing get_id_random directly ---")
|
||||
print(f"event ID 1 -> {get_id_random(1, 'event')}")
|
||||
print(f"session ID 543 -> {get_id_random(543, 'event_session')}")
|
||||
print(f"presenter ID 1629 -> {get_id_random(1629, 'event_presenter')}")
|
||||
|
||||
print("\n--- Testing load_event_file_obj for a2pPIT_W28o ---")
|
||||
res = load_event_file_obj('a2pPIT_W28o', model_as_dict=True)
|
||||
if res:
|
||||
import json
|
||||
print(json.dumps(res, indent=4))
|
||||
else:
|
||||
print("Failed to load object.")
|
||||
185
tests/e2e/test_e2e_jitsi_token.py
Normal file
185
tests/e2e/test_e2e_jitsi_token.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Jitsi JWT Token E2E Test Suite
|
||||
|
||||
Tests the /api/jitsi_token endpoint to verify:
|
||||
- Moderator tokens contain moderator=true in the JWT payload
|
||||
- Attendee tokens contain moderator=false in the JWT payload
|
||||
- Room claim is correctly scoped per request
|
||||
- Basic validation rejects malformed input
|
||||
|
||||
Run from project root:
|
||||
./environment/bin/python3 tests/e2e/test_e2e_jitsi_token.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
import requests
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
# --- Configuration ---
|
||||
API_ROOT = "https://dev-api.oneskyit.com"
|
||||
JITSI_ENDPOINT = f"{API_ROOT}/api/jitsi_token"
|
||||
|
||||
TEST_ROOM = "idaa-test-room-001"
|
||||
TEST_NAME = "E2E Test User"
|
||||
TEST_EMAIL = "e2e-test@oneskyit.com"
|
||||
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
suffix = f" — {message}" if message else ""
|
||||
print(f" [{status}] {label}{suffix}")
|
||||
|
||||
|
||||
def decode_jwt_payload(token: str) -> dict:
|
||||
"""Decode a JWT payload without signature verification (for inspection)."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return {}
|
||||
# Add padding
|
||||
padded = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||
return json.loads(base64.urlsafe_b64decode(padded))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def test_moderator_token():
|
||||
"""Request a moderator JWT and verify the claim is set correctly."""
|
||||
print("\n--- Test: Moderator Token ---")
|
||||
payload = {
|
||||
"room": TEST_ROOM,
|
||||
"name": TEST_NAME,
|
||||
"email": TEST_EMAIL,
|
||||
"is_moderator": True,
|
||||
}
|
||||
resp = requests.post(JITSI_ENDPOINT, json=payload)
|
||||
print_result("HTTP 200", resp.status_code == 200, f"status={resp.status_code}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f" Response: {resp.text}")
|
||||
return None
|
||||
|
||||
token = resp.json().get("token")
|
||||
print_result("Token returned", bool(token))
|
||||
if not token:
|
||||
return None
|
||||
|
||||
decoded = decode_jwt_payload(token)
|
||||
print(f" Decoded payload: {json.dumps(decoded, indent=6)}")
|
||||
|
||||
moderator_claim = decoded.get("context", {}).get("user", {}).get("moderator")
|
||||
room_claim = decoded.get("room")
|
||||
|
||||
print_result("moderator == True", moderator_claim is True, f"got: {moderator_claim!r}")
|
||||
print_result("room scoped correctly", room_claim == TEST_ROOM, f"got: {room_claim!r}")
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def test_attendee_token():
|
||||
"""Request a non-moderator JWT and verify the claim is False."""
|
||||
print("\n--- Test: Attendee Token (is_moderator=False) ---")
|
||||
payload = {
|
||||
"room": TEST_ROOM,
|
||||
"name": TEST_NAME,
|
||||
"email": TEST_EMAIL,
|
||||
"is_moderator": False,
|
||||
}
|
||||
resp = requests.post(JITSI_ENDPOINT, json=payload)
|
||||
print_result("HTTP 200", resp.status_code == 200, f"status={resp.status_code}")
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f" Response: {resp.text}")
|
||||
return None
|
||||
|
||||
token = resp.json().get("token")
|
||||
print_result("Token returned", bool(token))
|
||||
if not token:
|
||||
return None
|
||||
|
||||
decoded = decode_jwt_payload(token)
|
||||
print(f" Decoded payload: {json.dumps(decoded, indent=6)}")
|
||||
|
||||
moderator_claim = decoded.get("context", {}).get("user", {}).get("moderator")
|
||||
print_result("moderator == False", moderator_claim is False, f"got: {moderator_claim!r}")
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def test_room_isolation():
|
||||
"""Verify two requests for different rooms produce different room claims."""
|
||||
print("\n--- Test: Room Isolation ---")
|
||||
rooms = ["room-alpha", "room-beta"]
|
||||
tokens = []
|
||||
for room in rooms:
|
||||
resp = requests.post(JITSI_ENDPOINT, json={
|
||||
"room": room, "name": TEST_NAME, "email": TEST_EMAIL, "is_moderator": False
|
||||
})
|
||||
if resp.status_code == 200:
|
||||
tokens.append((room, decode_jwt_payload(resp.json().get("token", ""))))
|
||||
|
||||
if len(tokens) == 2:
|
||||
match_0 = tokens[0][1].get("room") == tokens[0][0]
|
||||
match_1 = tokens[1][1].get("room") == tokens[1][0]
|
||||
print_result("room-alpha scoped", match_0, f"got: {tokens[0][1].get('room')!r}")
|
||||
print_result("room-beta scoped", match_1, f"got: {tokens[1][1].get('room')!r}")
|
||||
print_result("Rooms differ", tokens[0][1].get("room") != tokens[1][1].get("room"))
|
||||
else:
|
||||
print_result("Both requests succeeded", False, "could not get both tokens")
|
||||
|
||||
|
||||
def test_invalid_email():
|
||||
"""Verify that a malformed email is rejected with 422."""
|
||||
print("\n--- Test: Input Validation (bad email) ---")
|
||||
payload = {
|
||||
"room": TEST_ROOM,
|
||||
"name": TEST_NAME,
|
||||
"email": "not-an-email",
|
||||
"is_moderator": False,
|
||||
}
|
||||
resp = requests.post(JITSI_ENDPOINT, json=payload)
|
||||
print_result("422 on bad email", resp.status_code == 422, f"status={resp.status_code}")
|
||||
|
||||
|
||||
def test_token_expiry():
|
||||
"""Verify the exp claim is approximately 1 hour from now."""
|
||||
print("\n--- Test: Token Expiry (exp claim) ---")
|
||||
payload = {
|
||||
"room": TEST_ROOM, "name": TEST_NAME, "email": TEST_EMAIL, "is_moderator": False
|
||||
}
|
||||
resp = requests.post(JITSI_ENDPOINT, json=payload)
|
||||
if resp.status_code != 200:
|
||||
print_result("HTTP 200 (skipping exp check)", False)
|
||||
return
|
||||
|
||||
decoded = decode_jwt_payload(resp.json().get("token", ""))
|
||||
exp = decoded.get("exp")
|
||||
now = int(time.time())
|
||||
ttl = exp - now if exp else 0
|
||||
# Should be ~3600s (allow 30s window for test runtime)
|
||||
ok = 3550 < ttl <= 3600
|
||||
print_result("exp ≈ now + 3600s", ok, f"ttl={ttl}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite_start = time.time()
|
||||
print("=" * 55)
|
||||
print(" Jitsi JWT Token — E2E Test Suite")
|
||||
print(f" Endpoint: {JITSI_ENDPOINT}")
|
||||
print("=" * 55)
|
||||
|
||||
test_moderator_token()
|
||||
test_attendee_token()
|
||||
test_room_isolation()
|
||||
test_invalid_email()
|
||||
test_token_expiry()
|
||||
|
||||
elapsed = time.time() - suite_start
|
||||
print(f"\n{'=' * 55}")
|
||||
print(f" Suite completed in {elapsed:.2f}s")
|
||||
print("=" * 55)
|
||||
@@ -10,6 +10,10 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
||||
# journal account: nqOzejLCDXM | event account: GpLf_bnywCs
|
||||
JOURNAL_PARENT_ID = "OGQK-02-04-94"
|
||||
EVENT_PARENT_ID = "vfzVJF0LH1O"
|
||||
# event_person: ffkKxiHpOEC (16603) "Scott Idem" under Demo event
|
||||
EVENT_PERSON_PARENT_ID = "ffkKxiHpOEC"
|
||||
# event_badge_template: jgfixEpYp1B (18) "Dev Demo 202x"
|
||||
EVENT_BADGE_TEMPLATE_ID = "jgfixEpYp1B"
|
||||
|
||||
# Test Targets: (Object Type, Valid ID Random)
|
||||
# Note: These IDs are extracted from real active records.
|
||||
@@ -127,6 +131,75 @@ def test_nested_create_lifecycle(parent_type, parent_id, child_type, payload):
|
||||
return True
|
||||
|
||||
|
||||
def test_nested_create_secondary_fk(parent_type, parent_id, child_type, payload, required_fk_fields):
|
||||
"""
|
||||
Regression test for secondary FK resolution in nested POST create.
|
||||
|
||||
Bug: sanitize_payload ran BEFORE model instantiation in the nested POST handler.
|
||||
For FKs other than the parent FK (e.g. event_badge_template_id on event_badge),
|
||||
sanitize_payload resolved the string → integer, then the model's root_validator
|
||||
stripped the integer back to None (Vision ID anti-leakage guard). The parent FK
|
||||
survived only because it was explicitly re-injected; secondary FKs were silently lost.
|
||||
|
||||
Fix (api_crud_v3_nested.py): moved sanitize_payload to run on data_to_insert AFTER
|
||||
model serialization, matching the flat V3 POST pattern.
|
||||
|
||||
Verifies:
|
||||
1. POST returns 200.
|
||||
2. Each field in required_fk_fields is present AND non-None in the response.
|
||||
3. All *_id fields are strings (Vision Standard).
|
||||
4. Cleanup: DELETE the created record.
|
||||
"""
|
||||
label = f"Nested Secondary FK ({parent_type}/{child_type})"
|
||||
print(f"\n--- Regression: {label} ---")
|
||||
url = f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/"
|
||||
headers = get_headers()
|
||||
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
if resp.status_code != 200:
|
||||
print(f" ❌ [FAIL] POST returned {resp.status_code}: {resp.text[:300]}")
|
||||
return False
|
||||
|
||||
data = resp.json().get('data', {})
|
||||
new_id = data.get('id') or data.get('obj_id_random')
|
||||
if not new_id or not isinstance(new_id, str):
|
||||
print(f" ❌ [FAIL] No string 'id' in response. Got: {data}")
|
||||
return False
|
||||
print(f" ✅ [PASS] Created {child_type} with id: {new_id}")
|
||||
|
||||
# Check required secondary FK fields are present and non-None
|
||||
for field in required_fk_fields:
|
||||
val = data.get(field)
|
||||
if val is None:
|
||||
print(f" ❌ [FAIL] Secondary FK '{field}' is None — was not saved to DB.")
|
||||
# Still attempt cleanup
|
||||
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
return False
|
||||
if not isinstance(val, str):
|
||||
print(f" ❌ [FAIL] Secondary FK '{field}' is {type(val).__name__} ({val}) — must be string (Vision Standard).")
|
||||
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
return False
|
||||
print(f" ✅ [PASS] Secondary FK '{field}' = {val}")
|
||||
|
||||
# Vision compliance: all *_id fields must be strings
|
||||
for key, val in data.items():
|
||||
if (key == 'id' or key.endswith('_id')) and not key.endswith('external_id'):
|
||||
if val is not None and not isinstance(val, str):
|
||||
print(f" ❌ [FAIL] Vision violation: {key} is {type(val).__name__} ({val})")
|
||||
requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
return False
|
||||
print(f" ✅ [PASS] Vision Standard: all ID fields are strings.")
|
||||
|
||||
# Cleanup
|
||||
del_resp = requests.delete(f"{BASE_URL}/{parent_type}/{parent_id}/{child_type}/{new_id}", headers=headers)
|
||||
if del_resp.status_code == 200:
|
||||
print(f" ✅ [PASS] Cleanup: deleted {new_id}")
|
||||
else:
|
||||
print(f" ⚠️ [WARN] Cleanup failed ({del_resp.status_code}) — manual cleanup may be needed for {new_id}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def test_nested_alias_resolution():
|
||||
"""
|
||||
Verifies that the 'entry' alias and nested resolution works for journals.
|
||||
@@ -171,6 +244,21 @@ if __name__ == "__main__":
|
||||
child_type='event_session',
|
||||
payload={'name': '[e2e-test] nested create regression', 'enable': False},
|
||||
))
|
||||
# Secondary FK regression: event_badge_template_id must survive nested POST
|
||||
# (was silently dropped as NULL before the sanitize_payload order fix)
|
||||
results.append(test_nested_create_secondary_fk(
|
||||
parent_type='event_person',
|
||||
parent_id=EVENT_PERSON_PARENT_ID,
|
||||
child_type='event_badge',
|
||||
payload={
|
||||
'event_badge_template_id': EVENT_BADGE_TEMPLATE_ID,
|
||||
'given_name': '[e2e-test]',
|
||||
'family_name': 'secondary-fk-regression',
|
||||
'enable': False,
|
||||
'hide': True,
|
||||
},
|
||||
required_fk_fields=['event_badge_template_id'],
|
||||
))
|
||||
|
||||
elapsed = time.time() - suite_start
|
||||
if all(results):
|
||||
|
||||
@@ -64,17 +64,72 @@ def test_extra_filters():
|
||||
resp = requests.get(f"{API_BASE}/user/?enabled=all&hidden=all", headers=get_headers())
|
||||
print_result("Bypass Filters (enabled=all)", resp.status_code == 200)
|
||||
|
||||
def test_event_session_qry_str_fields():
|
||||
"""
|
||||
Regression test for event_presentation_li_qry_str and event_presenter_li_qry_str.
|
||||
These fields were lost during the v1/v2 -> v3 migration and restored May 2026.
|
||||
They live in v_event_session_w_file_count (triggered by ?inc_file_count=true).
|
||||
|
||||
Demo session: DOW3h7v6H42 "How To Do Things" under Demo event pjrcghqwert
|
||||
"""
|
||||
print("\n--- Testing event_session qry_str fields (regression: May 2026) ---")
|
||||
|
||||
EVENT_ID = "pjrcghqwert"
|
||||
SESSION_ID = "DOW3h7v6H42"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Aether-API-Key": API_KEY,
|
||||
"x-no-account-id": "bypass"
|
||||
}
|
||||
|
||||
# 1. Verify fields are returned in the GET response when inc_file_count=true
|
||||
url = f"{API_BASE}/event_session/{SESSION_ID}?inc_file_count=true"
|
||||
resp = requests.get(url, headers=headers)
|
||||
ok = resp.status_code == 200
|
||||
print_result("GET event_session with inc_file_count", ok, f"(status={resp.status_code})")
|
||||
if ok:
|
||||
data = resp.json().get("data", {})
|
||||
has_pres = "event_presentation_li_qry_str" in data
|
||||
has_presenter = "event_presenter_li_qry_str" in data
|
||||
print_result("Field present: event_presentation_li_qry_str", has_pres,
|
||||
f"(value={data.get('event_presentation_li_qry_str')!r})")
|
||||
print_result("Field present: event_presenter_li_qry_str", has_presenter,
|
||||
f"(value={data.get('event_presenter_li_qry_str')!r})")
|
||||
|
||||
# 2. Verify searching by event_presentation_li_qry_str via ?view=alt (v_event_session_w_file_count)
|
||||
# These fields only exist in the alt view, so ?view=alt is required.
|
||||
search_url = f"{API_BASE}/event/{EVENT_ID}/event_session/search?view=alt"
|
||||
query = {"and": [{"field": "event_presentation_li_qry_str", "op": "like", "value": "%"}]}
|
||||
resp = requests.post(search_url, headers=headers, json=query)
|
||||
print_result("Search by event_presentation_li_qry_str (?view=alt)", resp.status_code == 200,
|
||||
f"(status={resp.status_code})")
|
||||
|
||||
# 3. Verify searching by event_presenter_li_qry_str via ?view=alt
|
||||
query = {"and": [{"field": "event_presenter_li_qry_str", "op": "like", "value": "%"}]}
|
||||
resp = requests.post(search_url, headers=headers, json=query)
|
||||
print_result("Search by event_presenter_li_qry_str (?view=alt)", resp.status_code == 200,
|
||||
f"(status={resp.status_code})")
|
||||
|
||||
# 4. Confirm search on default view still rejects these fields (expected 400 — not in v_event_session)
|
||||
search_url_default = f"{API_BASE}/event/{EVENT_ID}/event_session/search"
|
||||
query = {"and": [{"field": "event_presentation_li_qry_str", "op": "like", "value": "%"}]}
|
||||
resp = requests.post(search_url_default, headers=headers, json=query)
|
||||
print_result("Search on default view correctly rejects qry_str field (expect 400)", resp.status_code == 400,
|
||||
f"(status={resp.status_code})")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting Consolidated Search Engine E2E Suite")
|
||||
print(f"Target: {API_BASE}")
|
||||
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
test_basic_operators()
|
||||
test_registry_fields()
|
||||
test_nested_search()
|
||||
test_extra_filters()
|
||||
test_event_session_qry_str_fields()
|
||||
except Exception as e:
|
||||
print(f"💥 Suite Error: {e}")
|
||||
|
||||
|
||||
print(f"\nSuite completed in {time.time() - start_time:.2f}s")
|
||||
|
||||
@@ -6,13 +6,13 @@ import os
|
||||
# --- Configuration ---
|
||||
API_ROOT = "https://dev-api.oneskyit.com"
|
||||
# Using the key provided in your examples
|
||||
API_KEY = "dFP6J9DVj9hUgIMn-fNIqg"
|
||||
API_KEY = "dFP6J9DVj9hUgIMn-fNIqg"
|
||||
# A known private journal ID from account 1
|
||||
PRIVATE_JOURNAL_ID = "SWFK-48-89-90"
|
||||
# A known public object type/ID
|
||||
PUBLIC_FQDN = "dev-app.oneskyit.com"
|
||||
# A known valid account ID random for testing restoration of access
|
||||
VALID_ACCOUNT_ID_RAND = "_XY7DXtc9MY"
|
||||
VALID_ACCOUNT_ID_RAND = "_XY7DXtc9MY"
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
@@ -24,13 +24,13 @@ def test_hardened_search_leak():
|
||||
url = f"{API_ROOT}/v3/crud/journal/search"
|
||||
headers = {"x-aether-api-key": API_KEY}
|
||||
# NO account header, NO JWT
|
||||
payload = {"and": []}
|
||||
|
||||
payload = {"and": []}
|
||||
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json().get('data', [])
|
||||
# Should be 0 because all journals in DB have an account_id,
|
||||
# Should be 0 because all journals in DB have an account_id,
|
||||
# and we are now strictly filtering for account_id IS NULL.
|
||||
success = (len(data) == 0)
|
||||
print_result("Leak Blocked (Journal Search)", success, f"- Found {len(data)} records (Expected 0)")
|
||||
@@ -43,9 +43,9 @@ def test_strict_id_block():
|
||||
url = f"{API_ROOT}/v3/crud/journal/{PRIVATE_JOURNAL_ID}"
|
||||
headers = {"x-aether-api-key": API_KEY}
|
||||
# NO account header, NO JWT
|
||||
|
||||
|
||||
resp = requests.get(url, headers=headers)
|
||||
|
||||
|
||||
success = (resp.status_code == 403)
|
||||
print_result("Access Denied (Journal GET ID)", success, f"- Status: {resp.status_code} (Expected 403)")
|
||||
|
||||
@@ -55,9 +55,9 @@ def test_bootstrap_exception():
|
||||
url = f"{API_ROOT}/v3/crud/site_domain/search"
|
||||
headers = {"x-aether-api-key": API_KEY}
|
||||
payload = {"and": [{"field": "fqdn", "op": "eq", "value": PUBLIC_FQDN}]}
|
||||
|
||||
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
|
||||
|
||||
success = (resp.status_code == 200 and len(resp.json().get('data', [])) > 0)
|
||||
print_result("Bootstrap Allowed (Site Domain)", success, f"- Status: {resp.status_code}")
|
||||
|
||||
@@ -69,22 +69,82 @@ def test_restored_access():
|
||||
"x-aether-api-key": API_KEY,
|
||||
"x-account-id": VALID_ACCOUNT_ID_RAND
|
||||
}
|
||||
|
||||
|
||||
resp = requests.get(url, headers=headers)
|
||||
|
||||
|
||||
success = (resp.status_code == 200)
|
||||
print_result("Access Restored (Journal with Header)", success, f"- Status: {resp.status_code}")
|
||||
|
||||
|
||||
def test_site_domain_access_key():
|
||||
"""
|
||||
Verify site_domain lookup respects access_key.
|
||||
|
||||
The frontend reads the 'key' query param from the browser URL and forwards it
|
||||
as 'access_key' in the POST body. No key means a public domain is expected.
|
||||
|
||||
Valid (should return a result):
|
||||
https://dev-demo.oneskyit.com — public, no key needed
|
||||
http://idaa.localhost:5173/?key=restricted — correct key
|
||||
https://dev-idaa.oneskyit.com/?key=restricted-access — correct key
|
||||
https://sk-idaa.oneskyit.com/?key=8VTOJ0X5hvT6JdiTJsGEzQ — correct key
|
||||
|
||||
Invalid (should return empty):
|
||||
http://idaa.localhost:5173/ — key required, none given
|
||||
http://idaa.localhost:5173/?key=bad-key-example — wrong key
|
||||
https://dev-idaa.oneskyit.com/ — key required, none given
|
||||
https://dev-idaa.oneskyit.com/?key= — empty key treated as none
|
||||
https://dev-idaa.oneskyit.com/?key=any-wrong-key — wrong key
|
||||
https://sk-idaa.oneskyit.com/ — key required, none given
|
||||
https://sk-idaa.oneskyit.com/?key=another-bad-key-example — wrong key
|
||||
"""
|
||||
print("\n--- Test 5: Site Domain Access Key Behavior ---")
|
||||
url = f"{API_ROOT}/v3/crud/site_domain/search"
|
||||
headers = {"x-aether-api-key": API_KEY}
|
||||
|
||||
cases = [
|
||||
# (fqdn, key, should_pass, label)
|
||||
# --- valid ---
|
||||
("dev-demo.oneskyit.com", None, True, "public domain, no key"),
|
||||
("idaa.localhost:5173", "restricted", True, "correct key"),
|
||||
("dev-idaa.oneskyit.com", "restricted-access", True, "correct key"),
|
||||
("sk-idaa.oneskyit.com", "8VTOJ0X5hvT6JdiTJsGEzQ", True, "correct key"),
|
||||
# --- invalid ---
|
||||
("idaa.localhost:5173", None, False, "key required, none given"),
|
||||
("idaa.localhost:5173", "bad-key-example", False, "wrong key"),
|
||||
("dev-idaa.oneskyit.com", None, False, "key required, none given"),
|
||||
("dev-idaa.oneskyit.com", "", False, "empty key treated as none"),
|
||||
("dev-idaa.oneskyit.com", "any-wrong-key", False, "wrong key"),
|
||||
("sk-idaa.oneskyit.com", None, False, "key required, none given"),
|
||||
("sk-idaa.oneskyit.com", "another-bad-key-example", False, "wrong key"),
|
||||
]
|
||||
|
||||
for fqdn, key, should_pass, label in cases:
|
||||
payload = {"and": [{"field": "fqdn", "op": "eq", "value": fqdn}]}
|
||||
# Omit access_key entirely when None (no key in URL); send it when present (even if empty)
|
||||
if key is not None:
|
||||
payload["and"].append({"field": "access_key", "op": "eq", "value": key})
|
||||
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload)
|
||||
data = resp.json().get('data', []) if resp.status_code == 200 else []
|
||||
success = (resp.status_code == 200 and ((len(data) > 0) == should_pass))
|
||||
tag = "VALID " if should_pass else "INVALID"
|
||||
print_result(f"[{tag}] {fqdn} key={key!r:30} ({label})", success, f"- Count: {len(data)}")
|
||||
except Exception as e:
|
||||
print_result(f"[{'VALID ' if should_pass else 'INVALID'}] {fqdn} key={key!r}", False, f"- Exception: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"Starting V3 Security Hardening Verification")
|
||||
print(f"Target: {API_ROOT}")
|
||||
|
||||
|
||||
try:
|
||||
test_hardened_search_leak()
|
||||
test_strict_id_block()
|
||||
test_bootstrap_exception()
|
||||
test_restored_access()
|
||||
test_site_domain_access_key()
|
||||
except Exception as e:
|
||||
print(f"\n❌ ERROR during test execution: {e}")
|
||||
|
||||
|
||||
print("\nVerification completed.")
|
||||
|
||||
152
tests/tools/stress_list_queries.py
Normal file
152
tests/tools/stress_list_queries.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Read-only concurrent stress test against V3 list endpoints.
|
||||
Fires N workers each making R sequential requests across a set of
|
||||
list endpoints, then prints per-endpoint latency stats and an
|
||||
overall error summary.
|
||||
|
||||
Usage (from project root):
|
||||
./environment/bin/python3 tests/tools/stress_list_queries.py
|
||||
./environment/bin/python3 tests/tools/stress_list_queries.py --workers 20 --requests 10
|
||||
./environment/bin/python3 tests/tools/stress_list_queries.py --base-url https://api.oneskyit.com --workers 5
|
||||
"""
|
||||
import argparse
|
||||
import math
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_BASE_URL = "https://test-api.oneskyit.com"
|
||||
API_KEY = "nT0jPeiCfxSifkiDZur9jA"
|
||||
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo
|
||||
|
||||
HEADERS = {
|
||||
"x-aether-api-key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID,
|
||||
}
|
||||
|
||||
# Read-only list endpoints to hammer. Each is a (label, path) tuple.
|
||||
ENDPOINTS = [
|
||||
("event list", "/v3/crud/event/"),
|
||||
("event_session list", "/v3/crud/event_session/"),
|
||||
("event_badge list", "/v3/crud/event_badge/"),
|
||||
("event_file list", "/v3/crud/event_file/"),
|
||||
("person list", "/v3/crud/person/"),
|
||||
("journal list", "/v3/crud/journal/"),
|
||||
("hosted_file list", "/v3/crud/hosted_file/"),
|
||||
("data_store list", "/v3/crud/data_store/"),
|
||||
]
|
||||
|
||||
|
||||
def percentile(sorted_times: list[float], pct: float) -> float:
|
||||
"""Return the pct-th percentile of a pre-sorted list (0–100)."""
|
||||
if not sorted_times:
|
||||
return 0.0
|
||||
k = (len(sorted_times) - 1) * pct / 100
|
||||
lo, hi = int(math.floor(k)), int(math.ceil(k))
|
||||
return sorted_times[lo] + (sorted_times[hi] - sorted_times[lo]) * (k - lo)
|
||||
|
||||
|
||||
def do_request(label: str, url: str, session: requests.Session) -> dict:
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
r = session.get(url, headers=HEADERS, timeout=15)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return {"label": label, "status": r.status_code, "ms": elapsed, "error": None}
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return {"label": label, "status": 0, "ms": elapsed, "error": str(e)}
|
||||
|
||||
|
||||
def worker(worker_id: int, requests_per_worker: int, base_url: str, limit: int) -> list[dict]:
|
||||
results = []
|
||||
with requests.Session() as session:
|
||||
for _ in range(requests_per_worker):
|
||||
for label, path in ENDPOINTS:
|
||||
url = f"{base_url}{path}?limit={limit}"
|
||||
results.append(do_request(label, url, session))
|
||||
return results
|
||||
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
icon = "✅" if success else "❌"
|
||||
suffix = f" — {message}" if message else ""
|
||||
print(f" [{icon}] {label}{suffix}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Concurrent read-only stress test")
|
||||
parser.add_argument("--workers", type=int, default=10, help="Concurrent worker threads (default: 10)")
|
||||
parser.add_argument("--requests", type=int, default=5, help="Requests per worker per endpoint (default: 5)")
|
||||
parser.add_argument("--limit", type=int, default=20, help="?limit= param on each list request (default: 20)")
|
||||
parser.add_argument("--base-url", type=str, default=DEFAULT_BASE_URL, help=f"API base URL (default: {DEFAULT_BASE_URL})")
|
||||
args = parser.parse_args()
|
||||
|
||||
total_requests = args.workers * args.requests * len(ENDPOINTS)
|
||||
print(f"\n🔥 Stress Test: {args.workers} workers × {args.requests} rounds × {len(ENDPOINTS)} endpoints = {total_requests} total requests")
|
||||
print(f" Target: {args.base_url} limit={args.limit}\n")
|
||||
|
||||
all_results: list[dict] = []
|
||||
suite_start = time.perf_counter()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
futures = [pool.submit(worker, wid, args.requests, args.base_url, args.limit) for wid in range(args.workers)]
|
||||
for f in as_completed(futures):
|
||||
all_results.extend(f.result())
|
||||
|
||||
suite_elapsed = time.perf_counter() - suite_start
|
||||
|
||||
# --- Per-endpoint stats ---
|
||||
print("─" * 60)
|
||||
print(f"{'Endpoint':<35} {'OK':>5} {'ERR':>5} {'p50ms':>7} {'p95ms':>7} {'maxms':>7}")
|
||||
print("─" * 60)
|
||||
|
||||
by_label: dict[str, list[dict]] = {}
|
||||
for r in all_results:
|
||||
by_label.setdefault(r["label"], []).append(r)
|
||||
|
||||
any_fail = False
|
||||
for label, _ in ENDPOINTS:
|
||||
rows = by_label.get(label, [])
|
||||
ok = [r for r in rows if r["status"] in (200, 201, 404) and not r["error"]]
|
||||
err = [r for r in rows if r not in ok]
|
||||
times = sorted(r["ms"] for r in ok)
|
||||
p50 = statistics.median(times) if times else 0
|
||||
p95 = percentile(times, 95)
|
||||
mx = max(times) if times else 0
|
||||
flag = "" if not err else " ⚠"
|
||||
if err:
|
||||
any_fail = True
|
||||
print(f" {label:<33} {len(ok):>5} {len(err):>5} {p50:>7.0f} {p95:>7.0f} {mx:>7.0f}{flag}")
|
||||
|
||||
print("─" * 60)
|
||||
|
||||
# --- Error detail ---
|
||||
errors = [r for r in all_results if r["error"] or r["status"] not in (200, 201, 404)]
|
||||
if errors:
|
||||
print(f"\n⚠ {len(errors)} errors encountered:")
|
||||
seen = set()
|
||||
for r in errors:
|
||||
key = (r["label"], r["status"], r["error"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
print(f" [{r['status']}] {r['label']}: {r['error'] or 'non-2xx/404'}")
|
||||
else:
|
||||
print("\n✅ Zero errors.")
|
||||
|
||||
# --- Overall summary ---
|
||||
all_times = sorted(r["ms"] for r in all_results if not r["error"])
|
||||
rps = total_requests / suite_elapsed
|
||||
print(f"\n🏁 {total_requests} requests in {suite_elapsed:.2f}s ({rps:.1f} req/s)")
|
||||
if all_times:
|
||||
print(f" p50={statistics.median(all_times):.0f}ms "
|
||||
f"p95={percentile(all_times, 95):.0f}ms "
|
||||
f"max={max(all_times):.0f}ms\n")
|
||||
|
||||
sys.exit(1 if any_fail else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
219
tests/unit/test_unit_idaa_novi_verify.py
Normal file
219
tests/unit/test_unit_idaa_novi_verify.py
Normal file
@@ -0,0 +1,219 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# Add project root to path
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
# Mock low-level deps BEFORE importing the target module.
|
||||
# logger_reset must be a passthrough — if it stays a MagicMock the decorator
|
||||
# replaces the decorated function with a MagicMock and tests get garbage results.
|
||||
mock_lib_general = MagicMock()
|
||||
mock_lib_general.logger_reset = lambda f: f
|
||||
sys.modules['app.config'] = MagicMock()
|
||||
sys.modules['app.lib_general'] = mock_lib_general
|
||||
sys.modules['app.db_sql'] = MagicMock()
|
||||
sys.modules['app.lib_redis_helpers'] = MagicMock()
|
||||
|
||||
from app.methods import idaa_novi_verify_methods as m
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_cfg():
|
||||
return {
|
||||
'novi_api_root_url': 'https://www.idaa.org/api',
|
||||
'novi_idaa_api_key': 'dGVzdGtleQ==',
|
||||
}
|
||||
|
||||
|
||||
def _novi_resp(email='alice@idaa.org', first='Alice', last='Smith', name=None):
|
||||
d = {'Email': email, 'FirstName': first, 'LastName': last}
|
||||
if name is not None:
|
||||
d['Name'] = name
|
||||
return d
|
||||
|
||||
|
||||
def _set_redis(cached_value=None):
|
||||
"""Set redis_client on the already-imported module's imported name."""
|
||||
r = MagicMock()
|
||||
r.get.return_value = cached_value
|
||||
sys.modules['app.lib_redis_helpers'].redis_client = r
|
||||
return r
|
||||
|
||||
|
||||
# ── Cache hit bypasses Novi ───────────────────────────────────────────────
|
||||
|
||||
def test_cache_hit_bypasses_novi():
|
||||
print('--- test_cache_hit_bypasses_novi ---')
|
||||
cached = json.dumps({'status': 200, 'verified': True, 'full_name': 'Bob J.', 'email': 'bob@idaa.org'})
|
||||
redis_mock = _set_redis(cached_value=cached)
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get') as mock_get:
|
||||
result = m.verify_novi_member('some-uuid')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 200
|
||||
assert result['full_name'] == 'Bob J.'
|
||||
mock_get.assert_not_called() # Novi was never contacted
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Verified 200 ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_verified_member_200():
|
||||
print('--- test_verified_member_200 ---')
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = _novi_resp()
|
||||
redis_mock = _set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('abc-123')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 200
|
||||
assert result['verified'] is True
|
||||
assert result['full_name'] == 'Alice S.'
|
||||
assert result['email'] == 'alice@idaa.org'
|
||||
redis_mock.setex.assert_called_once() # verified result cached
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Email normalization: space → + ────────────────────────────────────────
|
||||
|
||||
def test_email_space_normalization():
|
||||
print('--- test_email_space_normalization ---')
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = _novi_resp(email='alice member@idaa.org')
|
||||
_set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('abc-123')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 200
|
||||
assert result['email'] == 'alice+member@idaa.org'
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Display name format ───────────────────────────────────────────────────
|
||||
|
||||
def test_display_name_format():
|
||||
print('--- test_display_name_format ---')
|
||||
cases = [
|
||||
(_novi_resp(first='Alice', last='Smith'), 'Alice S.'),
|
||||
(_novi_resp(first='Alice', last=''), 'Alice'),
|
||||
(_novi_resp(first='', last='Smith', name='Dr. Alice'), 'Dr. Alice'),
|
||||
(_novi_resp(first='', last='', name='Dr. Alice'), 'Dr. Alice'),
|
||||
(_novi_resp(first='', last='', name=''), 'Member'),
|
||||
]
|
||||
|
||||
for novi_data, expected_name in cases:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = novi_data
|
||||
_set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('abc-123')
|
||||
|
||||
assert result['status'] == 200
|
||||
assert result['full_name'] == expected_name, \
|
||||
f"Expected '{expected_name}', got '{result['full_name']}' for input {novi_data}"
|
||||
|
||||
print('All display name cases PASS')
|
||||
|
||||
|
||||
# ── Empty-member anti-pattern: Novi 200, no Email ─────────────────────────
|
||||
|
||||
def test_empty_member_returns_404():
|
||||
print('--- test_empty_member_returns_404 ---')
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {} # Novi 200 with no identity data
|
||||
redis_mock = _set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('ghost-uuid')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 404
|
||||
redis_mock.setex.assert_not_called() # 404 must NOT be cached
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Novi 404 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_novi_404_returns_404():
|
||||
print('--- test_novi_404_returns_404 ---')
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 404
|
||||
redis_mock = _set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('missing-uuid')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 404
|
||||
redis_mock.setex.assert_not_called()
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Novi 429 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_novi_429_returns_429():
|
||||
print('--- test_novi_429_returns_429 ---')
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 429
|
||||
redis_mock = _set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('any-uuid')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 429
|
||||
redis_mock.setex.assert_not_called()
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Novi 5xx → 503 ────────────────────────────────────────────────────────
|
||||
|
||||
def test_novi_5xx_returns_503():
|
||||
print('--- test_novi_5xx_returns_503 ---')
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 502
|
||||
_set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', return_value=mock_resp):
|
||||
result = m.verify_novi_member('any-uuid')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 503
|
||||
print('PASS')
|
||||
|
||||
|
||||
# ── Novi unreachable → 503 ────────────────────────────────────────────────
|
||||
|
||||
def test_novi_unreachable_returns_503():
|
||||
print('--- test_novi_unreachable_returns_503 ---')
|
||||
import requests as req_lib
|
||||
_set_redis()
|
||||
|
||||
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
|
||||
patch('requests.get', side_effect=req_lib.exceptions.ConnectionError('refused')):
|
||||
result = m.verify_novi_member('any-uuid')
|
||||
|
||||
print('Result:', result)
|
||||
assert result['status'] == 503
|
||||
print('PASS')
|
||||
Reference in New Issue
Block a user