Compare commits
66 Commits
a0767b1c69
...
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 | ||
|
|
1f9cbb0a1f | ||
|
|
7f87f32b70 | ||
|
|
687472f4e3 | ||
|
|
91434968f7 | ||
|
|
6bde236633 | ||
|
|
cffde249d3 | ||
|
|
9d5f2c8cea | ||
|
|
b9742cfcd8 | ||
|
|
b2adfe409b | ||
|
|
b55b7ea81d | ||
|
|
8eb699efe5 | ||
|
|
c7f1341b1e | ||
|
|
15b5084df3 | ||
|
|
c9ec3d7ea1 | ||
|
|
ccf2f30e11 | ||
|
|
f23d27de15 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -141,4 +141,7 @@ logs/
|
||||
myapp/files/
|
||||
myapp/file_distribution/
|
||||
temp/
|
||||
tmp/
|
||||
tmp/
|
||||
|
||||
# Added 2026-03-23
|
||||
gunicorn.ctl
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,19 @@ from app.models.error_models import StandardError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def apply_vision_id_fix(resp_data: dict, obj_type: str, by_alias: bool) -> dict:
|
||||
"""
|
||||
V3 contract: {obj_type}_id in responses must be the random string, never the DB integer.
|
||||
Applies to models not yet migrated to the Vision ID pattern (root_validator).
|
||||
Safe to call on already-migrated models — no-op if the value is already a string.
|
||||
"""
|
||||
_id_key = f'{obj_type}_id' if by_alias else 'id'
|
||||
_rand_key = f'{obj_type}_id_random' if by_alias else 'id_random'
|
||||
if isinstance(resp_data.get(_id_key), int) and resp_data.get(_rand_key):
|
||||
resp_data[_id_key] = resp_data[_rand_key]
|
||||
return resp_data
|
||||
|
||||
|
||||
def format_db_error(raw_error: str) -> StandardError:
|
||||
"""
|
||||
Parses raw SQLAlchemy/MariaDB errors into structured StandardError objects.
|
||||
|
||||
@@ -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:
|
||||
|
||||
14
app/main.py
14
app/main.py
@@ -26,7 +26,7 @@ from app.db_sql import sql_select, reset_redis, reconnect_db
|
||||
from app.lib_config_v3 import bootstrap_db_config, validate_critical_config
|
||||
|
||||
|
||||
print('### **** *** ** * The Aether API v4 using FastAPI is loading... * ** *** **** ###')
|
||||
print('### **** *** ** * The Aether API v3.0 using FastAPI is loading... * ** *** **** ###')
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -42,7 +42,7 @@ async def lifespan(app: FastAPI):
|
||||
"""
|
||||
# 1. Initialize Logging early but safely
|
||||
setup_logging(config.settings)
|
||||
log.info('### **** *** ** * Aether API v4 using FastAPI - Startup Lifespan Initiated * ** *** **** ###')
|
||||
log.info('### **** *** ** * Aether API v3.0 using FastAPI - Startup Lifespan Initiated * ** *** **** ###')
|
||||
|
||||
# 2. Bootstrapping Configuration from DB with robust error handling
|
||||
log.info("Bootstrapping Configuration...")
|
||||
@@ -82,21 +82,21 @@ async def lifespan(app: FastAPI):
|
||||
# 3. Final validation of critical infrastructure
|
||||
validate_critical_config(config.settings)
|
||||
|
||||
log.info('### **** *** ** * Aether API v4 using FastAPI - Startup Sequence Complete * ** *** **** ###')
|
||||
log.info('### **** *** ** * Aether API v3.0 using FastAPI - Startup Sequence Complete * ** *** **** ###')
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown logic
|
||||
log.info('### **** *** ** * Aether API v4 using FastAPI - Shutdown Lifespan Initiated * ** *** **** ###')
|
||||
log.info('### **** *** ** * Aether API v3.0 using FastAPI - Shutdown Lifespan Initiated * ** *** **** ###')
|
||||
log.info('The Aether FastAPI API is shutting down...')
|
||||
|
||||
|
||||
print('### **** *** ** * Aether API v4 using FastAPI - About to try FastAPI() while loading... * ** *** **** ###')
|
||||
print('### **** *** ** * Aether API v3.0 using FastAPI - About to try FastAPI() while loading... * ** *** **** ###')
|
||||
app = FastAPI(
|
||||
# debug = True,
|
||||
title = 'Aether API',
|
||||
description = 'One Sky IT\'s Aether API v4 using FastAPI.',
|
||||
version = '3.00.01',
|
||||
description = 'One Sky IT\'s Aether API v3.0 using FastAPI.',
|
||||
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
|
||||
@@ -29,7 +29,7 @@ def get_lookup_list_v3(
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY `group`
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
(for_type = :for_type AND for_id = :for_id) DESC,
|
||||
(account_id = :account_id) DESC,
|
||||
created_on DESC
|
||||
|
||||
@@ -147,6 +147,9 @@ def get_site_domain_rec_list(
|
||||
# ### BEGIN ### API Site Domain Methods ### lookup_site_domain_fqdn() ###
|
||||
def lookup_site_domain_fqdn(
|
||||
fqdn: str,
|
||||
# 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,
|
||||
@@ -156,15 +159,37 @@ def lookup_site_domain_fqdn(
|
||||
|
||||
data = {}
|
||||
data['fqdn'] = fqdn
|
||||
# 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
|
||||
|
||||
# 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
|
||||
{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};
|
||||
@@ -176,4 +201,11 @@ def lookup_site_domain_fqdn(
|
||||
site_domain_rec_li = []
|
||||
|
||||
return site_domain_rec_li
|
||||
|
||||
# ---
|
||||
# To restore access_key validation:
|
||||
# 1. Accept access_key as a parameter to this function (and any API endpoint calling it).
|
||||
# 2. Add access_key to the SQL WHERE clause (see above) so only matching records are returned.
|
||||
# 3. If access_key is required, return empty or error if not matched.
|
||||
# 4. Update API docs and tests to reflect the new/required parameter.
|
||||
# ### END ### API Site Domain Methods ### get_site_domain_rec_list() ###
|
||||
|
||||
@@ -654,7 +654,7 @@ def email_user_auth_key_url(
|
||||
else: return False
|
||||
log.debug(account_cfg)
|
||||
|
||||
user_id_random = user_obj.id_random # NOTE: Not user_id_random because of alias
|
||||
user_id_random = user_obj.id or user_obj.user_id # Vision ID: User_Out_Base uses 'id'/'user_id', not 'id_random'
|
||||
|
||||
from_email = account_cfg.default_no_reply_email
|
||||
from_name = account_cfg.default_no_reply_name
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
from app.lib_general import log, logging
|
||||
@@ -18,13 +18,12 @@ class Archive_Content_Base(BaseModel):
|
||||
# log.debug(test_var)
|
||||
# return test_var
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
# **base_fields['archive_content_id_random'],
|
||||
alias = 'archive_content_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'archive_content_id'
|
||||
)
|
||||
# --- Vision IDs (primary public identifiers — always random strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['archive_content_id_random'])
|
||||
archive_content_id: Optional[str] = Field(None, **base_fields['archive_content_id_random'])
|
||||
# Legacy alias kept for backward compatibility; populated by root_validator
|
||||
id_random: Optional[str] = Field(None, alias='archive_content_id_random')
|
||||
|
||||
account_id_random: Optional[str]
|
||||
account_id: Optional[int]
|
||||
|
||||
@@ -37,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]
|
||||
|
||||
@@ -94,6 +96,7 @@ class Archive_Content_Base(BaseModel):
|
||||
|
||||
# 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] = [
|
||||
'id', 'archive_content_id', 'id_random',
|
||||
'account_id', 'account_id_random', 'archive_id_random', 'hosted_file_id_random',
|
||||
'hosted_file_path', 'api_hosted_file_path_download', 'api_hosted_file_path_stream',
|
||||
'hosted_file_hash_sha256', 'hosted_file_content_type', 'hosted_file_size'
|
||||
@@ -101,12 +104,21 @@ class Archive_Content_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def archive_content_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='archive_content')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer: Map DB random-string keys to clean Vision ID fields.
|
||||
Collision prevention strips any integer that snuck into the string ID fields.
|
||||
"""
|
||||
rid = values.get('id_random') or values.get('archive_content_id_random')
|
||||
if rid and isinstance(rid, str):
|
||||
values['id'] = rid
|
||||
values['archive_content_id'] = rid
|
||||
# Collision prevention: reject integer values in Vision string fields
|
||||
for k in ['id', 'archive_content_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
return values
|
||||
|
||||
@validator('archive_id', always=True)
|
||||
def archive_id_lookup(cls, v, values, **kwargs):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime, hashlib, logging, os, pytz, redis, secrets
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from typing import ClassVar, Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import get_id_random, redis_lookup_id_random
|
||||
@@ -169,6 +169,14 @@ class User_New_Base(BaseModel):
|
||||
# Including JSON data
|
||||
other_json: Optional[Json]
|
||||
|
||||
# Fields that are part of the model (for input) but must not be written to the DB table
|
||||
fields_to_exclude_from_db: ClassVar[list] = [
|
||||
'new_password', # Virtual input field — the validator hashes it into 'password'; DB has no new_password column
|
||||
'id', 'user_id', # Vision ID strings — DB uses int 'id' (auto) and string 'id_random'
|
||||
'account_id_random', 'contact_id_random', 'organization_id_random', 'person_id_random',
|
||||
'account_name',
|
||||
]
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@root_validator(pre=True)
|
||||
@@ -181,7 +189,7 @@ class User_New_Base(BaseModel):
|
||||
if rid := values.get('id_random') or values.get('user_id_random'):
|
||||
values['id'] = rid
|
||||
values['user_id'] = rid
|
||||
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if c_rid := values.get('contact_id_random'):
|
||||
@@ -190,12 +198,22 @@ class User_New_Base(BaseModel):
|
||||
values['organization_id'] = o_rid
|
||||
if p_rid := values.get('person_id_random'):
|
||||
values['person_id'] = p_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'user_id', 'account_id', 'contact_id', 'organization_id', 'person_id']:
|
||||
|
||||
# 2. Prevent "Collision Population" — only strip self-reference IDs.
|
||||
# FK IDs (account_id, contact_id, etc.) are resolved to integers by sanitize_payload
|
||||
# before model construction and must NOT be stripped, or they won't be written to the DB.
|
||||
for k in ['id', 'user_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
|
||||
|
||||
# 3. Pre-inject hashed password so it appears in __fields_set__.
|
||||
# The @validator('password', always=True) below computes the same hash, but
|
||||
# exclude_unset=True (used by the CRUD POST handler) only includes fields that
|
||||
# were in the original input dict. By injecting 'password' here (pre=True),
|
||||
# it is treated as part of the input and thus written to the DB.
|
||||
if new_pw := values.get('new_password'):
|
||||
values['password'] = secure_hash_string(string=new_pw)
|
||||
|
||||
return values
|
||||
|
||||
@validator('password', always=True)
|
||||
|
||||
@@ -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,6 +33,12 @@ events_registration_obj_li = {
|
||||
'member_status', 'registration_type_code',
|
||||
'notes', 'created_on', 'updated_on', 'default_qry_str'
|
||||
],
|
||||
# Allow nested operations under both `event` and `event_person` parents.
|
||||
# `event_badge` is directly linked to `event_person` (FK: event_person_id),
|
||||
# but views expose it under `event` as well. Explicitly register both
|
||||
# so nested CRUD routes like POST /v3/crud/event_person/{id}/event_badge/
|
||||
# will be accepted by the generic nested router.
|
||||
'parent_types': ['event', 'event_person'],
|
||||
},
|
||||
'event_badge_template': {
|
||||
'tbl': 'event_badge_template',
|
||||
|
||||
@@ -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'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -39,24 +39,24 @@ async def get_aether_cfg_obj(
|
||||
return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
|
||||
@router.get('/aether/flask/cfg/{aether_flask_cfg_id}', response_model=Resp_Body_Base)
|
||||
async def get_aether_flask_cfg_obj(
|
||||
aether_flask_cfg_id: int,
|
||||
# aether_flask_cfg_id: str = Path(min_length=11, max_length=22),
|
||||
# @router.get('/aether/flask/cfg/{aether_flask_cfg_id}', response_model=Resp_Body_Base)
|
||||
# async def get_aether_flask_cfg_obj(
|
||||
# aether_flask_cfg_id: int,
|
||||
# # aether_flask_cfg_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
# NOTE: The x_account_id header value is not required.
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
commons: Common_Route_Params_No_Account_ID = Depends(common_route_params_no_account_id),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
# # NOTE: The x_account_id header value is not required.
|
||||
# # commons: Common_Route_Params = Depends(common_route_params),
|
||||
# commons: Common_Route_Params_No_Account_ID = Depends(common_route_params_no_account_id),
|
||||
# ):
|
||||
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
|
||||
if sql_select_result := sql_select(
|
||||
table_name = 'cfg_flask',
|
||||
record_id = aether_flask_cfg_id,
|
||||
as_list = False,
|
||||
max_count = 1,
|
||||
):
|
||||
return mk_resp(data=sql_select_result, response=commons.response)
|
||||
else:
|
||||
return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
# if sql_select_result := sql_select(
|
||||
# table_name = 'cfg_flask',
|
||||
# record_id = aether_flask_cfg_id,
|
||||
# as_list = False,
|
||||
# max_count = 1,
|
||||
# ):
|
||||
# return mk_resp(data=sql_select_result, response=commons.response)
|
||||
# else:
|
||||
# return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
@@ -4,15 +4,16 @@ 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
|
||||
|
||||
@@ -20,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(
|
||||
@@ -53,41 +65,45 @@ 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
|
||||
|
||||
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
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
|
||||
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}")
|
||||
@@ -98,7 +114,9 @@ async def authenticate_passcode(
|
||||
|
||||
# --- JWT Request ---
|
||||
|
||||
@router.get('/request_jwt', response_model=Resp_Body_Base)
|
||||
# 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),
|
||||
x_aether_api_key: Optional[str] = Header(None, min_length=22, max_length=22),
|
||||
@@ -151,7 +169,8 @@ 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 })
|
||||
|
||||
@router.get('/temp_token', response_model=Resp_Body_Base)
|
||||
# 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),
|
||||
response: Response = Response,
|
||||
@@ -167,6 +186,10 @@ async def get_api_temp_token(
|
||||
|
||||
# --- Jitsi Token ---
|
||||
|
||||
# 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"
|
||||
JITSI_DOMAIN = "jitsi.dgrzone.com"
|
||||
@@ -184,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": {
|
||||
@@ -211,40 +232,43 @@ async def create_jitsi_jwt(request_data: JitsiTokenRequest = Body(...)):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create JWT: {str(e)}")
|
||||
|
||||
# --- Api_Base CRUD ---
|
||||
# LEGACY (disabled) - superseded by V3 CRUD: /v3/crud/api/
|
||||
|
||||
@router.post('', response_model=Resp_Body_Base)
|
||||
async def post_api_obj(obj: Api_Base, x_account_id: str = Header(...)):
|
||||
return post_obj_template(obj_type='api', data=obj.dict(by_alias=False, exclude_unset=True), return_obj=True)
|
||||
# @router.post('', response_model=Resp_Body_Base)
|
||||
# async def post_api_obj(obj: Api_Base, x_account_id: str = Header(...)):
|
||||
# return post_obj_template(obj_type='api', data=obj.dict(by_alias=False, exclude_unset=True), return_obj=True)
|
||||
|
||||
@router.patch('/{obj_id}', response_model=Resp_Body_Base)
|
||||
async def patch_api_obj(obj_id: str, obj: Api_Base, x_account_id: str = Header(...)):
|
||||
data = obj.dict(by_alias=False, exclude_unset=True)
|
||||
data['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name='api')
|
||||
return patch_obj_template(obj_type='api', data=data, obj_id=obj_id, return_obj=True)
|
||||
# @router.patch('/{obj_id}', response_model=Resp_Body_Base)
|
||||
# async def patch_api_obj(obj_id: str, obj: Api_Base, x_account_id: str = Header(...)):
|
||||
# data = obj.dict(by_alias=False, exclude_unset=True)
|
||||
# data['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name='api')
|
||||
# return patch_obj_template(obj_type='api', data=data, obj_id=obj_id, return_obj=True)
|
||||
|
||||
@router.get('/list', response_model=Resp_Body_Base)
|
||||
async def get_api_obj_li(for_obj_type: Optional[str] = Query(None), for_obj_id: Optional[str] = Query(None), x_account_id: str = Header(...)):
|
||||
return get_obj_li_template(obj_type='api', for_obj_type=for_obj_type, for_obj_id=for_obj_id)
|
||||
# @router.get('/list', response_model=Resp_Body_Base)
|
||||
# async def get_api_obj_li(for_obj_type: Optional[str] = Query(None), for_obj_id: Optional[str] = Query(None), x_account_id: str = Header(...)):
|
||||
# return get_obj_li_template(obj_type='api', for_obj_type=for_obj_type, for_obj_id=for_obj_id)
|
||||
|
||||
@router.get('/{obj_id}', response_model=Resp_Body_Base)
|
||||
async def get_api_obj(obj_id: str, x_account_id: str = Header(...)):
|
||||
return get_obj_template(obj_type='api', obj_id=obj_id)
|
||||
# @router.get('/{obj_id}', response_model=Resp_Body_Base)
|
||||
# async def get_api_obj(obj_id: str, x_account_id: str = Header(...)):
|
||||
# return get_obj_template(obj_type='api', obj_id=obj_id)
|
||||
|
||||
@router.delete('/{obj_id}', response_model=Resp_Body_Base)
|
||||
async def delete_api_obj(obj_id: str, x_account_id: str = Header(...)):
|
||||
return delete_obj_template(obj_type='api', obj_id=obj_id)
|
||||
# @router.delete('/{obj_id}', response_model=Resp_Body_Base)
|
||||
# async def delete_api_obj(obj_id: str, x_account_id: str = Header(...)):
|
||||
# return delete_obj_template(obj_type='api', obj_id=obj_id)
|
||||
|
||||
@router.get('/get_id/{object_type}/{object_id_random}', response_model=Resp_Body_Base)
|
||||
async def get_api_object_id(object_type: str, object_id_random: str):
|
||||
if object_id := redis_lookup_id_random(record_id_random=object_id_random, table_name=object_type):
|
||||
return mk_resp(data={ 'object_id': object_id})
|
||||
return mk_resp(data=None, status_code=404)
|
||||
# LEGACY (disabled) - exposes internal integer IDs, breaks id_random abstraction
|
||||
# @router.get('/get_id/{object_type}/{object_id_random}', response_model=Resp_Body_Base)
|
||||
# async def get_api_object_id(object_type: str, object_id_random: str):
|
||||
# if object_id := redis_lookup_id_random(record_id_random=object_id_random, table_name=object_type):
|
||||
# return mk_resp(data={ 'object_id': object_id})
|
||||
# return mk_resp(data=None, status_code=404)
|
||||
|
||||
@router.get('/sql_test', tags=['Testing'])
|
||||
async def sql_test(response: Response = Response):
|
||||
sql = text("SELECT NOW() as current_time, VERSION() as version")
|
||||
try:
|
||||
result = db.execute(sql).fetchone()
|
||||
return mk_resp(data={"current_time": str(result[0]), "version": result[1]})
|
||||
except Exception as e:
|
||||
return mk_resp(data=False, status_code=500, details=str(e), response=response)
|
||||
# LEGACY (disabled) - testing/debug endpoint
|
||||
# @router.get('/sql_test', tags=['Testing'])
|
||||
# async def sql_test(response: Response = Response):
|
||||
# sql = text("SELECT NOW() as current_time, VERSION() as version")
|
||||
# try:
|
||||
# result = db.execute(sql).fetchone()
|
||||
# return mk_resp(data={"current_time": str(result[0]), "version": result[1]})
|
||||
# except Exception as e:
|
||||
# return mk_resp(data=False, status_code=500, details=str(e), response=response)
|
||||
@@ -15,7 +15,8 @@ from app.lib_general_v3 import (
|
||||
)
|
||||
from app.lib_api_crud_v3 import (
|
||||
check_account_access, apply_forced_account_filter, filter_order_by,
|
||||
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error
|
||||
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error,
|
||||
apply_vision_id_fix
|
||||
)
|
||||
from app.lib_schema_v3 import get_object_schema_info
|
||||
from app.db_sql import get_last_sql_error
|
||||
@@ -60,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"])
|
||||
@@ -86,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.
|
||||
"""
|
||||
@@ -117,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.
|
||||
@@ -148,15 +149,16 @@ 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
|
||||
|
||||
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none)
|
||||
apply_vision_id_fix(resp_data, obj_name, serialization.by_alias)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
else:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message=f"Object with ID '{obj_id}' not found in database.")
|
||||
@@ -179,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).
|
||||
@@ -197,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']
|
||||
@@ -211,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):
|
||||
@@ -230,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)
|
||||
|
||||
@@ -276,14 +278,14 @@ 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:
|
||||
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
|
||||
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
|
||||
return mk_resp(data=resp_data_li, response=response)
|
||||
else:
|
||||
return mk_resp(data=[], status_code=200, response=response)
|
||||
@@ -306,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.
|
||||
@@ -341,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.")
|
||||
@@ -386,14 +413,14 @@ 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:
|
||||
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
|
||||
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
|
||||
return mk_resp(data=resp_data_li, response=response)
|
||||
else:
|
||||
return mk_resp(data=[], status_code=200, response=response)
|
||||
@@ -412,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.
|
||||
@@ -438,14 +465,9 @@ async def post_obj(
|
||||
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
|
||||
elif obj_name == 'account':
|
||||
if obj_name == 'account':
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Account creation is restricted.")
|
||||
|
||||
# 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:
|
||||
@@ -457,6 +479,22 @@ async def post_obj(
|
||||
|
||||
data_to_insert = validated_obj.dict(exclude_unset=True)
|
||||
|
||||
# Sanitize payload AFTER model validation so that:
|
||||
# 1. The model receives raw Vision ID strings (passes field-length constraints).
|
||||
# 2. ID resolution (string → integer) happens on the serialized dict that goes to the DB,
|
||||
# avoiding conflicts with root_validator collision-prevention logic.
|
||||
sanitize_payload(data_to_insert, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
# 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__:
|
||||
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
|
||||
new_obj_id_random = get_id_random(record_id=new_obj_id, table_name=obj_name)
|
||||
@@ -464,8 +502,9 @@ async def post_obj(
|
||||
if return_obj:
|
||||
if sql_select_result := sql_select(table_name=table_name_select, record_id=new_obj_id):
|
||||
resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
apply_vision_id_fix(resp_data, obj_name, serialization.by_alias)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response)
|
||||
return mk_resp(data={"obj_id": new_obj_id_random, "obj_id_random": new_obj_id_random}, response=response)
|
||||
else:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
@@ -486,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.
|
||||
@@ -527,6 +566,7 @@ async def patch_obj(
|
||||
if return_obj:
|
||||
if sql_select_result := sql_select(table_name=table_name_select, record_id=record_id):
|
||||
resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
apply_vision_id_fix(resp_data, obj_name, serialization.by_alias)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=True, response=response, status_message="Object updated successfully.")
|
||||
else:
|
||||
@@ -546,7 +586,7 @@ async def delete_obj(
|
||||
):
|
||||
"""
|
||||
Delete Object.
|
||||
|
||||
|
||||
Supports:
|
||||
- Soft Delete: `method='hide'` or `method='disable'`.
|
||||
- Hard Delete: `method='delete'`.
|
||||
|
||||
@@ -13,7 +13,8 @@ from app.lib_general_v3 import (
|
||||
)
|
||||
from app.lib_api_crud_v3 import (
|
||||
check_account_access, apply_forced_account_filter, filter_order_by,
|
||||
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error
|
||||
get_supported_filters, safe_json_loads, sanitize_payload, format_db_error,
|
||||
apply_vision_id_fix
|
||||
)
|
||||
from app.db_sql import get_last_sql_error
|
||||
from app.models.response_models import *
|
||||
@@ -84,6 +85,9 @@ async def get_child_obj_li(
|
||||
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
|
||||
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
||||
|
||||
# Log parent/child resolution details (use INFO so logs appear in production)
|
||||
log.info("nested.list start parent=%s parent_table=%s parent_id_random=%s child=%s table=%s allowed_parents=%s", parent_obj_type, parent_table, parent_obj_id, obj_name, table_name, obj_cfg.get('parent_types'))
|
||||
|
||||
if not table_name or not base_name:
|
||||
return mk_resp(data=False, status_code=500, response=response, status_message=f"Configuration error.")
|
||||
|
||||
@@ -91,15 +95,26 @@ async def get_child_obj_li(
|
||||
status_filter = get_supported_filters(base_name, status_filter)
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
log.info("nested.list resolved_parent_id=%s (random=%s) for parent_table=%s", resolved_parent_id, parent_obj_id, parent_table)
|
||||
if not resolved_parent_id:
|
||||
log.info("nested.list parent resolution failed for random id=%s table=%s", parent_obj_id, parent_table)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message=f"Parent not found.")
|
||||
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
log.info("nested.list invalid parent type: parent=%s allowed=%s", parent_obj_type, allowed_parents)
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message=f"Invalid parent type for this child.")
|
||||
|
||||
parent_cfg = obj_type_kv_li[parent_obj_type]
|
||||
parent_table_select = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table_select, record_id=resolved_parent_id):
|
||||
log.info("nested.list parent_sql_res found for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
||||
log.info("nested.list access denied to parent id=%s for account=%s", resolved_parent_id, getattr(account, 'account_id', None))
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
||||
else:
|
||||
log.info("nested.list parent sql_select returned no row for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name)
|
||||
@@ -132,7 +147,7 @@ async def get_child_obj_li(
|
||||
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:
|
||||
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
|
||||
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
|
||||
return mk_resp(data=resp_data_li, response=response)
|
||||
else:
|
||||
return mk_resp(data=[], status_code=200, response=response)
|
||||
@@ -181,15 +196,26 @@ async def search_child_obj_li(
|
||||
searchable_fields = obj_cfg.get('searchable_fields')
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
log.info("nested.search resolved_parent_id=%s (random=%s) for parent_table=%s", resolved_parent_id, parent_obj_id, parent_table)
|
||||
if not resolved_parent_id:
|
||||
log.info("nested.search parent resolution failed for random id=%s table=%s", parent_obj_id, parent_table)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
log.info("nested.search invalid parent type: parent=%s allowed=%s", parent_obj_type, allowed_parents)
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid parent type for this child.")
|
||||
|
||||
parent_cfg = obj_type_kv_li[parent_obj_type]
|
||||
parent_table_select = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table_select, record_id=resolved_parent_id):
|
||||
log.info("nested.search parent_sql_res found for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
||||
log.info("nested.search access denied to parent id=%s for account=%s", resolved_parent_id, getattr(account, 'account_id', None))
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
||||
else:
|
||||
log.info("nested.search parent sql_select returned no row for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
# Enforce account isolation on the search query
|
||||
@@ -218,7 +244,7 @@ async def search_child_obj_li(
|
||||
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:
|
||||
resp_data_li = [base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) for record in sql_result]
|
||||
resp_data_li = [apply_vision_id_fix(base_name(**record).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none), obj_name, serialization.by_alias) for record in sql_result]
|
||||
return mk_resp(data=resp_data_li, response=response)
|
||||
else:
|
||||
return mk_resp(data=[], status_code=200, response=response)
|
||||
@@ -256,18 +282,29 @@ async def post_child_obj(
|
||||
# ID Vision: Resolve physical table names from registry to support aliases
|
||||
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
log.info("nested.post parent=%s parent_table=%s parent_id_random=%s", parent_obj_type, parent_table, parent_obj_id)
|
||||
log.info("nested.post resolved_parent_id=%s for random=%s table=%s", resolved_parent_id, parent_obj_id, parent_table)
|
||||
if not resolved_parent_id:
|
||||
log.info("nested.post parent resolution failed for random id=%s table=%s", parent_obj_id, parent_table)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
parent_cfg = obj_type_kv_li[parent_obj_type]
|
||||
parent_table_select = parent_cfg.get('tbl_default', parent_cfg.get('tbl'))
|
||||
if parent_sql_res := sql_select(table_name=parent_table_select, record_id=resolved_parent_id):
|
||||
log.info("nested.post parent_sql_res found for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
if not check_account_access(parent_sql_res, account, parent_obj_type):
|
||||
log.info("nested.post access denied to parent id=%s for account=%s", resolved_parent_id, getattr(account, 'account_id', None))
|
||||
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to parent.")
|
||||
else:
|
||||
log.info("nested.post parent sql_select returned no row for id=%s table=%s", resolved_parent_id, parent_table_select)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Parent not found.")
|
||||
|
||||
obj_cfg = obj_type_kv_li[child_obj_type]
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
log.info("nested.post invalid parent type: parent=%s allowed=%s", parent_obj_type, allowed_parents)
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid parent type for this child.")
|
||||
table_name_insert = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
|
||||
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
||||
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
|
||||
@@ -276,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:
|
||||
@@ -295,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):
|
||||
@@ -306,8 +344,9 @@ async def post_child_obj(
|
||||
if return_obj:
|
||||
if sql_select_result := sql_select(table_name=table_name_select, record_id=new_obj_id):
|
||||
resp_data = output_model(**sql_select_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
apply_vision_id_fix(resp_data, child_obj_type, serialization.by_alias)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data={"obj_id": new_obj_id, "obj_id_random": new_obj_id_random}, response=response)
|
||||
return mk_resp(data={"obj_id": new_obj_id_random, "obj_id_random": new_obj_id_random}, response=response)
|
||||
else:
|
||||
# Standardized rich error bubbling
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
@@ -349,6 +388,10 @@ async def get_child_obj(
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
|
||||
|
||||
obj_cfg = obj_type_kv_li[child_obj_type]
|
||||
# Enforce allowed parent types when configured on the child object
|
||||
allowed_parents = obj_cfg.get('parent_types')
|
||||
if allowed_parents and parent_obj_type not in allowed_parents:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid parent type for this child.")
|
||||
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
|
||||
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
||||
|
||||
@@ -357,6 +400,7 @@ async def get_child_obj(
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
|
||||
|
||||
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
apply_vision_id_fix(resp_data, child_obj_type, serialization.by_alias)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
|
||||
|
||||
@@ -418,6 +462,7 @@ async def patch_child_obj(
|
||||
if return_obj:
|
||||
if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
|
||||
resp_data = output_model(**updated_child).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
apply_vision_id_fix(resp_data, child_obj_type, serialization.by_alias)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=True, response=response, status_message="Updated successfully.")
|
||||
else:
|
||||
@@ -425,116 +470,6 @@ async def patch_child_obj(
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.", details=db_err.dict())
|
||||
|
||||
|
||||
@router.get('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
|
||||
async def get_child_obj(
|
||||
response: Response,
|
||||
parent_obj_type: str = Path(min_length=2, max_length=50),
|
||||
parent_obj_id: str = Path(min_length=11, max_length=22),
|
||||
child_obj_type: str = Path(min_length=2, max_length=50),
|
||||
child_obj_id: str = Path(min_length=11, max_length=22),
|
||||
view: str = Query('default'),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
serialization: SerializationParams = Depends(),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Retrieve Child Object.
|
||||
|
||||
Verifies that the child belongs to the specified parent.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select
|
||||
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
# ID Vision: Resolve physical table names from registry to support aliases
|
||||
if parent_obj_type not in obj_type_kv_li or child_obj_type not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid object type(s).")
|
||||
|
||||
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
|
||||
child_table = obj_type_kv_li[child_obj_type].get('tbl')
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_table)
|
||||
|
||||
if not resolved_parent_id or not resolved_child_id:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
|
||||
|
||||
obj_cfg = obj_type_kv_li[child_obj_type]
|
||||
table_name = obj_cfg.get(f'tbl_{view}', obj_cfg.get('tbl_default', obj_cfg.get('tbl')))
|
||||
base_name = obj_cfg.get(f'mdl_{view}', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
||||
|
||||
if sql_result := sql_select(table_name=table_name, record_id=resolved_child_id):
|
||||
if sql_result.get(f'{parent_obj_type}_id') != resolved_parent_id:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
|
||||
|
||||
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
|
||||
|
||||
|
||||
@router.patch('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
|
||||
async def patch_child_obj(
|
||||
request: Request,
|
||||
response: Response,
|
||||
parent_obj_type: str = Path(min_length=2, max_length=50),
|
||||
parent_obj_id: str = Path(min_length=11, max_length=22),
|
||||
child_obj_type: str = Path(min_length=2, max_length=50),
|
||||
child_obj_id: str = Path(min_length=11, max_length=22),
|
||||
return_obj: Optional[bool] = True,
|
||||
x_ae_ignore_extra_fields: Optional[bool] = Header(False),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
serialization: SerializationParams = Depends(),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Update Child Object.
|
||||
|
||||
Verifies that the child belongs to the specified parent before updating.
|
||||
"""
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
|
||||
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
obj_data = await request.json()
|
||||
|
||||
# ID Vision: Resolve physical table names from registry to support aliases
|
||||
if parent_obj_type not in obj_type_kv_li or child_obj_type not in obj_type_kv_li:
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Invalid object type(s).")
|
||||
|
||||
parent_table = obj_type_kv_li[parent_obj_type].get('tbl')
|
||||
child_table = obj_type_kv_li[child_obj_type].get('tbl')
|
||||
|
||||
resolved_parent_id = redis_lookup_id_random(record_id_random=parent_obj_id, table_name=parent_table)
|
||||
resolved_child_id = redis_lookup_id_random(record_id_random=child_obj_id, table_name=child_table)
|
||||
|
||||
if not resolved_parent_id or not resolved_child_id:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Object(s) not found.")
|
||||
|
||||
obj_cfg = obj_type_kv_li[child_obj_type]
|
||||
table_name_update = obj_cfg.get('tbl_update', obj_cfg.get('tbl'))
|
||||
table_name_select = obj_cfg.get('tbl_default', obj_cfg.get('tbl'))
|
||||
input_model = obj_cfg.get('mdl_in', obj_cfg.get('mdl'))
|
||||
output_model = obj_cfg.get('mdl_out', obj_cfg.get('mdl_default', obj_cfg.get('mdl')))
|
||||
|
||||
if existing_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
|
||||
if existing_child.get(f'{parent_obj_type}_id') != resolved_parent_id:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found under parent.")
|
||||
else:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Child not found.")
|
||||
|
||||
# Sanitize payload (ID resolution, virtual fields, and optionally extra fields)
|
||||
sanitize_payload(obj_data, input_model, ignore_extra=x_ae_ignore_extra_fields)
|
||||
|
||||
if sql_update(data=obj_data, table_name=table_name_update, record_id=resolved_child_id):
|
||||
if return_obj:
|
||||
if updated_child := sql_select(table_name=table_name_select, record_id=resolved_child_id):
|
||||
resp_data = output_model(**updated_child).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset)
|
||||
return mk_resp(data=resp_data, response=response)
|
||||
return mk_resp(data=True, response=response, status_message="Updated successfully.")
|
||||
else:
|
||||
db_err = format_db_error(get_last_sql_error())
|
||||
return mk_resp(data=False, status_code=400, response=response, status_message="Update failed.", details=db_err.dict())
|
||||
|
||||
|
||||
@router.delete('/{parent_obj_type}/{parent_obj_id}/{child_obj_type}/{child_obj_id}', response_model=Resp_Body_Base)
|
||||
async def delete_child_obj(
|
||||
|
||||
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)
|
||||
@@ -15,7 +15,8 @@ from app.config import settings
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete, get_id_random
|
||||
from app.methods.hosted_file_methods import (
|
||||
create_hosted_file_obj, load_hosted_file_obj, save_file,
|
||||
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list
|
||||
create_hosted_file_link, delete_hosted_file_link, get_hosted_file_link_rec_list,
|
||||
lookup_file_hash, check_for_hosted_file_hash_file
|
||||
)
|
||||
from app.methods.lib_media import convert_file_method
|
||||
from app.methods.lib_media import clip_video_method
|
||||
@@ -354,6 +355,38 @@ async def download_file_by_hash_action(
|
||||
return FileResponse(full_file_path, filename=target_filename, media_type=media_type)
|
||||
|
||||
|
||||
@router.get('/hash/{hosted_file_hash}', response_model=Resp_Body_Base)
|
||||
async def check_hosted_file_obj_w_hash_action(
|
||||
response: Response,
|
||||
hosted_file_hash: str = Path(min_length=64, max_length=64),
|
||||
check_for_local: Optional[bool] = Query(True),
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
delay: DelayParams = Depends(),
|
||||
):
|
||||
"""
|
||||
Look up a hosted_file record by its hash (Deduplication Check).
|
||||
Optionally verifies physical file existence on disk.
|
||||
"""
|
||||
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
|
||||
|
||||
if hfid := lookup_file_hash(file_hash=hosted_file_hash):
|
||||
obj_model = load_hosted_file_obj(hosted_file_id=hfid, model_as_dict=False)
|
||||
if not obj_model:
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="Record found but data could not be loaded.")
|
||||
|
||||
if check_for_local:
|
||||
# We use the model directly to access subdirectory_path even if it's excluded from dicts
|
||||
sub_dir = getattr(obj_model, 'subdirectory_path', '') or ''
|
||||
if check := check_for_hosted_file_hash_file(file_hash=hosted_file_hash, sub_dir=sub_dir):
|
||||
obj_model.hosted_file_found_check = True
|
||||
obj_model.hosted_file_size_check = check['file_size']
|
||||
|
||||
# mk_resp will handle model->dict conversion with proper ID Vision mapping
|
||||
return mk_resp(data=obj_model)
|
||||
|
||||
return mk_resp(data=False, status_code=404, response=response, status_message="No record found for this hash.")
|
||||
|
||||
|
||||
@router.delete('/{hosted_file_id}', response_model=Resp_Body_Base)
|
||||
async def delete_file_action(
|
||||
hosted_file_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
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.'))
|
||||
299
app/routers/api_v3_actions_user.py
Normal file
299
app/routers/api_v3_actions_user.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Aether API V3 - User Action Router
|
||||
------------------------------------
|
||||
Handles secure, stateful user account operations that are not standard CRUD.
|
||||
|
||||
Routes:
|
||||
POST /authenticate — username+password or user_id+auth_key (body, not query params)
|
||||
POST /verify_password — verify a user's current password without changing it
|
||||
POST /{user_id}/change_password — change password (with optional current-password verification)
|
||||
GET /{user_id}/new_auth_key — generate a new one-time login auth key
|
||||
GET /{user_id}/email_auth_key_url — email a one-time login link to the user
|
||||
|
||||
Security improvements over legacy /user/* routes:
|
||||
- Credentials are in the POST body, never in query params (no URL logging exposure).
|
||||
- Uses V3 AccountContext (x-aether-api-key mandatory).
|
||||
- HTTPException for all error paths (native FastAPI status codes).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, status
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db_sql import redis_lookup_id_random, sql_select, sql_update
|
||||
from app.lib_general import secure_hash_string, verify_secure_hash_string
|
||||
from app.lib_general_v3 import AccountContext, get_account_context
|
||||
from app.methods.user_methods import email_user_auth_key_url, load_user_obj
|
||||
from app.models.common_field_schema import default_num_bytes
|
||||
from app.models.response_models import Resp_Body_Base, mk_resp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# --- Request Body Models ---
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=10, max_length=100)
|
||||
current_password: Optional[str] = Field(None, description="If provided, verified before applying the change.")
|
||||
|
||||
|
||||
class AuthenticateRequest(BaseModel):
|
||||
"""Provide either username+password or user_id+auth_key."""
|
||||
username: Optional[str] = Field(None, min_length=3, max_length=50)
|
||||
password: Optional[str] = Field(None, min_length=8, max_length=100)
|
||||
user_id: Optional[str] = Field(None, min_length=11, max_length=22, description="Vision ID (id_random) of the user.")
|
||||
auth_key: Optional[str] = Field(None, min_length=11, max_length=22)
|
||||
valid_email: Optional[bool] = Field(None, description="If True, marks email_verified=True on successful auth.")
|
||||
|
||||
|
||||
class VerifyPasswordRequest(BaseModel):
|
||||
"""Provide user_id (Vision ID) or username, plus the password to verify."""
|
||||
current_password: str = Field(..., min_length=1, max_length=100)
|
||||
user_id: Optional[str] = Field(None, min_length=11, max_length=22)
|
||||
username: Optional[str] = Field(None, min_length=2, max_length=50)
|
||||
|
||||
|
||||
# --- Internal Helper ---
|
||||
|
||||
def _check_user_enabled(rec: dict) -> Optional[str]:
|
||||
"""
|
||||
Returns an error message string if the user account is not currently active, None if OK.
|
||||
Checks: enable flag, enable_from, enable_to (all treated as UTC).
|
||||
"""
|
||||
if not rec.get('enable'):
|
||||
return 'This user account is not enabled.'
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if enable_from := rec.get('enable_from'):
|
||||
ef = enable_from.replace(tzinfo=datetime.timezone.utc)
|
||||
if ef > now:
|
||||
return f'This user account is not yet enabled (active from: {ef}).'
|
||||
if enable_to := rec.get('enable_to'):
|
||||
et = enable_to.replace(tzinfo=datetime.timezone.utc)
|
||||
if et < now:
|
||||
return f'This user account has expired (expired: {et}).'
|
||||
return None
|
||||
|
||||
|
||||
# --- Routes ---
|
||||
|
||||
@router.post('/authenticate', response_model=Resp_Body_Base)
|
||||
async def action_authenticate(
|
||||
body: AuthenticateRequest = Body(...),
|
||||
inc_user_role_list: bool = Query(False),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Authenticate a user by username+password or user_id+auth_key.
|
||||
|
||||
- Credentials are in the POST body (not query params) — safe from URL logging.
|
||||
- Auth key is one-time-use: cleared on successful authentication.
|
||||
- On success: stamps logged_in_on, returns the full user object.
|
||||
- Provide x-account-id to scope username lookups to the correct account.
|
||||
"""
|
||||
account_id = account.account_id
|
||||
|
||||
if body.username and body.password:
|
||||
sql = """
|
||||
SELECT id AS user_id, id_random AS user_id_random, password,
|
||||
enable, enable_from, enable_to
|
||||
FROM `user`
|
||||
WHERE account_id = :account_id AND username = :username
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
|
||||
if not rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found for this account and username.')
|
||||
if not rec.get('password'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No password is set for this user.')
|
||||
if not verify_secure_hash_string(string=body.password, string_hash=rec['password']):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Password did not match.')
|
||||
if err := _check_user_enabled(rec):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
|
||||
|
||||
db_user_id = rec['user_id']
|
||||
update_data = {'id': db_user_id, 'logged_in_on': datetime.datetime.utcnow()}
|
||||
if body.valid_email:
|
||||
update_data['email_verified'] = True
|
||||
sql_update(table_name='user', data=update_data)
|
||||
|
||||
elif body.user_id and body.auth_key:
|
||||
sql = """
|
||||
SELECT id AS user_id, id_random AS user_id_random, password,
|
||||
enable, enable_from, enable_to
|
||||
FROM `user`
|
||||
WHERE id_random = :user_id_random
|
||||
AND auth_key = :auth_key
|
||||
AND allow_auth_key = 1
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'user_id_random': body.user_id, 'auth_key': body.auth_key})
|
||||
if not rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User + auth key combination not found.')
|
||||
if err := _check_user_enabled(rec):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=err)
|
||||
|
||||
db_user_id = rec['user_id']
|
||||
# Auth key is one-time-use — clear it immediately.
|
||||
update_data = {'id': db_user_id, 'auth_key': None, 'logged_in_on': datetime.datetime.utcnow()}
|
||||
if body.valid_email:
|
||||
update_data['email_verified'] = True
|
||||
sql_update(table_name='user', data=update_data)
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide either username+password or user_id+auth_key.')
|
||||
|
||||
user_obj = load_user_obj(user_id=db_user_id, inc_user_role_list=inc_user_role_list)
|
||||
if not user_obj:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Authentication succeeded but user record could not be loaded.')
|
||||
|
||||
return mk_resp(data=user_obj.dict(by_alias=True), status_message='Authentication successful.')
|
||||
|
||||
|
||||
@router.post('/verify_password', response_model=Resp_Body_Base)
|
||||
async def action_verify_password(
|
||||
body: VerifyPasswordRequest = Body(...),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Verify a user's current password without changing it.
|
||||
|
||||
Provide user_id (Vision ID) or username + current_password.
|
||||
Returns data=True on match, 403 on mismatch.
|
||||
"""
|
||||
account_id = account.account_id
|
||||
|
||||
if body.user_id:
|
||||
sql = """
|
||||
SELECT id AS user_id, username, password
|
||||
FROM `user`
|
||||
WHERE id_random = :user_id_random
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'user_id_random': body.user_id})
|
||||
elif body.username:
|
||||
sql = """
|
||||
SELECT id AS user_id, username, password
|
||||
FROM `user`
|
||||
WHERE account_id = :account_id AND username = :username
|
||||
LIMIT 1
|
||||
"""
|
||||
rec = sql_select(sql=sql, data={'account_id': account_id, 'username': body.username})
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Provide user_id or username.')
|
||||
|
||||
if not rec:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
if not rec.get('password'):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='No password is set for this user.')
|
||||
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Password did not match.')
|
||||
|
||||
return mk_resp(data=True, status_message='Password verified.')
|
||||
|
||||
|
||||
@router.post('/{user_id}/change_password', response_model=Resp_Body_Base)
|
||||
async def action_change_password(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
body: ChangePasswordRequest = Body(...),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Change a user's password.
|
||||
|
||||
- new_password is required (min 10 chars).
|
||||
- If current_password is provided, it is verified before the change is applied.
|
||||
- Stamps password_set_on on success.
|
||||
"""
|
||||
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
|
||||
if not db_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
|
||||
if body.current_password:
|
||||
sql = "SELECT password FROM `user` WHERE id = :uid LIMIT 1"
|
||||
rec = sql_select(sql=sql, data={'uid': db_user_id})
|
||||
if not rec or not rec.get('password'):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='User not found or password not set.')
|
||||
if not verify_secure_hash_string(string=body.current_password, string_hash=rec['password']):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Current password is incorrect.')
|
||||
|
||||
update_data = {
|
||||
'id': db_user_id,
|
||||
'password': secure_hash_string(string=body.new_password),
|
||||
'password_set_on': datetime.datetime.utcnow(),
|
||||
}
|
||||
if not sql_update(table_name='user', data=update_data):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Password update failed.')
|
||||
|
||||
return mk_resp(data=True, status_message='Password changed successfully.')
|
||||
|
||||
|
||||
@router.get('/{user_id}/new_auth_key', response_model=Resp_Body_Base)
|
||||
async def action_new_auth_key(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
account: AccountContext = Depends(get_account_context),
|
||||
):
|
||||
"""
|
||||
Generate a new one-time-use auth key for the user.
|
||||
|
||||
The key is written to the DB and returned in the response body.
|
||||
The user record must have allow_auth_key=1 for the key to be usable
|
||||
with the /authenticate endpoint.
|
||||
"""
|
||||
import secrets
|
||||
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
|
||||
if not db_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
|
||||
new_key = secrets.token_urlsafe(default_num_bytes)
|
||||
update_data = {'id': db_user_id, 'auth_key': new_key}
|
||||
if not sql_update(table_name='user', data=update_data):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to write auth key.')
|
||||
|
||||
return mk_resp(data={'auth_key': new_key}, status_message='New auth key generated.')
|
||||
|
||||
|
||||
@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: 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),
|
||||
):
|
||||
"""
|
||||
Generate a new auth key and email a one-time login URL to the user.
|
||||
|
||||
root_url is the base URL the login link will be built from.
|
||||
key_param_name controls the query param name used for the auth key in the link (default: auth_key).
|
||||
Returns data=True on success (email sent), 500 if delivery failed.
|
||||
"""
|
||||
db_user_id = redis_lookup_id_random(record_id_random=user_id, table_name='user')
|
||||
if not db_user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found.')
|
||||
|
||||
result = email_user_auth_key_url(
|
||||
account_id=account.account_id,
|
||||
user_id=db_user_id,
|
||||
root_url=root_url,
|
||||
key_param_name=key_param_name,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Auth key email could not be sent. Check account email config and user enable status.')
|
||||
|
||||
return mk_resp(data=True, status_message='Auth key email sent.')
|
||||
@@ -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() ###
|
||||
@@ -24,119 +24,125 @@ router = APIRouter()
|
||||
|
||||
|
||||
# ### BEGIN ### API Data Store Routers ### post_data_store_obj() ###
|
||||
# LEGACY (disabled) - superseded by V3 CRUD: POST /v3/crud/data_store/
|
||||
# Updated 2026-01-28
|
||||
@router.post('/data_store', response_model=Resp_Body_Base)
|
||||
async def post_data_store_obj(
|
||||
data_store_obj: Data_Store_Base,
|
||||
|
||||
return_obj: bool = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
# None
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if data_store_id := create_update_data_store_obj(
|
||||
data_store_dict_obj = data_store_obj,
|
||||
): pass
|
||||
else:
|
||||
log.warning('Likely bad request')
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not created. Something failed while processing the data. Check the field names and data types.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
data_store_obj = load_data_store_obj(
|
||||
data_store_id = data_store_id,
|
||||
)
|
||||
data = data_store_obj
|
||||
else:
|
||||
data_store_id_random = get_id_random(record_id=data_store_id, table_name='data_store')
|
||||
data = {}
|
||||
data['data_store_id'] = data_store_id
|
||||
data['data_store_id_random'] = data_store_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# @router.post('/data_store', response_model=Resp_Body_Base)
|
||||
# async def post_data_store_obj(
|
||||
# data_store_obj: Data_Store_Base,
|
||||
#
|
||||
# return_obj: bool = True,
|
||||
#
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
#
|
||||
# # ### SECTION ### Secondary data validation
|
||||
# # None
|
||||
#
|
||||
# # ### SECTION ### Process data
|
||||
# if data_store_id := create_update_data_store_obj(
|
||||
# data_store_dict_obj = data_store_obj,
|
||||
# ): pass
|
||||
# else:
|
||||
# log.warning('Likely bad request')
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not created. Something failed while processing the data. Check the field names and data types.') # Bad Request
|
||||
#
|
||||
# # ### SECTION ### Return successful results
|
||||
# if return_obj:
|
||||
# data_store_obj = load_data_store_obj(
|
||||
# data_store_id = data_store_id,
|
||||
# )
|
||||
# data = data_store_obj
|
||||
# else:
|
||||
# data_store_id_random = get_id_random(record_id=data_store_id, table_name='data_store')
|
||||
# data = {}
|
||||
# data['data_store_id'] = data_store_id
|
||||
# data['data_store_id_random'] = data_store_id_random
|
||||
# return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Data Store Routers ### post_data_store_obj() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Data Store Routers ### patch_data_store_obj() ###
|
||||
# LEGACY (disabled) - superseded by V3 CRUD: PATCH /v3/crud/data_store/{id}
|
||||
# Updated 2022-03-11
|
||||
@router.patch('/data_store/{data_store_id}', response_model=Resp_Body_Base)
|
||||
async def patch_data_store_obj(
|
||||
data_store_obj: Data_Store_Base,
|
||||
data_store_id: str = Path(min_length=11, max_length=22),
|
||||
|
||||
return_obj: Optional[bool] = True,
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
data_store_id_random = data_store_id # This is used later for the response data
|
||||
if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The Data Store ID was invalid or not found.')
|
||||
|
||||
# ### SECTION ### Process data
|
||||
if data_store_up_result := create_update_data_store_obj(
|
||||
data_store_dict_obj = data_store_obj,
|
||||
data_store_id = data_store_id,
|
||||
): pass
|
||||
else:
|
||||
log.warning('Likely bad request')
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not updated. Something failed while processing the data. Check the field names and data types.') # Bad Request
|
||||
|
||||
# ### SECTION ### Return successful results
|
||||
if return_obj:
|
||||
data_store_obj = load_data_store_obj(
|
||||
data_store_id = data_store_id,
|
||||
)
|
||||
data = data_store_obj
|
||||
else:
|
||||
data = {}
|
||||
data['data_store_id'] = data_store_id
|
||||
data['data_store_id_random'] = data_store_id_random
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
# @router.patch('/data_store/{data_store_id}', response_model=Resp_Body_Base)
|
||||
# async def patch_data_store_obj(
|
||||
# data_store_obj: Data_Store_Base,
|
||||
# data_store_id: str = Path(min_length=11, max_length=22),
|
||||
#
|
||||
# return_obj: Optional[bool] = True,
|
||||
#
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
#
|
||||
# # ### SECTION ### Secondary data validation
|
||||
# data_store_id_random = data_store_id # This is used later for the response data
|
||||
# if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
|
||||
# else: return mk_resp(data=None, status_code=404, response=commons.response, status_message='The Data Store ID was invalid or not found.')
|
||||
#
|
||||
# # ### SECTION ### Process data
|
||||
# if data_store_up_result := create_update_data_store_obj(
|
||||
# data_store_dict_obj = data_store_obj,
|
||||
# data_store_id = data_store_id,
|
||||
# ): pass
|
||||
# else:
|
||||
# log.warning('Likely bad request')
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response, status_message='Not updated. Something failed while processing the data. Check the field names and data types.') # Bad Request
|
||||
#
|
||||
# # ### SECTION ### Return successful results
|
||||
# if return_obj:
|
||||
# data_store_obj = load_data_store_obj(
|
||||
# data_store_id = data_store_id,
|
||||
# )
|
||||
# data = data_store_obj
|
||||
# else:
|
||||
# data = {}
|
||||
# data['data_store_id'] = data_store_id
|
||||
# data['data_store_id_random'] = data_store_id_random
|
||||
# return mk_resp(data=data, response=commons.response)
|
||||
# ### END ### API Data Store Routers ### patch_data_store_obj() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Data Store ### get_data_store_obj() ###
|
||||
# LEGACY (disabled) - superseded by V3 CRUD: GET /v3/crud/data_store/{id}
|
||||
# Updated 2026-01-28
|
||||
@router.get('/data_store/{data_store_id}', response_model=Resp_Body_Base)
|
||||
async def get_data_store_obj(
|
||||
data_store_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())
|
||||
|
||||
# ### SECTION ### Secondary data validation
|
||||
if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
if data_store_rec_result := load_data_store_obj(
|
||||
data_store_id = data_store_id,
|
||||
limit = commons.limit,
|
||||
enabled = commons.enabled,
|
||||
):
|
||||
log.info('Loading successful. Returning result')
|
||||
return mk_resp(data=data_store_rec_result, response=commons.response)
|
||||
elif isinstance(data_store_rec_result, list) or data_store_rec_result is None: # Empty list or None
|
||||
log.info('No results')
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
log.warning('Likely bad request')
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# @router.get('/data_store/{data_store_id}', response_model=Resp_Body_Base)
|
||||
# async def get_data_store_obj(
|
||||
# data_store_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())
|
||||
#
|
||||
# # ### SECTION ### Secondary data validation
|
||||
# if data_store_id := redis_lookup_id_random(record_id_random=data_store_id, table_name='data_store'): pass
|
||||
# else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
#
|
||||
# if data_store_rec_result := load_data_store_obj(
|
||||
# data_store_id = data_store_id,
|
||||
# limit = commons.limit,
|
||||
# enabled = commons.enabled,
|
||||
# ):
|
||||
# log.info('Loading successful. Returning result')
|
||||
# return mk_resp(data=data_store_rec_result, response=commons.response)
|
||||
# elif isinstance(data_store_rec_result, list) or data_store_rec_result is None: # Empty list or None
|
||||
# log.info('No results')
|
||||
# return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
# else:
|
||||
# log.warning('Likely bad request')
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# ### END ### API Data Store ### get_data_store_obj() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API Data Store ### get_v3_data_store_obj_w_code() ###
|
||||
# NEW V3 Endpoint for Code Lookup
|
||||
# TODO: Migrate to a dedicated api_v3_actions_data_store.py router and rename path to
|
||||
# /v3/action/data_store/code/{data_store_code} to match the V3 action naming convention.
|
||||
# Requires a coordinated frontend update before the path rename can happen.
|
||||
# Updated 2026-01-28
|
||||
@router.get('/v3/data_store/code/{data_store_code}', response_model=Resp_Body_Base, tags=['Data Store V3'])
|
||||
async def get_v3_data_store_obj_w_code(
|
||||
@@ -156,13 +162,13 @@ async def get_v3_data_store_obj_w_code(
|
||||
Returns a single object if limit=1, otherwise returns a list.
|
||||
"""
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
|
||||
# Map V3 params to the shared handler
|
||||
v3_commons = Common_Route_Params(
|
||||
x_account_id=account.account_id,
|
||||
x_account_id_random=account.account_id_random,
|
||||
enabled=status_filter.enabled,
|
||||
response=Response()
|
||||
response=Response()
|
||||
)
|
||||
|
||||
return await handle_get_data_store_obj_w_code(
|
||||
@@ -177,57 +183,60 @@ async def get_v3_data_store_obj_w_code(
|
||||
|
||||
|
||||
# ### BEGIN ### API Data Store ### get_data_store_obj_w_code() ###
|
||||
# NOTE: Adding some explanation because this is not quickly obvious how it fully works.
|
||||
# The look up order starts with a required data_store_code. Then the first result that matches the most specific method. The for_type and for_id fields are not required. I think it makes the most sense to be a part of the URL path, not the GET params. Either should work with no problem though.
|
||||
# LEGACY (disabled) - legacy code-based lookup; use GET /v3/data_store/code/{code} instead.
|
||||
# NOTE: The look up order starts with a required data_store_code. Then the first result that matches the most specific method. The for_type and for_id fields are not required. I think it makes the most sense to be a part of the URL path, not the GET params. Either should work with no problem though.
|
||||
# Lookup using: for_type and for_id > account_id > data_store_code
|
||||
# This is a nice way to have global default data along with account and object specific data.
|
||||
# Updated 2023-05-22
|
||||
|
||||
|
||||
@router.get('/data_store/code/{data_store_code}/{for_type}/{for_id}', response_model=Resp_Body_Base)
|
||||
async def get_data_store_obj_w_code_path(
|
||||
data_store_code: str = Path(min_length=3, max_length=50),
|
||||
for_type: Optional[str] = Path(min_length=1, max_length=25),
|
||||
for_id: Optional[str] = Path(min_length=11, max_length=22),
|
||||
limit: int = Query(1, ge=1),
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
log.info('Using path parameters')
|
||||
# ### SECTION ### Call generic function to get the data_store object
|
||||
return await handle_get_data_store_obj_w_code(
|
||||
data_store_code = data_store_code,
|
||||
for_type = for_type,
|
||||
for_id = for_id,
|
||||
commons = commons,
|
||||
limit = limit,
|
||||
)
|
||||
# @router.get('/data_store/code/{data_store_code}/{for_type}/{for_id}', response_model=Resp_Body_Base)
|
||||
# async def get_data_store_obj_w_code_path(
|
||||
# data_store_code: str = Path(min_length=3, max_length=50),
|
||||
# for_type: Optional[str] = Path(min_length=1, max_length=25),
|
||||
# for_id: Optional[str] = Path(min_length=11, max_length=22),
|
||||
# limit: int = Query(1, ge=1),
|
||||
#
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
# log.info('Using path parameters')
|
||||
# # ### SECTION ### Call generic function to get the data_store object
|
||||
# return await handle_get_data_store_obj_w_code(
|
||||
# data_store_code = data_store_code,
|
||||
# for_type = for_type,
|
||||
# for_id = for_id,
|
||||
# commons = commons,
|
||||
# limit = limit,
|
||||
# )
|
||||
|
||||
|
||||
@router.get('/data_store/code/{data_store_code}', response_model=Resp_Body_Base)
|
||||
async def get_data_store_obj_w_code_query(
|
||||
data_store_code: str = Path(min_length=3, max_length=50),
|
||||
for_type: Optional[str] = Query(None, min_length=1, max_length=25),
|
||||
for_id: Optional[str] = Query(None, min_length=11, max_length=22),
|
||||
limit: int = Query(1, ge=1),
|
||||
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
log.info('Using query parameters')
|
||||
# ### SECTION ### Call generic function to get the data_store object
|
||||
return await handle_get_data_store_obj_w_code(
|
||||
data_store_code = data_store_code,
|
||||
for_type = for_type,
|
||||
for_id = for_id,
|
||||
commons = commons,
|
||||
limit = limit,
|
||||
)
|
||||
# @router.get('/data_store/code/{data_store_code}', response_model=Resp_Body_Base)
|
||||
# async def get_data_store_obj_w_code_query(
|
||||
# data_store_code: str = Path(min_length=3, max_length=50),
|
||||
# for_type: Optional[str] = Query(None, min_length=1, max_length=25),
|
||||
# for_id: Optional[str] = Query(None, min_length=11, max_length=22),
|
||||
# limit: int = Query(1, ge=1),
|
||||
#
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
# log.info('Using query parameters')
|
||||
# # ### SECTION ### Call generic function to get the data_store object
|
||||
# return await handle_get_data_store_obj_w_code(
|
||||
# data_store_code = data_store_code,
|
||||
# for_type = for_type,
|
||||
# for_id = for_id,
|
||||
# commons = commons,
|
||||
# limit = limit,
|
||||
# )
|
||||
# ### END ### API Data Store ### get_data_store_obj_w_code() ###
|
||||
|
||||
|
||||
# TODO: Migrate to a dedicated api_v3_actions_data_store.py router and rename path to
|
||||
# /v3/action/data_store/code/{data_store_code}/search to match the V3 action naming convention.
|
||||
# Requires a coordinated frontend update before the path rename can happen.
|
||||
@router.post('/v3/data_store/code/{data_store_code}/search', response_model=Resp_Body_Base, tags=['Data Store V3'])
|
||||
async def search_v3_data_store_obj_w_code(
|
||||
data_store_code: str,
|
||||
@@ -256,18 +265,18 @@ async def search_v3_data_store_obj_w_code(
|
||||
# 2. Construct the hierarchical search SQL
|
||||
# We must enforce that users only see their own account records OR global defaults (account_id IS NULL)
|
||||
from app.db_sql import sql_enable_part, sql_hidden_part, sql_search_qry_part, sql_limit_offset_part
|
||||
|
||||
|
||||
sql_enabled, data_enabled = sql_enable_part('data_store', status_filter.enabled)
|
||||
sql_hidden, data_hidden = sql_hidden_part('data_store', status_filter.hidden)
|
||||
|
||||
|
||||
# Generate search logic from the SearchQuery model
|
||||
search_sql, search_data = sql_search_qry_part(
|
||||
search_query=search_query,
|
||||
search_query=search_query,
|
||||
table_name='v_data_store'
|
||||
)
|
||||
|
||||
|
||||
sql_limit = sql_limit_offset_part(limit=pagination.limit, offset=pagination.offset)
|
||||
|
||||
|
||||
# Prepare parameter dictionary
|
||||
data = {
|
||||
'code': data_store_code,
|
||||
@@ -342,11 +351,11 @@ async def handle_get_data_store_obj_w_code(
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.info(f'Loading successful. Returning {len(data_store_obj_result)} result(s)')
|
||||
|
||||
|
||||
# If limit=1, return the first object directly (standard lookup behavior)
|
||||
# If limit > 1, return the list of results
|
||||
data = data_store_obj_result[0] if limit == 1 else data_store_obj_result
|
||||
|
||||
|
||||
log.debug(data)
|
||||
return mk_resp(data=data, response=commons.response)
|
||||
elif isinstance(data_store_obj_result, list) or data_store_obj_result is None: # Empty list or None
|
||||
@@ -359,43 +368,44 @@ async def handle_get_data_store_obj_w_code(
|
||||
|
||||
|
||||
# ### BEGIN ### API Data Store ### get_account_obj_data_store_list() ###
|
||||
# LEGACY (disabled) - superseded by V3 CRUD search: POST /v3/crud/data_store/search
|
||||
# Updated 2022-03-11
|
||||
@router.get('/account/{account_id}/data_store/list', response_model=Resp_Body_Base)
|
||||
async def get_account_obj_data_store_list(
|
||||
account_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 account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
# Updated 2022-03-11
|
||||
if data_store_rec_list_result := get_data_store_rec_list(
|
||||
account_id = account_id,
|
||||
for_type = 'account',
|
||||
for_id = account_id,
|
||||
enabled = commons.enabled,
|
||||
limit = commons.limit,
|
||||
offset = commons.offset,
|
||||
):
|
||||
data_store_result_list = []
|
||||
for data_store_rec in data_store_rec_list_result:
|
||||
if load_data_store_result := load_data_store_obj(
|
||||
data_store_id = data_store_rec.get('data_store_id', None),
|
||||
enabled = commons.enabled,
|
||||
):
|
||||
data_store_result_list.append(load_data_store_result)
|
||||
else:
|
||||
data_store_result_list.append(None)
|
||||
response_data = data_store_result_list
|
||||
return mk_resp(data=response_data, response=commons.response)
|
||||
elif isinstance(data_store_rec_list_result, list) or data_store_rec_list_result is None: # Empty list or None
|
||||
log.info('No results')
|
||||
return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
log.warning('Likely bad request')
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# ### END ### API Data Store ### get_account_obj_data_store_list() ###
|
||||
# @router.get('/account/{account_id}/data_store/list', response_model=Resp_Body_Base)
|
||||
# async def get_account_obj_data_store_list(
|
||||
# account_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 account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
|
||||
# else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
#
|
||||
# # Updated 2022-03-11
|
||||
# if data_store_rec_list_result := get_data_store_rec_list(
|
||||
# account_id = account_id,
|
||||
# for_type = 'account',
|
||||
# for_id = account_id,
|
||||
# enabled = commons.enabled,
|
||||
# limit = commons.limit,
|
||||
# offset = commons.offset,
|
||||
# ):
|
||||
# data_store_result_list = []
|
||||
# for data_store_rec in data_store_rec_list_result:
|
||||
# if load_data_store_result := load_data_store_obj(
|
||||
# data_store_id = data_store_rec.get('data_store_id', None),
|
||||
# enabled = commons.enabled,
|
||||
# ):
|
||||
# data_store_result_list.append(load_data_store_result)
|
||||
# else:
|
||||
# data_store_result_list.append(None)
|
||||
# response_data = data_store_result_list
|
||||
# return mk_resp(data=response_data, response=commons.response)
|
||||
# elif isinstance(data_store_rec_list_result, list) or data_store_rec_list_result is None: # Empty list or None
|
||||
# log.info('No results')
|
||||
# return mk_resp(data=None, status_code=404, response=commons.response) # Not Found
|
||||
# else:
|
||||
# log.warning('Likely bad request')
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# ### END ### API Data Store ### get_account_obj_data_store_list() ###
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from fastapi import FastAPI, Depends
|
||||
from app.routers.dependencies_v3 import DeprecationParams
|
||||
from app.routers import (
|
||||
ae_obj, aether_cfg, api_crud, api_crud_v2, api_crud_v3, api, health, importing, sql,
|
||||
account, contact, data_store,
|
||||
event, event_badge, event_badge_importing, event_badge_template,
|
||||
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
||||
event_location, event_person,
|
||||
event_presentation, event_presenter, event_session,
|
||||
flask_cfg, hosted_file, 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, lookup, lookup_v3,
|
||||
organization, page, person,
|
||||
person_user, qr, site, site_domain, user,
|
||||
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
||||
ae_obj, aether_cfg, api_crud_v3, api, health, importing,
|
||||
data_store,
|
||||
event_badge_importing,
|
||||
event_importing,
|
||||
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
|
||||
)
|
||||
|
||||
def setup_routers(app: FastAPI):
|
||||
@@ -21,13 +19,13 @@ def setup_routers(app: FastAPI):
|
||||
app.include_router(ae_obj.router, prefix='/ae_obj', tags=['AE Object'])
|
||||
app.include_router(aether_cfg.router, tags=['Aether Config'])
|
||||
# app.include_router(api_crud.router, prefix='/crud', tags=['CRUD v1.2 (Legacy)'], dependencies=[Depends(DeprecationParams)])
|
||||
app.include_router(api_crud_v2.router, prefix='/v2/crud', tags=['CRUD v2.5'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(api_crud_v2.router, prefix='/v2/crud', tags=['CRUD v2.5'], dependencies=[Depends(DeprecationParams)])
|
||||
app.include_router(api_crud_v3.router, prefix='/v3/crud', tags=['CRUD v3'])
|
||||
|
||||
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'])
|
||||
app.include_router(sql.router, tags=['SQL'])
|
||||
# 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)])
|
||||
|
||||
app.include_router(data_store.router, tags=['Data Store'])
|
||||
@@ -38,8 +36,8 @@ def setup_routers(app: FastAPI):
|
||||
|
||||
# app.include_router(event_device.router, tags=['Event Device'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(event_exhibit.router, tags=['Event Exhibit'], dependencies=[Depends(DeprecationParams)])
|
||||
app.include_router(event_exhibit_tracking.router, tags=['Event Exhibit Tracking'])
|
||||
app.include_router(event_file.router, tags=['Event File'])
|
||||
# app.include_router(event_exhibit_tracking.router, tags=['Event Exhibit Tracking'])
|
||||
# app.include_router(event_file.router, tags=['Event File'])
|
||||
app.include_router(event_importing.router, tags=['Event Importing'])
|
||||
# app.include_router(event_location.router, tags=['Event Location'], dependencies=[Depends(DeprecationParams)])
|
||||
|
||||
@@ -47,13 +45,16 @@ def setup_routers(app: FastAPI):
|
||||
# app.include_router(event_presenter.router, prefix='/event/presenter', tags=['Event Presenter'], dependencies=[Depends(DeprecationParams)])
|
||||
# app.include_router(event_session.router, tags=['Event Session'], dependencies=[Depends(DeprecationParams)])
|
||||
|
||||
app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
|
||||
# app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
|
||||
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
|
||||
app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
|
||||
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'])
|
||||
|
||||
# app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)])
|
||||
@@ -64,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'])
|
||||
app.include_router(util_email.router, tags=['Utility: Email'])
|
||||
app.include_router(websockets.router, tags=['Websockets'])
|
||||
app.include_router(websockets_redis.router, tags=['Websockets (Redis)'])
|
||||
# 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
|
||||
|
||||
@@ -20,65 +20,67 @@ from app.models.user_models import User_Base, User_New_Base, User_Out_Base
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/user', response_model=Resp_Body_Base)
|
||||
async def post_user_obj(
|
||||
obj: User_Base,
|
||||
return_obj: Optional[bool] = True,
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
# @router.post('/user', response_model=Resp_Body_Base)
|
||||
# async def post_user_obj(
|
||||
# obj: User_Base,
|
||||
# return_obj: Optional[bool] = True,
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
|
||||
obj_type = 'user'
|
||||
obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
|
||||
result = post_obj_template(
|
||||
obj_type = obj_type,
|
||||
data = obj_data_dict,
|
||||
return_obj = True,
|
||||
by_alias = True,
|
||||
exclude_unset = True,
|
||||
)
|
||||
return result
|
||||
# obj_type = 'user'
|
||||
# obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
|
||||
# result = post_obj_template(
|
||||
# obj_type = obj_type,
|
||||
# data = obj_data_dict,
|
||||
# return_obj = True,
|
||||
# by_alias = True,
|
||||
# exclude_unset = True,
|
||||
# )
|
||||
# return result
|
||||
|
||||
|
||||
# ### BEGIN ### API User ### post_user_obj_new() ###
|
||||
# Updated 2021-08-21 (complete re-write)
|
||||
@router.post('/user/new', response_model=Resp_Body_Base)
|
||||
async def post_user_obj_new(
|
||||
user_obj: User_New_Base,
|
||||
allow_update: bool = False,
|
||||
avoid_dup_username: bool = False,
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
# # ### BEGIN ### API User ### post_user_obj_new() ###
|
||||
# # Updated 2021-08-21 (complete re-write)
|
||||
# @router.post('/user/new', response_model=Resp_Body_Base)
|
||||
# async def post_user_obj_new(
|
||||
# user_obj: User_New_Base,
|
||||
# allow_update: bool = False,
|
||||
# avoid_dup_username: bool = False,
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
|
||||
if account_id_random := user_obj.account_id_random: pass
|
||||
else: return False
|
||||
# if account_id_random := user_obj.account_id_random: pass
|
||||
# else: return False
|
||||
|
||||
if create_user_obj_result := create_user_obj(account_id=account_id_random, user_dict_obj=user_obj, allow_update=allow_update, avoid_dup_username=avoid_dup_username): pass
|
||||
else: return mk_resp(data=False, status_code=400, response=commons.response, status_message='The user account was not created. This is likely because that username already exists for this account.')
|
||||
# if create_user_obj_result := create_user_obj(account_id=account_id_random, user_dict_obj=user_obj, allow_update=allow_update, avoid_dup_username=avoid_dup_username): pass
|
||||
# else: return mk_resp(data=False, status_code=400, response=commons.response, status_message='The user account was not created. This is likely because that username already exists for this account.')
|
||||
|
||||
if isinstance(create_user_obj_result, int):
|
||||
user_id = create_user_obj_result
|
||||
if return_obj:
|
||||
if load_user_obj_result := load_user_obj(user_id=user_id):
|
||||
data = load_user_obj_result
|
||||
else:
|
||||
data = False
|
||||
else:
|
||||
user_id = create_user_obj_result
|
||||
user_id_random = get_id_random(record_id=user_id, table_name='user')
|
||||
data = {}
|
||||
data['user_id'] = user_id
|
||||
data['user_id_random'] = user_id_random
|
||||
return mk_resp(data=data, response=commons.response, status_message='The user account was created.')
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response, status_message='The result from trying to create a user account was unexpected.')
|
||||
# ### END ### API User ### post_user_obj_new() ###
|
||||
# if isinstance(create_user_obj_result, int):
|
||||
# user_id = create_user_obj_result
|
||||
# if return_obj:
|
||||
# if load_user_obj_result := load_user_obj(user_id=user_id):
|
||||
# data = load_user_obj_result
|
||||
# else:
|
||||
# data = False
|
||||
# else:
|
||||
# user_id = create_user_obj_result
|
||||
# user_id_random = get_id_random(record_id=user_id, table_name='user')
|
||||
# data = {}
|
||||
# data['user_id'] = user_id
|
||||
# data['user_id_random'] = user_id_random
|
||||
# return mk_resp(data=data, response=commons.response, status_message='The user account was created.')
|
||||
# else:
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response, status_message='The result from trying to create a user account was unexpected.')
|
||||
# # ### END ### API User ### post_user_obj_new() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API User ### user_obj_change_password() ###
|
||||
# NOTE: This is actively in use 2026-03-24 -Scott
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.patch('/user/{user_id}/change_password', response_model=Resp_Body_Base)
|
||||
async def user_obj_change_password(
|
||||
user_id: Union[int,str],
|
||||
@@ -143,35 +145,37 @@ async def user_obj_change_password(
|
||||
# ### END ### API User ### user_obj_change_password() ###
|
||||
|
||||
|
||||
@router.patch('/user/{obj_id}', response_model=Resp_Body_Base)
|
||||
async def patch_user_obj(
|
||||
obj: User_Base,
|
||||
obj_id: str = Path(min_length=11, max_length=22),
|
||||
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())
|
||||
# @router.patch('/user/{obj_id}', response_model=Resp_Body_Base)
|
||||
# async def patch_user_obj(
|
||||
# obj: User_Base,
|
||||
# obj_id: str = Path(min_length=11, max_length=22),
|
||||
# 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())
|
||||
|
||||
obj_type = 'user'
|
||||
obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
|
||||
obj_data_dict['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_type)
|
||||
obj_data_dict['id_random'] = obj_id
|
||||
result = patch_obj_template(
|
||||
obj_type=obj_type,
|
||||
data=obj_data_dict,
|
||||
obj_id=obj_id,
|
||||
return_obj=True,
|
||||
by_alias=True,
|
||||
exclude_unset=True,
|
||||
)
|
||||
return result
|
||||
# obj_type = 'user'
|
||||
# obj_data_dict = obj.dict(by_alias=False, exclude_unset=True)
|
||||
# obj_data_dict['id'] = redis_lookup_id_random(record_id_random=obj_id, table_name=obj_type)
|
||||
# obj_data_dict['id_random'] = obj_id
|
||||
# result = patch_obj_template(
|
||||
# obj_type=obj_type,
|
||||
# data=obj_data_dict,
|
||||
# obj_id=obj_id,
|
||||
# return_obj=True,
|
||||
# by_alias=True,
|
||||
# exclude_unset=True,
|
||||
# )
|
||||
# return result
|
||||
|
||||
|
||||
# ### BEGIN ### API User Routers ### user_new_auth_key() ###
|
||||
# Generate a new one time use authorization key for login without password
|
||||
# Updated 2022-01-07
|
||||
# @router.get('/user/new_auth_key', response_model=Resp_Body_Base)
|
||||
# NOTE: This may be actively in use 2026-03-24
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.get('/user/{user_id}/new_auth_key', response_model=Resp_Body_Base)
|
||||
async def user_new_auth_key(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
@@ -218,6 +222,8 @@ async def user_new_auth_key(
|
||||
# A new key will need to be requested for a particular user each time.
|
||||
# NOTE: Should this be divided into username/password and user ID/auth key endpoints? Probably vote 2x
|
||||
# Updated 2021-10-06
|
||||
# NOTE: This is actively in use 2026-03-24 -Scott
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.get('/user/authenticate', response_model=Resp_Body_Base)
|
||||
async def user_authenticate(
|
||||
null_account_id: bool = False,
|
||||
@@ -394,6 +400,8 @@ async def user_authenticate(
|
||||
|
||||
|
||||
# ### BEGIN ### API User ### user_verify_password() ###
|
||||
# NOTE: This may be actively in use 2026-03-24
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
# @router.post('/{user_id}/verify_password', response_model=Resp_Body_Base)
|
||||
@router.post('/user/verify_password', response_model=Resp_Body_Base)
|
||||
async def user_verify_password(
|
||||
@@ -410,14 +418,14 @@ async def user_verify_password(
|
||||
account_id = commons.x_account_id
|
||||
|
||||
log.debug(user_obj)
|
||||
log.debug(user_obj.id_random)
|
||||
log.debug(user_obj.id)
|
||||
log.debug(user_obj.current_password)
|
||||
log.debug(user_obj.username)
|
||||
|
||||
if current_password := user_obj.current_password: pass
|
||||
else: return mk_resp(data=False, status_code=400, status_message='The current password to verify is required.', response=commons.response) # Bad Request
|
||||
|
||||
if user_id_random := user_obj.id_random: # Use id_random instead of user_id_random when getting from User model.
|
||||
if user_id_random := user_obj.id: # Vision ID: User_Base uses 'id' (not 'id_random') for the random string.
|
||||
log.info(f'Using the user ID to look up the user. User ID: {user_id_random}')
|
||||
# NOTE: Not doing a redis lookup since we have to look up the record again. Redis lookup may save or add an insignificant amount of time.
|
||||
user_data = {}
|
||||
@@ -487,82 +495,84 @@ async def user_verify_password(
|
||||
# ### END ### API User ### user_verify_password() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API User ### get_account_user_obj_li() ###
|
||||
# Updated 2021-12-13
|
||||
@router.get('/account/{account_id}/user/list', response_model=Resp_Body_Base)
|
||||
async def get_account_user_obj_li(
|
||||
account_id: str = Path(min_length=11, max_length=22),
|
||||
hidden: str = 'not_hidden', # hidden, not_hidden, all
|
||||
inc_address: bool = False, # Priority l1
|
||||
inc_contact: bool = False, # Priority l1
|
||||
inc_person: bool = False, # Priority l1
|
||||
inc_user_role_list: bool = False, # Priority l1
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
# # ### BEGIN ### API User ### get_account_user_obj_li() ###
|
||||
# # Updated 2021-12-13
|
||||
# @router.get('/account/{account_id}/user/list', response_model=Resp_Body_Base)
|
||||
# async def get_account_user_obj_li(
|
||||
# account_id: str = Path(min_length=11, max_length=22),
|
||||
# hidden: str = 'not_hidden', # hidden, not_hidden, all
|
||||
# inc_address: bool = False, # Priority l1
|
||||
# inc_contact: bool = False, # Priority l1
|
||||
# inc_person: bool = False, # Priority l1
|
||||
# inc_user_role_list: bool = False, # Priority l1
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.INFO) # 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 mk_resp(data=None, status_code=404, response=commons.response)
|
||||
# if account_id := redis_lookup_id_random(record_id_random=account_id, table_name='account'): pass
|
||||
# else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
# Updated 2021-12-13
|
||||
if user_rec_list_result := get_user_rec_list(
|
||||
account_id = account_id,
|
||||
hidden = hidden, # hidden, not_hidden, all
|
||||
enabled = commons.enabled,
|
||||
limit = commons.limit,
|
||||
):
|
||||
user_result_list = []
|
||||
for user_rec in user_rec_list_result:
|
||||
if load_user_result := load_user_obj(
|
||||
user_id = user_rec.get('user_id', None),
|
||||
enabled = commons.enabled,
|
||||
# hidden = hidden,
|
||||
limit = commons.limit,
|
||||
inc_address = inc_address,
|
||||
inc_contact = inc_contact,
|
||||
inc_person = inc_person,
|
||||
inc_user_role_list = inc_user_role_list,
|
||||
by_alias = commons.by_alias,
|
||||
exclude_unset = commons.exclude_unset,
|
||||
# model_as_dict = model_as_dict,
|
||||
):
|
||||
user_result_list.append(load_user_result)
|
||||
else:
|
||||
user_result_list.append(None)
|
||||
response_data = user_result_list
|
||||
elif isinstance(user_rec_list_result, list) or user_rec_list_result is None: # Empty list or None
|
||||
log.info('No results')
|
||||
return mk_resp(data=False, status_code=404, response=commons.response) # Not Found
|
||||
else:
|
||||
log.warning('Likely bad request')
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# # Updated 2021-12-13
|
||||
# if user_rec_list_result := get_user_rec_list(
|
||||
# account_id = account_id,
|
||||
# hidden = hidden, # hidden, not_hidden, all
|
||||
# enabled = commons.enabled,
|
||||
# limit = commons.limit,
|
||||
# ):
|
||||
# user_result_list = []
|
||||
# for user_rec in user_rec_list_result:
|
||||
# if load_user_result := load_user_obj(
|
||||
# user_id = user_rec.get('user_id', None),
|
||||
# enabled = commons.enabled,
|
||||
# # hidden = hidden,
|
||||
# limit = commons.limit,
|
||||
# inc_address = inc_address,
|
||||
# inc_contact = inc_contact,
|
||||
# inc_person = inc_person,
|
||||
# inc_user_role_list = inc_user_role_list,
|
||||
# by_alias = commons.by_alias,
|
||||
# exclude_unset = commons.exclude_unset,
|
||||
# # model_as_dict = model_as_dict,
|
||||
# ):
|
||||
# user_result_list.append(load_user_result)
|
||||
# else:
|
||||
# user_result_list.append(None)
|
||||
# response_data = user_result_list
|
||||
# elif isinstance(user_rec_list_result, list) or user_rec_list_result is None: # Empty list or None
|
||||
# log.info('No results')
|
||||
# return mk_resp(data=False, status_code=404, response=commons.response) # Not Found
|
||||
# else:
|
||||
# log.warning('Likely bad request')
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
|
||||
return mk_resp(data=response_data, response=commons.response)
|
||||
# ### END ### API User ### get_account_user_obj_li() ###
|
||||
# return mk_resp(data=response_data, response=commons.response)
|
||||
# # ### END ### API User ### get_account_user_obj_li() ###
|
||||
|
||||
|
||||
@router.get('/user/list', response_model=Resp_Body_Base)
|
||||
async def get_user_obj_li(
|
||||
for_obj_type: Optional[str] = Query(None, min_length=2, max_length=50),
|
||||
for_obj_id: Optional[str] = Query(None, min_length=1, max_length=22),
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
# @router.get('/user/list', response_model=Resp_Body_Base)
|
||||
# async def get_user_obj_li(
|
||||
# for_obj_type: Optional[str] = Query(None, min_length=2, max_length=50),
|
||||
# for_obj_id: Optional[str] = Query(None, min_length=1, max_length=22),
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
|
||||
obj_type = 'user'
|
||||
result = get_obj_li_template(
|
||||
obj_type=obj_type,
|
||||
for_obj_type=for_obj_type,
|
||||
for_obj_id=for_obj_id,
|
||||
by_alias=True,
|
||||
exclude_unset=True,
|
||||
)
|
||||
return result
|
||||
# obj_type = 'user'
|
||||
# result = get_obj_li_template(
|
||||
# obj_type=obj_type,
|
||||
# for_obj_type=for_obj_type,
|
||||
# for_obj_id=for_obj_id,
|
||||
# by_alias=True,
|
||||
# exclude_unset=True,
|
||||
# )
|
||||
# return result
|
||||
|
||||
|
||||
# Look up is only for account or person records
|
||||
# NOTE: This may be actively in use 2026-03-24
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.get('/user/lookup', response_model=Resp_Body_Base)
|
||||
async def lookup_user_obj(
|
||||
for_obj_id: Union[int,str],
|
||||
@@ -638,6 +648,8 @@ async def lookup_user_obj(
|
||||
|
||||
|
||||
# Look up a user with an email address for an account
|
||||
# NOTE: This is actively in use 2026-03-24 -Scott
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.get('/user/lookup_email', response_model=Resp_Body_Base)
|
||||
async def lookup_email(
|
||||
email: str = Query(..., min_length=2, max_length=50),
|
||||
@@ -728,6 +740,8 @@ async def lookup_email(
|
||||
|
||||
# Look up is only for account or person records
|
||||
# Look up a user with a username for an account
|
||||
# NOTE: This may be actively in use 2026-03-24
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.get('/user/lookup_username', response_model=Resp_Body_Base)
|
||||
async def lookup_username(
|
||||
username: str = Query(..., min_length=2, max_length=50),
|
||||
@@ -799,6 +813,8 @@ async def lookup_username(
|
||||
# This requires the user_id and root_url or base_url.
|
||||
# This endpoint will generate a new user auth_key and send the email to the user's email address.
|
||||
# Updated 2025-04-08
|
||||
# NOTE: This is actively in use 2026-03-24 -Scott
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
# @router.get('/user/email_auth_key_url', response_model=Resp_Body_Base)
|
||||
@router.get('/user/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
|
||||
async def email_auth_key_url(
|
||||
@@ -830,69 +846,69 @@ async def email_auth_key_url(
|
||||
# ### END ### API User ### email_auth_key_url() ###
|
||||
|
||||
|
||||
# ### BEGIN ### API User ### get_user_obj() ###
|
||||
# Updated 2022-01-05
|
||||
@router.get('/user/{user_id}', response_model=Resp_Body_Base)
|
||||
async def get_user_obj(
|
||||
user_id: str = Path(min_length=11, max_length=22),
|
||||
inc_address: bool = False, # Priority l1
|
||||
# inc_archive_list: bool = False, # Priority l3
|
||||
inc_contact: bool = False, # Priority l1
|
||||
inc_event_list: bool = False, # Priority l1
|
||||
# inc_hosted_file_list: bool = False, # Priority l3
|
||||
inc_journal_list: bool = False, # Priority l2
|
||||
# inc_journal_entry_list: bool = False, # Priority l3
|
||||
inc_membership_person: bool = False, # Priority l2
|
||||
# inc_membership_list: bool = False, # ???
|
||||
inc_order_line_list: bool = False, # Priority l1
|
||||
inc_order_list: bool = False, # Priority l1
|
||||
inc_order_cart_list: bool = False, # Priority l1
|
||||
inc_organization: bool = False, # Priority l1
|
||||
# inc_organization_list: bool = False,
|
||||
inc_person: bool = False, # Priority l1
|
||||
# inc_person_list: bool = False,
|
||||
inc_post_list: bool = False, # Priority l2
|
||||
inc_post_comment_list: bool = False, # Priority l3
|
||||
inc_user_role_list: bool = False, # Priority l1
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
# # ### BEGIN ### API User ### get_user_obj() ###
|
||||
# # Updated 2022-01-05
|
||||
# @router.get('/user/{user_id}', response_model=Resp_Body_Base)
|
||||
# async def get_user_obj(
|
||||
# user_id: str = Path(min_length=11, max_length=22),
|
||||
# inc_address: bool = False, # Priority l1
|
||||
# # inc_archive_list: bool = False, # Priority l3
|
||||
# inc_contact: bool = False, # Priority l1
|
||||
# inc_event_list: bool = False, # Priority l1
|
||||
# # inc_hosted_file_list: bool = False, # Priority l3
|
||||
# inc_journal_list: bool = False, # Priority l2
|
||||
# # inc_journal_entry_list: bool = False, # Priority l3
|
||||
# inc_membership_person: bool = False, # Priority l2
|
||||
# # inc_membership_list: bool = False, # ???
|
||||
# inc_order_line_list: bool = False, # Priority l1
|
||||
# inc_order_list: bool = False, # Priority l1
|
||||
# inc_order_cart_list: bool = False, # Priority l1
|
||||
# inc_organization: bool = False, # Priority l1
|
||||
# # inc_organization_list: bool = False,
|
||||
# inc_person: bool = False, # Priority l1
|
||||
# # inc_person_list: bool = False,
|
||||
# inc_post_list: bool = False, # Priority l2
|
||||
# inc_post_comment_list: bool = False, # Priority l3
|
||||
# inc_user_role_list: bool = False, # Priority l1
|
||||
# commons: Common_Route_Params = Depends(common_route_params),
|
||||
# ):
|
||||
# log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
# log.debug(locals())
|
||||
|
||||
if user_id := redis_lookup_id_random(record_id_random=user_id, table_name='user'): pass
|
||||
else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
# if user_id := redis_lookup_id_random(record_id_random=user_id, table_name='user'): pass
|
||||
# else: return mk_resp(data=None, status_code=404, response=commons.response)
|
||||
|
||||
if user_result := load_user_obj(
|
||||
user_id = user_id,
|
||||
limit = commons.limit,
|
||||
model_as_dict = True, # NOTE: returning model as a dict
|
||||
enabled = commons.enabled,
|
||||
inc_address = inc_address,
|
||||
# inc_archive_list = inc_archive_list,
|
||||
inc_contact = inc_contact,
|
||||
inc_event_list = inc_event_list,
|
||||
# inc_hosted_file_list = inc_hosted_file_list,
|
||||
# inc_journal_list = inc_journal_list,
|
||||
# inc_journal_entry_list = inc_journal_entry_list,
|
||||
# inc_membership_person = inc_membership_person,
|
||||
# inc_membership_list = inc_membership_list, # ???
|
||||
inc_order_line_list = inc_order_line_list,
|
||||
inc_order_list = inc_order_list,
|
||||
inc_order_cart_list = inc_order_cart_list,
|
||||
# inc_organization = inc_organization,
|
||||
# inc_organization_list = inc_organization_list,
|
||||
inc_person = inc_person,
|
||||
# inc_person_list = inc_person_list,
|
||||
# inc_post_list = inc_post_list,
|
||||
# inc_post_comment_list = inc_post_comment_list,
|
||||
inc_user_role_list = inc_user_role_list,
|
||||
):
|
||||
response_data = user_result
|
||||
else:
|
||||
return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
# if user_result := load_user_obj(
|
||||
# user_id = user_id,
|
||||
# limit = commons.limit,
|
||||
# model_as_dict = True, # NOTE: returning model as a dict
|
||||
# enabled = commons.enabled,
|
||||
# inc_address = inc_address,
|
||||
# # inc_archive_list = inc_archive_list,
|
||||
# inc_contact = inc_contact,
|
||||
# inc_event_list = inc_event_list,
|
||||
# # inc_hosted_file_list = inc_hosted_file_list,
|
||||
# # inc_journal_list = inc_journal_list,
|
||||
# # inc_journal_entry_list = inc_journal_entry_list,
|
||||
# # inc_membership_person = inc_membership_person,
|
||||
# # inc_membership_list = inc_membership_list, # ???
|
||||
# inc_order_line_list = inc_order_line_list,
|
||||
# inc_order_list = inc_order_list,
|
||||
# inc_order_cart_list = inc_order_cart_list,
|
||||
# # inc_organization = inc_organization,
|
||||
# # inc_organization_list = inc_organization_list,
|
||||
# inc_person = inc_person,
|
||||
# # inc_person_list = inc_person_list,
|
||||
# # inc_post_list = inc_post_list,
|
||||
# # inc_post_comment_list = inc_post_comment_list,
|
||||
# inc_user_role_list = inc_user_role_list,
|
||||
# ):
|
||||
# response_data = user_result
|
||||
# else:
|
||||
# return mk_resp(data=False, status_code=400, response=commons.response) # Bad Request
|
||||
|
||||
return mk_resp(data=response_data, response=commons.response)
|
||||
# ### END ### API User ### get_user_obj() ###
|
||||
# return mk_resp(data=response_data, response=commons.response)
|
||||
# # ### END ### API User ### get_user_obj() ###
|
||||
|
||||
|
||||
# # ### BEGIN ### API User ### get_user_obj_order_list() ###
|
||||
@@ -962,17 +978,17 @@ async def get_user_obj(
|
||||
# # ### END ### API User ### get_user_obj_order_list() ###
|
||||
|
||||
|
||||
@router.delete('/user/{obj_id}', response_model=Resp_Body_Base)
|
||||
async def delete_user_obj(
|
||||
obj_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())
|
||||
# @router.delete('/user/{obj_id}', response_model=Resp_Body_Base)
|
||||
# async def delete_user_obj(
|
||||
# obj_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())
|
||||
|
||||
obj_type = 'user'
|
||||
result = delete_obj_template(
|
||||
obj_type=obj_type,
|
||||
obj_id=obj_id,
|
||||
)
|
||||
return result
|
||||
# obj_type = 'user'
|
||||
# result = delete_obj_template(
|
||||
# obj_type=obj_type,
|
||||
# obj_id=obj_id,
|
||||
# )
|
||||
# return result
|
||||
@@ -20,6 +20,8 @@ router = APIRouter()
|
||||
|
||||
# ### BEGIN ### API Utility: Email ### util_email_send_obj() ###
|
||||
# Updated 2023-06-27
|
||||
# NOTE: This is actively in use 2026-03-24 -Scott
|
||||
# This is marked for deprecation and must be migrated to Aether API v3 standards!
|
||||
@router.post('/util/email/send', response_model=Resp_Body_Base)
|
||||
async def util_email_send_obj(
|
||||
email_send_obj: Email_Send_Base,
|
||||
|
||||
@@ -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,39 +106,161 @@ 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]
|
||||
> **V3 responses always use random string IDs — never database integers.**
|
||||
|
||||
All V3 responses — `POST` create, `GET` single, `GET` list, search, and `PATCH` update — contain:
|
||||
|
||||
| Field | Type | Use |
|
||||
| :--- | :--- | :--- |
|
||||
| `{obj_type}_id` | `string` | **Primary public ID.** Use this for subsequent `PATCH` calls and UI routing. |
|
||||
| `{obj_type}_id_random` | `string` | Legacy alias. Same value as `{obj_type}_id`. Present for backward compat only. |
|
||||
|
||||
**Example — create then immediately PATCH:**
|
||||
```ts
|
||||
const created = await postArchiveContent(archiveId, payload);
|
||||
const newId = created.data.archive_content_id; // random string e.g. "xK9mP3qRtL2"
|
||||
|
||||
// Use it directly in the PATCH URL — no lookup needed
|
||||
await patchArchiveContent(newId, { name: 'Updated Name' });
|
||||
// PATCH /v3/crud/archive/{archive_id}/archive_content/{newId}
|
||||
```
|
||||
|
||||
> **Note on `_id_random` suffix:** The `{obj_type}_id_random` field is a legacy artifact from the pre-Vision model. Once you confirm `{obj_type}_id` is a random string (length 11–22), you do not need `_id_random` as a fallback. New code should only read `{obj_type}_id`.
|
||||
|
||||
---
|
||||
|
||||
## 4. V3 Uniform Lookup System
|
||||
|
||||
The V3 Lookup system provides a hierarchical, deduplicated interface for standardized tables (Countries, Timezones, etc.). It supports global defaults, account overrides, and site-specific whitelisting.
|
||||
The V3 Lookup system provides a hierarchical, deduplicated interface for standardized reference tables (Countries, Timezones, etc.). It supports global defaults, account-level overrides, and object-level overrides, with optional site-specific whitelisting.
|
||||
|
||||
### How the hierarchy works
|
||||
|
||||
Each lookup table (`lu_v3_country`, `lu_v3_time_zone`, etc.) can hold multiple rows for the same logical item at different scopes:
|
||||
|
||||
| Scope | `account_id` | `for_type` / `for_id` | Wins over |
|
||||
|---|---|---|---|
|
||||
| Global default | `NULL` | `NULL` / `NULL` | nothing |
|
||||
| Account override | set | `NULL` / `NULL` | Global default |
|
||||
| Object override | set | set | Account override + Global default |
|
||||
|
||||
The API uses `ROW_NUMBER() PARTITION BY group` to collapse all rows for the same item down to the single highest-priority winner before returning results. **`group` is the identity key** — it is what makes two rows "the same item competing for priority."
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **The `group` field is not a display label.** It is the deduplication key. Each lookup type uses a different natural key for `group`:
|
||||
>
|
||||
> | Lookup type | `group` value | Example |
|
||||
> |---|---|---|
|
||||
> | `country` | ISO alpha-2 code | `"US"`, `"CA"`, `"GB"` |
|
||||
> | `country_subdivision` | subdivision code | `"US-NY"`, `"CA-ON"` |
|
||||
> | `time_zone` | IANA timezone name | `"America/New_York"`, `"US/Eastern"` |
|
||||
>
|
||||
> For `time_zone`, `group` and `name` must always be identical — there is no concept of "override all US timezones as a group." Each timezone is its own identity.
|
||||
|
||||
### A. List Lookups
|
||||
Retrieve a ranked and filtered list of lookup items.
|
||||
|
||||
Retrieve the deduplicated, ranked list for a lookup type.
|
||||
|
||||
* **Endpoint:** `GET /v3/lookup/{lu_type}/list`
|
||||
* **Available Types:** `country`, `country_subdivision`, `time_zone`
|
||||
* **Parameters:**
|
||||
* `site_id` (Optional): Random ID of the site to apply a **Whitelist Policy**.
|
||||
* `only_priority` (Optional): Set to `true` to return only high-priority items (e.g., common time zones).
|
||||
* `for_type` / `for_id` (Optional): Context for object-specific overrides.
|
||||
* `include_disabled` (Optional): Set to `true` to see shadowed/disabled records.
|
||||
* `site_id` (Optional): Random ID of the site — applies a **Whitelist Policy** (see §C).
|
||||
* `only_priority` (Optional): `true` returns only `priority=1` items (e.g., common time zones).
|
||||
* `for_type` / `for_id` (Optional): Object context — activates object-level override matching.
|
||||
* `include_disabled` (Optional): `true` includes shadowed/disabled records (useful for admin views).
|
||||
|
||||
**Frontend keying:** Always key Svelte `{#each}` blocks on `group`, not `id` or `name`. `group` is guaranteed unique in the response. Keying on `id` will break if an account override wins (different `id`, same logical item).
|
||||
|
||||
### B. Resolve Identity
|
||||
Resolves a string (code, group, or name) to a single record.
|
||||
|
||||
Resolves a string to a single lookup record.
|
||||
|
||||
* **Endpoint:** `GET /v3/lookup/{lu_type}/resolve?q=VALUE`
|
||||
* **Usage:** Use this when you have an external code (e.g., ISO "US") and need the full Aether record.
|
||||
* **Usage:** Use when you have an external code (e.g., ISO `"US"`) and need the full Aether record. Scans `name`, `group`, and other identity fields.
|
||||
|
||||
### C. Site Whitelist Policy
|
||||
To limit lookups for a specific site, add a `lookup_policy` to the `site.cfg_json` field.
|
||||
**Schema:**
|
||||
|
||||
To restrict which lookup items appear for a specific site, add a `lookup_policy` to `site.cfg_json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"lookup_policy": {
|
||||
"country": ["US", "CA", "GB"],
|
||||
"time_zone": ["America/New_York"]
|
||||
"time_zone": ["America/New_York", "US/Eastern"]
|
||||
}
|
||||
}
|
||||
```
|
||||
*Note: Whitelist values must match the `group` field in the database.*
|
||||
|
||||
> **Whitelist values must match the `group` field** — i.e., the natural key for that type (ISO code for country, IANA name for time zone). Using a display name will silently return no results for that item.
|
||||
|
||||
### D. Adding and managing client overrides
|
||||
|
||||
When a client needs a customized label or wants to hide/reorder lookup items, create override records rather than modifying global defaults.
|
||||
|
||||
**Rules:**
|
||||
1. **Never modify global default rows** (`account_id = NULL`). Those are shared across all accounts. Any change there affects every client.
|
||||
2. **Set `group` to the exact same value as the global default row** for the item you are overriding. If `group` doesn't match, the override creates a new item instead of replacing the existing one.
|
||||
3. **Set `account_id`** to the client's account ID. Leave `for_type` / `for_id` null unless the override is specific to a single object (e.g., one site).
|
||||
|
||||
**Example — rename "US/Eastern" for one account:**
|
||||
|
||||
```sql
|
||||
INSERT INTO lu_v3_time_zone
|
||||
(account_id, name, name_override, `group`, enable, priority, sort)
|
||||
VALUES
|
||||
(42, 'US/Eastern', 'Eastern Time (Client Label)', 'US/Eastern', 1, 1, 50);
|
||||
```
|
||||
|
||||
The `name_override` field is the display label the frontend should prefer when set. `group = 'US/Eastern'` ensures this row competes with — and wins over — the global default in the `PARTITION BY group` deduplication.
|
||||
|
||||
**To disable an item for one account** (hide it from their dropdowns):
|
||||
|
||||
```sql
|
||||
INSERT INTO lu_v3_time_zone
|
||||
(account_id, name, `group`, enable)
|
||||
VALUES
|
||||
(42, 'US/Samoa', 'US/Samoa', 0);
|
||||
```
|
||||
|
||||
Setting `enable = 0` on an account-scoped row shadows the global default for that account only.
|
||||
|
||||
**To remove a client override** (revert to global default):
|
||||
|
||||
Simply delete the row where `account_id = <client>` and `group = '<item>'`. The global default row is unaffected and immediately resumes winning.
|
||||
|
||||
### E. Adding new global lookup items
|
||||
|
||||
When seeding new lookup data (e.g., adding timezones in bulk):
|
||||
|
||||
1. Set `group = name` for every row (for `time_zone`). This is a hard invariant — if `group` is set to a regional label like `"United States"` instead of the timezone name, the entire group collapses to a single winner and all but one entry disappear from the API response.
|
||||
2. Set `account_id = NULL` and `for_type = NULL` / `for_id = NULL` for global defaults.
|
||||
3. After seeding, verify with:
|
||||
```sql
|
||||
-- Should return 0 rows; any result means multiple items will collapse into one
|
||||
SELECT `group`, COUNT(*) AS cnt
|
||||
FROM lu_v3_time_zone
|
||||
WHERE account_id IS NULL
|
||||
GROUP BY `group`
|
||||
HAVING cnt > 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -156,7 +316,261 @@ Frontend guidance:
|
||||
|
||||
---
|
||||
|
||||
## 7. Event Exhibit Tracking Export (Leads Export)
|
||||
## 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`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Migration from legacy `/user/*` routes:** The table below maps each legacy endpoint to its V3 replacement. Run both in parallel during transition; remove legacy routes once traffic logs confirm they are quiet.
|
||||
>
|
||||
> | Legacy | V3 Replacement |
|
||||
> |---|---|
|
||||
> | `GET /user/authenticate` | `POST /v3/action/user/authenticate` |
|
||||
> | `POST /user/verify_password` | `POST /v3/action/user/verify_password` |
|
||||
> | `PATCH /user/{id}/change_password` | `POST /v3/action/user/{id}/change_password` |
|
||||
> | `GET /user/{id}/new_auth_key` | `GET /v3/action/user/{id}/new_auth_key` |
|
||||
> | `GET /user/{id}/email_auth_key_url` | `GET /v3/action/user/{id}/email_auth_key_url` |
|
||||
> | `GET /user/lookup` | `POST /v3/crud/user/search` |
|
||||
> | `GET /user/lookup_email` | `POST /v3/crud/user/search` |
|
||||
> | `GET /user/lookup_username` | `POST /v3/crud/user/search` |
|
||||
|
||||
### A. Authenticate
|
||||
|
||||
Authenticate a user by **username + password** or **user_id + auth_key**.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/v3/action/user/authenticate`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id` (scopes username lookups to the correct account)
|
||||
- **Security improvement:** Credentials are in the **POST body**, not query params — safe from URL logging.
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "username": "scott", "password": "MyPassword123!" }
|
||||
```
|
||||
or:
|
||||
```json
|
||||
{ "user_id": "<user_id_random>", "auth_key": "<one_time_key>", "valid_email": true }
|
||||
```
|
||||
|
||||
- `valid_email` (optional `bool`): if `true`, marks `email_verified = true` on success.
|
||||
- `inc_user_role_list` (optional query param, default `false`): include role list in the returned user object.
|
||||
|
||||
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
|
||||
|
||||
**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`.
|
||||
|
||||
---
|
||||
|
||||
### B. Verify Password
|
||||
|
||||
Check a user's current password without changing it.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/v3/action/user/verify_password`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id`
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "user_id": "<user_id_random>", "current_password": "MyPassword123!" }
|
||||
```
|
||||
or use `"username"` instead of `"user_id"` to look up by username within the account.
|
||||
|
||||
**Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
|
||||
|
||||
---
|
||||
|
||||
### C. Change Password
|
||||
|
||||
Change a user's password. Optionally verify the current password first.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/v3/action/user/{user_id}/change_password`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id`
|
||||
|
||||
**Request body:**
|
||||
```json
|
||||
{ "new_password": "NewPassword456!", "current_password": "MyPassword123!" }
|
||||
```
|
||||
|
||||
- `new_password` is required (minimum 10 characters).
|
||||
- `current_password` is optional. If provided, it is verified before the change is applied. Omit it for admin-driven resets.
|
||||
|
||||
**Response:** `data: true` on success. `403` if `current_password` provided but wrong.
|
||||
|
||||
---
|
||||
|
||||
### D. Generate New Auth Key
|
||||
|
||||
Generate a fresh one-time-use auth key for the user and write it to the DB.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/v3/action/user/{user_id}/new_auth_key`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "data": { "auth_key": "<new_key>" } }
|
||||
```
|
||||
|
||||
The returned key can then be passed to `/authenticate` (as `auth_key`) or embedded in a login URL. The user record must have `allow_auth_key = true` for key-based authentication to work.
|
||||
|
||||
---
|
||||
|
||||
### E. Email Auth Key URL
|
||||
|
||||
Generate a new auth key and email a one-time login link to the user's email address.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/v3/action/user/{user_id}/email_auth_key_url`
|
||||
- **Auth:** `x-aether-api-key` + `x-account-id`
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `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. |
|
||||
|
||||
> [!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`.
|
||||
|
||||
---
|
||||
|
||||
### F. User Lookups via V3 CRUD Search
|
||||
|
||||
The three legacy lookup routes (`lookup`, `lookup_email`, `lookup_username`) are replaced by standard V3 CRUD search:
|
||||
|
||||
```typescript
|
||||
// Look up by user_id (Vision ID)
|
||||
POST /v3/crud/user/search
|
||||
{ "and": [{ "field": "id_random", "op": "eq", "value": "<user_id>" }] }
|
||||
|
||||
// Look up by email
|
||||
POST /v3/crud/user/search
|
||||
{ "and": [{ "field": "email", "op": "eq", "value": "user@example.com" }] }
|
||||
|
||||
// Look up by username
|
||||
POST /v3/crud/user/search
|
||||
{ "and": [{ "field": "username", "op": "eq", "value": "scott" }] }
|
||||
```
|
||||
|
||||
Results are automatically scoped to the `x-account-id` provided in the request.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -224,10 +638,67 @@ const url = URL.createObjectURL(blob);
|
||||
|
||||
---
|
||||
|
||||
## 8. 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.
|
||||
|
||||
144
documentation/PROJECT__AE_Lookups_fixes_and_docs_update.md
Normal file
144
documentation/PROJECT__AE_Lookups_fixes_and_docs_update.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Project: V3 Lookup Bug Fix — Timezone Group Data + PARTITION BY Revert
|
||||
|
||||
> **Status:** 🔧 Action Required
|
||||
> **Date:** 2026-03-23
|
||||
> **Related doc:** `PROJECT__V3_UNIFORM_LOOKUP_SYSTEM.md`
|
||||
> **Reported by:** Frontend Agent (Scott Idem / One Sky IT)
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Two bugs were discovered in the V3 Uniform Lookup System during IDAA Recovery Meetings
|
||||
timezone dropdown testing. They stem from a single root cause: the `lu_v3_time_zone`
|
||||
table was seeded with regional `group` values (`"United States"`, `"Europe"`) instead of
|
||||
individual timezone names — contrary to the design specified in Phase 2 of the lookup
|
||||
architecture doc, which explicitly states `lu_v3_time_zone (Group: name)`.
|
||||
|
||||
An attempted fix changed `PARTITION BY group` to `PARTITION BY name` in
|
||||
`get_lookup_list_v3()`. This unintentionally broke country deduplication, which depends
|
||||
on `PARTITION BY group` being correct (country group = `alpha_2_code`, e.g. `"US"`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Root Cause
|
||||
|
||||
### 2.1 Timezone `group` values were set to regional names instead of timezone names
|
||||
|
||||
The `lu_v3_time_zone` table has two groups where multiple records share a single group value:
|
||||
|
||||
| `group` value | Count | Example records |
|
||||
|----------------|-------|-----------------|
|
||||
| `United States` | 13 | US/Alaska, US/Arizona, US/Central, US/East-Indiana, US/Eastern, US/Hawaii, US/Indiana-Starke, US/Michigan, US/Mountain, US/Pacific, US/Pacific-New, US/Samoa, US/Aleutian |
|
||||
| `Europe` | 63 | Europe/London, Europe/Paris, Europe/Prague, Europe/Rome, ... (all Europe/* zones) |
|
||||
|
||||
All other timezone records already have `group = name` (e.g., `Canada/Eastern` has
|
||||
`group = "Canada/Eastern"`). The US and Europe records were loaded incorrectly.
|
||||
|
||||
**Effect:** `PARTITION BY group` collapsed all 13 US/* records into a single winner and
|
||||
all 63 Europe/* records into a single winner. Only ~7 distinct US timezones and 1 Europe
|
||||
timezone appeared in the dropdown instead of all 76.
|
||||
|
||||
### 2.2 Attempted fix broke country lookup deduplication
|
||||
|
||||
Changing `PARTITION BY group` → `PARTITION BY name` in `get_lookup_list_v3()` fixed the
|
||||
timezone collapse but broke `lu_v3_country`.
|
||||
|
||||
`lu_v3_country` has (at minimum) two records for `alpha_2_code = "US"`:
|
||||
- `id=240`: global default (`account_id=NULL`), `group="US"`
|
||||
- `id=251`: account-specific (`account_id=1`), `group="US"`
|
||||
|
||||
With `PARTITION BY group`, both records share `group="US"` and are correctly deduped —
|
||||
the account-specific record wins per the override hierarchy. With `PARTITION BY name`,
|
||||
if the two records have different `name` values they are treated as separate identities
|
||||
and both survive, resulting in duplicate `alpha_2_code="US"` entries in the API response.
|
||||
|
||||
The frontend's `{#each lu_country_list as country (country.alpha_2_code)}` then throws:
|
||||
> `Svelte error: each_key_duplicate — Keyed each block has duplicate key 'US'`
|
||||
|
||||
The same risk applies to `lu_v3_country_subdivision`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Correct Fix (Two Steps)
|
||||
|
||||
### Step 1 — Revert `app/methods/lookup_methods.py`
|
||||
|
||||
Change `PARTITION BY name` back to `PARTITION BY group`:
|
||||
|
||||
```python
|
||||
# lookup_methods.py — get_lookup_list_v3()
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY `group` # <-- revert to this
|
||||
ORDER BY
|
||||
(for_type = :for_type AND for_id = :for_id) DESC,
|
||||
(account_id = :account_id) DESC,
|
||||
created_on DESC
|
||||
) as rank_priority
|
||||
```
|
||||
|
||||
This restores correct behavior for all three active V3 lookup types
|
||||
(`country`, `country_subdivision`, `time_zone`).
|
||||
|
||||
### Step 2 — Fix the `lu_v3_time_zone` data
|
||||
|
||||
Set `group = name` for all records where the group is a regional label rather than the
|
||||
timezone's own name. Run once against the database:
|
||||
|
||||
```sql
|
||||
UPDATE lu_v3_time_zone
|
||||
SET `group` = `name`
|
||||
WHERE `group` IN ('United States', 'Europe');
|
||||
```
|
||||
|
||||
**Verification:**
|
||||
```sql
|
||||
-- Should return 0 rows after the fix
|
||||
SELECT `group`, COUNT(*) as cnt
|
||||
FROM lu_v3_time_zone
|
||||
GROUP BY `group`
|
||||
HAVING cnt > 1;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Why PARTITION BY `group` Is Correct
|
||||
|
||||
As documented in `PROJECT__V3_UNIFORM_LOOKUP_SYSTEM.md` (Section 2.1):
|
||||
|
||||
> `group`: The primary business key/cluster key. *Note: Must be populated for hierarchy to work.*
|
||||
|
||||
The `group` field IS the deduplication identity. Each lookup type uses a different natural
|
||||
key for `group`:
|
||||
|
||||
| Lookup type | `group` field | Example |
|
||||
|---|---|---|
|
||||
| `country` | `alpha_2_code` | `"US"`, `"CA"`, `"GB"` |
|
||||
| `country_subdivision` | `code` | `"US-NY"`, `"CA-ON"` |
|
||||
| `time_zone` | `name` (= the IANA timezone identifier) | `"US/Eastern"`, `"Europe/London"` |
|
||||
|
||||
For `time_zone`, `group` and `name` are intended to be the same value — each timezone
|
||||
is its own identity. There is no meaningful concept of "override all US timezones as a
|
||||
group." Each one is individually addressable.
|
||||
|
||||
---
|
||||
|
||||
## 5. Regression Tests to Add / Update
|
||||
|
||||
- `test_timezone_us_dedup()` — assert all 13 US/* priority zones are present individually
|
||||
- `test_timezone_europe_dedup()` — assert all Europe/* priority zones present individually
|
||||
- `test_country_us_dedup()` — assert only one `alpha_2_code="US"` record returned;
|
||||
account-specific override wins over global default
|
||||
- General: `GET /v3/lookup/time_zone/list?only_priority=true` should return exactly 72
|
||||
records (the current count of priority=1 enabled timezones)
|
||||
|
||||
---
|
||||
|
||||
## 6. What Was NOT Changed (and Should Not Be)
|
||||
|
||||
- The endpoint signature for `GET /v3/lookup/{lu_type}/list` — it does not and should
|
||||
not expose `limit`, `offset`, or `order_by_li` query params. The frontend sends these
|
||||
but they are correctly ignored. The sort order is hardcoded and correct:
|
||||
`ORDER BY COALESCE(priority, 0) DESC, COALESCE(sort, 0) DESC, name ASC`
|
||||
- Country and country_subdivision data — no changes needed to those tables
|
||||
- Frontend code — no backend-side changes are needed on the frontend for this fix
|
||||
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 |
|
||||
124
documentation/PROJECT__AE_hosted_files_uploads_util.md
Normal file
124
documentation/PROJECT__AE_hosted_files_uploads_util.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# PROJECT: AE Hosted Files — Upload Util & V3 Actions Migration
|
||||
|
||||
**Status:** In Progress
|
||||
**Date:** 2026-03-25
|
||||
**Affected systems:** Frontend (aether_app_sveltekit), Backend (aether_api_fastapi)
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
The legacy `hosted_file.router` (registered at prefix `/hosted_file`) was commented out
|
||||
in `app/routers/registry.py` as part of the V3 migration:
|
||||
|
||||
```python
|
||||
# app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
|
||||
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', ...)
|
||||
```
|
||||
|
||||
This broke several frontend features that were still calling the old endpoints.
|
||||
Three endpoints have been fixed on the frontend side (already committed and pushed).
|
||||
One endpoint still needs a backend fix.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints: Status Summary
|
||||
|
||||
### FIXED (frontend updated to call new V3 path)
|
||||
|
||||
| Old endpoint | New endpoint | Frontend file |
|
||||
|---|---|---|
|
||||
| `POST /hosted_file/upload_files` | `POST /v3/action/hosted_file/upload` | `src/lib/ae_core/ae_comp__hosted_files_upload.svelte`, `src/routes/events/ae_comp__event_files_upload.svelte` |
|
||||
| `GET /hosted_file/{id}/clip_video` | `GET /v3/action/hosted_file/{id}/clip_video` | `src/lib/ae_core/ae_comp__hosted_files_clip_video.svelte` |
|
||||
|
||||
### NEEDS BACKEND ACTION — Hash Lookup Endpoint
|
||||
|
||||
**Missing endpoint:** `GET /hosted_file/hash/{hosted_file_hash}`
|
||||
|
||||
This endpoint existed in the legacy `hosted_file.py` router (line 233) and has **not** been
|
||||
ported to `api_v3_actions_hosted_file.py`.
|
||||
|
||||
**What it does:**
|
||||
1. Looks up a `hosted_file` record by its `hash_sha256` field
|
||||
2. Optionally checks that the physical file actually exists on disk (`check_for_local=true`)
|
||||
3. Returns the full hosted_file object with two extra flags:
|
||||
- `hosted_file_found_check: true` — file record exists AND physical file confirmed on disk
|
||||
- `hosted_file_size_check: <bytes>` — file size from disk
|
||||
|
||||
**Legacy implementation (hosted_file.py:233):**
|
||||
```python
|
||||
@router.get('/hash/{hosted_file_hash}', response_model=Resp_Body_Base)
|
||||
async def check_hosted_file_obj_w_hash(
|
||||
hosted_file_hash: str = Path(min_length=64, max_length=64),
|
||||
check_for_local: Optional[bool] = True,
|
||||
commons: Common_Route_Params = Depends(common_route_params),
|
||||
):
|
||||
if hfid := lookup_file_hash(file_hash=hosted_file_hash):
|
||||
obj = load_hosted_file_obj(hosted_file_id=hfid, model_as_dict=True)
|
||||
if check_for_local and obj:
|
||||
if check := check_for_hosted_file_hash_file(file_hash=hosted_file_hash, sub_dir=obj.get('subdirectory_path', '')):
|
||||
obj['hosted_file_found_check'] = True
|
||||
obj['hosted_file_size_check'] = check['file_size']
|
||||
return mk_resp(data=obj, response=commons.response)
|
||||
return mk_resp(data=False, status_code=404, response=commons.response)
|
||||
```
|
||||
|
||||
**Where it's called on the frontend:**
|
||||
- `src/lib/ae_core/core__check_hosted_file_obj_w_hash.ts` — thin wrapper, calls `GET /hosted_file/hash/{hash}`
|
||||
- `src/lib/elements/element_input_file.svelte` — calls this before uploading (dedup check)
|
||||
- `src/lib/elements/element_input_files_tbl.svelte` — same (dedup check in the table file input)
|
||||
- Exported via `src/lib/ae_core/ae_core_functions.ts` as `core_func.check_hosted_file_obj_w_hash`
|
||||
|
||||
**Current impact:** The 404 causes a null return. The frontend checks
|
||||
`result && result.hosted_file_found_check` — so if null, it silently skips the dedup check
|
||||
and proceeds to upload anyway. Uploads still work, but duplicate files may be created rather
|
||||
than reusing existing records.
|
||||
|
||||
**Requested fix (backend):**
|
||||
Port this endpoint to `api_v3_actions_hosted_file.py` as:
|
||||
|
||||
```
|
||||
GET /v3/action/hosted_file/hash/{hosted_file_hash}
|
||||
```
|
||||
|
||||
Parameters and response shape should match the legacy implementation exactly.
|
||||
The `check_for_local` query param (default `True`) must be preserved — the frontend
|
||||
passes `check_for_local=true` and expects `hosted_file_found_check` in the response.
|
||||
|
||||
**After backend deploys the new endpoint**, the frontend needs one line changed in
|
||||
`src/lib/ae_core/core__check_hosted_file_obj_w_hash.ts`:
|
||||
```ts
|
||||
// Before:
|
||||
const endpoint = `/hosted_file/hash/${hosted_file_hash}`;
|
||||
// After:
|
||||
const endpoint = `/v3/action/hosted_file/hash/${hosted_file_hash}`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Other Legacy Endpoints — Audit Notes
|
||||
|
||||
The following were also in `hosted_file.py` but appear to either have V3 equivalents already
|
||||
or are not currently called by the frontend. Backend should confirm:
|
||||
|
||||
| Legacy endpoint | V3 equivalent | Notes |
|
||||
|---|---|---|
|
||||
| `GET /hosted_file/{id}/download` | `GET /v3/action/hosted_file/{id}/download` | Exists in V3 router |
|
||||
| `DELETE /hosted_file/{id}` | `DELETE /v3/action/hosted_file/{id}` | Exists in V3 router |
|
||||
| `GET /hosted_file/{id}/convert_file` | `GET /v3/action/hosted_file/{id}/convert_file` | Exists in V3 router |
|
||||
| `GET /hosted_file/{id}/stream` | Unknown | Not confirmed in V3 router — verify |
|
||||
| `GET /hosted_file/directory_check` | Unknown | Admin/dev utility — verify if still needed |
|
||||
| `GET /hosted_file/hash/{hash}/download` (via V3) | `GET /v3/action/hosted_file/hash/{sha256}/download` | Exists in V3 router (hash-based download) |
|
||||
| `GET /hosted_file/tmp/{subdir}/{filename}/download` | Unknown | Temp file download — verify if still needed |
|
||||
| `POST /hosted_file/create_video` | Unknown | Verify if still needed |
|
||||
|
||||
---
|
||||
|
||||
## Coordinator Notes
|
||||
|
||||
- Frontend commits fixing upload and clip_video are on branch `ae_app_3x_llm`
|
||||
(commits `a5a806e2` and `362136e6`)
|
||||
- Once the backend adds the hash lookup endpoint, the frontend one-line fix in
|
||||
`core__check_hosted_file_obj_w_hash.ts` can be committed alongside it
|
||||
- The `check_for_local` flag is important — it verifies the physical file exists on disk,
|
||||
not just the DB record. Don't drop it in the V3 port.
|
||||
@@ -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)
|
||||
|
||||
@@ -15,7 +15,7 @@ email-validator
|
||||
et-xmlfile
|
||||
fastapi>=0.115.5
|
||||
# greenlet
|
||||
gunicorn
|
||||
gunicorn==23.0.0
|
||||
h11
|
||||
html2text
|
||||
httpcore
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -96,6 +133,7 @@ Always run test scripts from the **project root** directory. Most scripts includ
|
||||
* Use snake_case (or Snake_Case or Snake_case or test_NASA_example or test_API_key)
|
||||
* Aether test/demo base URL: 'http://demo.localhost:5173'
|
||||
* Aether development API: 'https://dev-api.oneskyit.com'
|
||||
* Aether development API "secret" key: 'nT0jPeiCfxSifkiDZur9jA'
|
||||
|
||||
These are IDs for records that we can use for testing. Please do not delete them. They are also used for demo purposes with clients.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -14,7 +14,20 @@ HEADERS = {
|
||||
}
|
||||
|
||||
# TODO: SET THIS to your demo site's random ID
|
||||
SITE_ID_RANDOM = "92vkYC4fVEl"
|
||||
SITE_ID_RANDOM = "92vkYC4fVEl"
|
||||
|
||||
# All US/* priority timezones — group must equal name in lu_v3_time_zone for these to survive
|
||||
# PARTITION BY group dedup. If group="United States" for these, only 1 survives.
|
||||
US_TIMEZONES = [
|
||||
"US/Alaska", "US/Aleutian", "US/Arizona", "US/Central", "US/East-Indiana",
|
||||
"US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan",
|
||||
"US/Mountain", "US/Pacific", "US/Pacific-New", "US/Samoa",
|
||||
]
|
||||
|
||||
# Spot-check a subset of Europe/* priority timezones — same root cause as US/*
|
||||
EUROPE_TIMEZONES_SAMPLE = [
|
||||
"Europe/London", "Europe/Paris", "Europe/Prague", "Europe/Rome",
|
||||
]
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
@@ -30,17 +43,17 @@ def test_lookup_list(lu_type, site_id=None, only_priority=False):
|
||||
if only_priority:
|
||||
params["only_priority"] = "true"
|
||||
label += " (Priority Only)"
|
||||
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = requests.get(url, headers=HEADERS, params=params)
|
||||
duration = time.time() - start_time
|
||||
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json().get('data', [])
|
||||
msg = f"Found {len(data)} items ({duration:.2f}s)"
|
||||
print_result(label, True, msg)
|
||||
|
||||
|
||||
# Print top 10 for sorting verification
|
||||
if data and not site_id: # Only print for full or priority lists
|
||||
limit = 10 if not only_priority else len(data)
|
||||
@@ -49,7 +62,7 @@ def test_lookup_list(lu_type, site_id=None, only_priority=False):
|
||||
prio = item.get('priority', 0)
|
||||
sort = item.get('sort', 0)
|
||||
print(f" [{i+1}] {item.get('name')} (Prio: {prio}, Sort: {sort})")
|
||||
|
||||
|
||||
return data
|
||||
else:
|
||||
print_result(label, False, f"Status {response.status_code}: {response.text[:100]}")
|
||||
@@ -75,27 +88,99 @@ def test_lookup_resolve(lu_type, query):
|
||||
print_result(f"GET /{lu_type}/resolve?q={query}", False, str(e))
|
||||
return False
|
||||
|
||||
def test_timezone_us_dedup(data):
|
||||
"""
|
||||
Regression: lu_v3_time_zone group data fix.
|
||||
All 13 US/* priority zones must appear individually.
|
||||
Root cause: group was seeded as 'United States' instead of name — PARTITION BY group
|
||||
collapsed all 13 into one winner.
|
||||
"""
|
||||
label = "time_zone: all 13 US/* zones present (group=name data fix)"
|
||||
if data is None:
|
||||
print_result(label, False, "No data")
|
||||
return
|
||||
names = {item.get("name") for item in data}
|
||||
missing = [tz for tz in US_TIMEZONES if tz not in names]
|
||||
if missing:
|
||||
print_result(label, False, f"Missing (group data not yet fixed?): {missing}")
|
||||
else:
|
||||
print_result(label, True, f"All {len(US_TIMEZONES)} US/* timezones present")
|
||||
|
||||
def test_timezone_europe_dedup(data):
|
||||
"""
|
||||
Regression: same root cause as US/* — group was 'Europe' for all Europe/* zones.
|
||||
Spot-check that the priority ones appear individually after data fix.
|
||||
"""
|
||||
label = "time_zone: Europe/* spot-check (group=name data fix)"
|
||||
if data is None:
|
||||
print_result(label, False, "No data")
|
||||
return
|
||||
names = {item.get("name") for item in data}
|
||||
missing = [tz for tz in EUROPE_TIMEZONES_SAMPLE if tz not in names]
|
||||
if missing:
|
||||
print_result(label, False, f"Missing (group data not yet fixed?): {missing}")
|
||||
else:
|
||||
print_result(label, True, f"Europe/* spot-check passed ({len(EUROPE_TIMEZONES_SAMPLE)} zones found)")
|
||||
|
||||
def test_country_us_dedup(data):
|
||||
"""
|
||||
Regression: PARTITION BY group must NOT produce duplicate alpha_2_code values.
|
||||
Two records exist for alpha_2_code='US' (global default + account override) — only one
|
||||
should survive. If PARTITION BY name were used, both would appear and Svelte would
|
||||
throw each_key_duplicate on alpha_2_code='US'.
|
||||
"""
|
||||
label = "country: no duplicate alpha_2_code (PARTITION BY group dedup)"
|
||||
if data is None:
|
||||
print_result(label, False, "No data")
|
||||
return
|
||||
codes = [item.get("alpha_2_code") for item in data if item.get("alpha_2_code")]
|
||||
duplicates = [c for c in set(codes) if codes.count(c) > 1]
|
||||
if duplicates:
|
||||
print_result(label, False, f"Duplicate alpha_2_codes: {duplicates}")
|
||||
else:
|
||||
print_result(label, True, f"No duplicates across {len(data)} countries")
|
||||
|
||||
def test_priority_only_count(data, expected=72):
|
||||
"""priority=1 enabled timezones: should be exactly {expected} after data fix."""
|
||||
label = f"time_zone priority-only count == {expected}"
|
||||
if data is None:
|
||||
print_result(label, False, "No data")
|
||||
return
|
||||
if len(data) == expected:
|
||||
print_result(label, True, f"{len(data)} records")
|
||||
else:
|
||||
print_result(label, False, f"Got {len(data)}, expected {expected} (data fix pending?)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"🚀 Starting V3 Lookup E2E Suite ({BASE_URL})\n")
|
||||
start_suite = time.time()
|
||||
|
||||
# 1. Basic Lists (Phase 1)
|
||||
test_lookup_list("country")
|
||||
|
||||
print("\n--- Testing Priority Only ---")
|
||||
test_lookup_list("time_zone", only_priority=True)
|
||||
|
||||
# 2. Whitelist Test (Phase 2)
|
||||
|
||||
# 1. Country — basic list + dedup regression
|
||||
print("--- Country ---")
|
||||
country_data = test_lookup_list("country")
|
||||
test_country_us_dedup(country_data)
|
||||
|
||||
# 2. Timezone — full list + group data fix regressions
|
||||
print("\n--- Timezone (full list) ---")
|
||||
tz_data = test_lookup_list("time_zone")
|
||||
test_timezone_us_dedup(tz_data)
|
||||
test_timezone_europe_dedup(tz_data)
|
||||
|
||||
# 3. Timezone — priority only
|
||||
print("\n--- Timezone (priority only) ---")
|
||||
tz_priority_data = test_lookup_list("time_zone", only_priority=True)
|
||||
test_priority_only_count(tz_priority_data, expected=72)
|
||||
|
||||
# 4. Whitelist Test
|
||||
if SITE_ID_RANDOM != "SET_ME_TO_SITE_ID":
|
||||
print("\n--- Testing Site Whitelist Policy ---")
|
||||
# Should return only whitelisted items
|
||||
print("\n--- Site Whitelist Policy ---")
|
||||
test_lookup_list("country", site_id=SITE_ID_RANDOM)
|
||||
test_lookup_list("time_zone", site_id=SITE_ID_RANDOM)
|
||||
else:
|
||||
print("\n⚠️ Skipping Phase 2 test: SITE_ID_RANDOM not set.")
|
||||
|
||||
# 3. Resolve Test
|
||||
print("\n--- Testing Resolve ---")
|
||||
print("\n⚠️ Skipping whitelist test: SITE_ID_RANDOM not set.")
|
||||
|
||||
# 5. Resolve
|
||||
print("\n--- Resolve ---")
|
||||
test_lookup_resolve("country", "US")
|
||||
|
||||
|
||||
print(f"\n⏱️ Suite completed in {time.time() - start_suite:.2f}s")
|
||||
|
||||
91
tests/e2e/test_e2e_v3_nested_create_event_badge.py
Normal file
91
tests/e2e/test_e2e_v3_nested_create_event_badge.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
E2E: Nested-create regression test
|
||||
- Creates an `event_person` under demo event `pjrcghqwert` then creates an
|
||||
`event_badge` under that person using the nested CRUD endpoints.
|
||||
- Cleans up created records on success.
|
||||
|
||||
Usage:
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_nested_create_event_badge.py
|
||||
|
||||
This test uses the standard Agent API Key defined in the project README.
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
import sys
|
||||
import time
|
||||
|
||||
BASE = os.environ.get('AE_API_BASE', 'https://dev-api.oneskyit.com')
|
||||
API_BASE = BASE.rstrip('/') + '/v3/crud'
|
||||
AGENT_API_KEY = os.environ.get('AE_AGENT_API_KEY', 'nT0jPeiCfxSifkiDZur9jA')
|
||||
EVENT_ID = os.environ.get('AE_TEST_EVENT', 'pjrcghqwert')
|
||||
ACCOUNT_ID = os.environ.get('AE_ACCOUNT', '_XY7DXtc9MY')
|
||||
|
||||
HEADERS = {'x-aether-api-key': AGENT_API_KEY, 'x-account-id': ACCOUNT_ID, 'Content-Type': 'application/json'}
|
||||
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
mark = '✅ PASS' if success else '❌ FAIL'
|
||||
print(f"{mark} - {label}: {message}")
|
||||
|
||||
|
||||
def run():
|
||||
created = {}
|
||||
try:
|
||||
# 1) Create event_person under event
|
||||
url = f"{API_BASE}/event/{EVENT_ID}/event_person/?return_obj=false"
|
||||
r = requests.post(url, headers=HEADERS, json={})
|
||||
if r.status_code != 200:
|
||||
print_result('create event_person', False, f'status={r.status_code} body={r.text}')
|
||||
return 2
|
||||
data = r.json().get('data') or {}
|
||||
person_id = data.get('obj_id') or data.get('obj_id_random')
|
||||
if not person_id:
|
||||
print_result('create event_person', False, f'missing obj_id in response {r.json()}')
|
||||
return 2
|
||||
created['person'] = person_id
|
||||
print_result('create event_person', True, f'person_id={person_id}')
|
||||
|
||||
# small delay to let DB/indexing settle on remote dev
|
||||
time.sleep(0.5)
|
||||
|
||||
# 2) Create event_badge under event_person
|
||||
url = f"{API_BASE}/event_person/{person_id}/event_badge/?return_obj=false"
|
||||
r2 = requests.post(url, headers=HEADERS, json={})
|
||||
if r2.status_code != 200:
|
||||
print_result('create event_badge', False, f'status={r2.status_code} body={r2.text}')
|
||||
return 2
|
||||
data2 = r2.json().get('data') or {}
|
||||
badge_id = data2.get('obj_id') or data2.get('obj_id_random')
|
||||
if not badge_id:
|
||||
print_result('create event_badge', False, f'missing obj_id in response {r2.json()}')
|
||||
return 2
|
||||
created['badge'] = badge_id
|
||||
print_result('create event_badge', True, f'badge_id={badge_id}')
|
||||
|
||||
# 3) Cleanup: delete badge then person
|
||||
# Delete badge
|
||||
del_url = f"{API_BASE}/event_person/{person_id}/event_badge/{badge_id}?method=delete"
|
||||
rd = requests.delete(del_url, headers=HEADERS)
|
||||
if rd.status_code == 200:
|
||||
print_result('delete event_badge', True, '')
|
||||
else:
|
||||
print_result('delete event_badge', False, f'status={rd.status_code} body={rd.text}')
|
||||
|
||||
# Delete person (as child of event)
|
||||
delp_url = f"{API_BASE}/event/{EVENT_ID}/event_person/{person_id}?method=delete"
|
||||
rp = requests.delete(delp_url, headers=HEADERS)
|
||||
if rp.status_code == 200:
|
||||
print_result('delete event_person', True, '')
|
||||
else:
|
||||
print_result('delete event_person', False, f'status={rp.status_code} body={rp.text}')
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print_result('exception', False, str(e))
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(run())
|
||||
@@ -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")
|
||||
|
||||
498
tests/e2e/test_e2e_v3_user_action_routes.py
Normal file
498
tests/e2e/test_e2e_v3_user_action_routes.py
Normal file
@@ -0,0 +1,498 @@
|
||||
"""
|
||||
E2E Tests: V3 User Action Routes (app/routers/api_v3_actions_user.py)
|
||||
======================================================================
|
||||
Covers the new V3 action endpoints under /v3/action/user/:
|
||||
- POST /v3/action/user/authenticate
|
||||
- POST /v3/action/user/verify_password
|
||||
- POST /v3/action/user/{user_id}/change_password
|
||||
- GET /v3/action/user/{user_id}/new_auth_key
|
||||
- GET /v3/action/user/{user_id}/email_auth_key_url
|
||||
|
||||
Setup: creates a temporary test user via V3 CRUD; tears down on completion.
|
||||
|
||||
Run from project root:
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_user_action_routes.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
# --- Configuration ---
|
||||
API_ROOT = "https://dev-api.oneskyit.com"
|
||||
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
|
||||
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo account
|
||||
|
||||
V3_HEADERS = {
|
||||
"x-aether-api-key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID,
|
||||
}
|
||||
|
||||
TEST_PASSWORD = "TestAction1234!" # >= 10 chars
|
||||
NEW_PASSWORD = "NewAction5678!" # used after change_password tests
|
||||
|
||||
# Populated during setup
|
||||
_test_user_id = None # Vision ID (random string)
|
||||
_test_username = None
|
||||
_test_email = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
print(f" [{status}] {label}" + (f" — {message}" if message else ""))
|
||||
|
||||
|
||||
def assert_vision_id(obj, field_name="user_id"):
|
||||
"""Returns True if field is a non-empty string of length 11–22 (Vision ID)."""
|
||||
val = obj.get(field_name) if isinstance(obj, dict) else None
|
||||
return isinstance(val, str) and 11 <= len(val) <= 22
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup / Teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_test_user():
|
||||
"""Create a temporary test user via V3 CRUD. Returns the Vision ID or None."""
|
||||
global _test_user_id, _test_username, _test_email
|
||||
|
||||
ts = int(time.time())
|
||||
_test_username = f"test_v3act_e2e_{ts}"
|
||||
_test_email = f"test_v3act_e2e_{ts}@test.invalid"
|
||||
|
||||
payload = {
|
||||
"account_id": ACCOUNT_ID,
|
||||
"username": _test_username,
|
||||
"name": "E2E V3 Action Test User",
|
||||
"email": _test_email,
|
||||
"new_password": TEST_PASSWORD,
|
||||
"enable": True,
|
||||
"allow_auth_key": True,
|
||||
}
|
||||
|
||||
resp = requests.post(f"{API_ROOT}/v3/crud/user/", json=payload, headers=V3_HEADERS)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f" [SETUP ❌] Failed to create test user — HTTP {resp.status_code}")
|
||||
print(f" {resp.text[:300]}")
|
||||
return None
|
||||
|
||||
data = resp.json().get("data", {})
|
||||
_test_user_id = data.get("user_id") or data.get("id")
|
||||
|
||||
if not _test_user_id:
|
||||
print(f" [SETUP ❌] Test user created but no Vision ID returned: {data}")
|
||||
return None
|
||||
|
||||
print(f" [SETUP ✅] Test user created — user_id={_test_user_id} username={_test_username}")
|
||||
return _test_user_id
|
||||
|
||||
|
||||
def teardown_test_user(user_id):
|
||||
"""Delete the test user via V3 CRUD."""
|
||||
if not user_id:
|
||||
return
|
||||
resp = requests.delete(f"{API_ROOT}/v3/crud/user/{user_id}", headers=V3_HEADERS)
|
||||
if resp.status_code == 200:
|
||||
print(f" [TEARDOWN ✅] Test user deleted — user_id={user_id}")
|
||||
else:
|
||||
print(f" [TEARDOWN ❌] Failed to delete test user — HTTP {resp.status_code} {resp.text[:200]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# authenticate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_authenticate_username_password():
|
||||
"""POST /v3/action/user/authenticate — valid username + password."""
|
||||
print("\n--- authenticate ---")
|
||||
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"username": _test_username, "password": TEST_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data", {})
|
||||
vision_ok = assert_vision_id(data, "user_id")
|
||||
success = resp.status_code == 200 and vision_ok
|
||||
print_result("Valid username+password", success,
|
||||
f"HTTP {resp.status_code}" + ("" if vision_ok else " — missing Vision ID"))
|
||||
return success
|
||||
|
||||
|
||||
def test_authenticate_wrong_password():
|
||||
"""POST /v3/action/user/authenticate — wrong password → 403."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"username": _test_username, "password": "WrongPassword999!"},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 403
|
||||
print_result("Wrong password → 403", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_authenticate_unknown_user():
|
||||
"""POST /v3/action/user/authenticate — unknown username → 404."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"username": "no_such_user_xyzzy", "password": TEST_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 404
|
||||
print_result("Unknown username → 404", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_authenticate_missing_fields():
|
||||
"""POST /v3/action/user/authenticate — no credentials → 400."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"username": _test_username}, # password missing
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 400
|
||||
print_result("Missing credentials → 400", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_authenticate_auth_key_flow():
|
||||
"""
|
||||
Full auth-key flow:
|
||||
1. GET new_auth_key → get a key
|
||||
2. POST authenticate with user_id + auth_key → success
|
||||
3. POST authenticate again with same key → 404 (key cleared)
|
||||
"""
|
||||
print("\n--- authenticate (auth_key flow) ---")
|
||||
|
||||
# Step 1: generate key
|
||||
resp1 = requests.get(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/new_auth_key",
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
if resp1.status_code != 200:
|
||||
print_result("Auth key flow — generate key", False, f"HTTP {resp1.status_code}")
|
||||
return False
|
||||
key = resp1.json().get("data", {}).get("auth_key")
|
||||
if not key:
|
||||
print_result("Auth key flow — generate key", False, "No auth_key in response")
|
||||
return False
|
||||
|
||||
# Step 2: authenticate with key
|
||||
resp2 = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"user_id": _test_user_id, "auth_key": key},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data2 = resp2.json().get("data", {})
|
||||
step2_ok = resp2.status_code == 200 and assert_vision_id(data2, "user_id")
|
||||
print_result("Auth key flow — first use succeeds", step2_ok,
|
||||
f"HTTP {resp2.status_code}")
|
||||
|
||||
# Step 3: replay must fail (key is cleared)
|
||||
resp3 = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"user_id": _test_user_id, "auth_key": key},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
step3_ok = resp3.status_code == 404
|
||||
print_result("Auth key flow — replay → 404 (one-time-use)", step3_ok,
|
||||
f"HTTP {resp3.status_code}")
|
||||
|
||||
return step2_ok and step3_ok
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_password
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_verify_password_by_user_id():
|
||||
"""POST /v3/action/user/verify_password — correct password by user_id."""
|
||||
print("\n--- verify_password ---")
|
||||
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/verify_password",
|
||||
json={"user_id": _test_user_id, "current_password": TEST_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
# Primitive True is wrapped as {"result": True}
|
||||
result = data.get("result") if isinstance(data, dict) else data
|
||||
success = resp.status_code == 200 and result is True
|
||||
print_result("Correct password by user_id → True", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_verify_password_by_username():
|
||||
"""POST /v3/action/user/verify_password — correct password by username."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/verify_password",
|
||||
json={"username": _test_username, "current_password": TEST_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
result = data.get("result") if isinstance(data, dict) else data
|
||||
success = resp.status_code == 200 and result is True
|
||||
print_result("Correct password by username → True", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_verify_password_wrong():
|
||||
"""POST /v3/action/user/verify_password — wrong password → 403."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/verify_password",
|
||||
json={"user_id": _test_user_id, "current_password": "WrongPassword999!"},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 403
|
||||
print_result("Wrong password → 403", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_verify_password_no_identifier():
|
||||
"""POST /v3/action/user/verify_password — no user_id or username → 400."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/verify_password",
|
||||
json={"current_password": TEST_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 400
|
||||
print_result("No identifier → 400", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# change_password
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_change_password_no_verification():
|
||||
"""POST /v3/action/user/{id}/change_password — no current_password (admin reset)."""
|
||||
print("\n--- change_password ---")
|
||||
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
|
||||
json={"new_password": NEW_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
result = data.get("result") if isinstance(data, dict) else data
|
||||
success = resp.status_code == 200 and result is True
|
||||
print_result("Change password (no verification)", success, f"HTTP {resp.status_code}")
|
||||
|
||||
# Verify the new password works
|
||||
resp2 = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/verify_password",
|
||||
json={"user_id": _test_user_id, "current_password": NEW_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data2 = resp2.json().get("data")
|
||||
r2 = data2.get("result") if isinstance(data2, dict) else data2
|
||||
verify_ok = resp2.status_code == 200 and r2 is True
|
||||
print_result("New password accepted by verify_password", verify_ok,
|
||||
f"HTTP {resp2.status_code}")
|
||||
|
||||
return success and verify_ok
|
||||
|
||||
|
||||
def test_change_password_with_verification():
|
||||
"""POST /v3/action/user/{id}/change_password — with correct current_password."""
|
||||
# Password is currently NEW_PASSWORD (set by previous test)
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
|
||||
json={"current_password": NEW_PASSWORD, "new_password": TEST_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
result = data.get("result") if isinstance(data, dict) else data
|
||||
success = resp.status_code == 200 and result is True
|
||||
print_result("Change password with correct current_password", success,
|
||||
f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_change_password_wrong_current():
|
||||
"""POST /v3/action/user/{id}/change_password — wrong current_password → 403."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
|
||||
json={"current_password": "WrongPassword999!", "new_password": NEW_PASSWORD},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 403
|
||||
print_result("Wrong current_password → 403", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_change_password_too_short():
|
||||
"""POST /v3/action/user/{id}/change_password — new_password < 10 chars → 422."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/change_password",
|
||||
json={"new_password": "short"},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
# Pydantic validation rejects min_length constraint with 422 Unprocessable Entity
|
||||
success = resp.status_code == 422
|
||||
print_result("new_password too short → 422", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
def test_change_password_bad_user():
|
||||
"""POST /v3/action/user/{id}/change_password — invalid user_id → 404."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/change_password",
|
||||
json={"new_password": "ValidPassword123!"},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 404
|
||||
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_auth_key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_auth_key():
|
||||
"""GET /v3/action/user/{user_id}/new_auth_key — generates and returns key."""
|
||||
print("\n--- new_auth_key ---")
|
||||
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/new_auth_key",
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data", {})
|
||||
key = data.get("auth_key") if isinstance(data, dict) else None
|
||||
success = resp.status_code == 200 and isinstance(key, str) and len(key) >= 11
|
||||
print_result("Returns new auth_key string", success,
|
||||
f"HTTP {resp.status_code}" + (f" key={key!r}" if success else ""))
|
||||
return success
|
||||
|
||||
|
||||
def test_new_auth_key_bad_user():
|
||||
"""GET /v3/action/user/{user_id}/new_auth_key — invalid user → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/new_auth_key",
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 404
|
||||
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# email_auth_key_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_email_auth_key_url():
|
||||
"""GET /v3/action/user/{user_id}/email_auth_key_url — sends or fails gracefully."""
|
||||
print("\n--- email_auth_key_url ---")
|
||||
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/v3/action/user/{_test_user_id}/email_auth_key_url",
|
||||
params={"root_url": "https://test.invalid/login"},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
# 200 = email sent; 500 = delivery failed (.invalid domain) — both are acceptable.
|
||||
success = resp.status_code in (200, 500)
|
||||
print_result(
|
||||
"email_auth_key_url (200=sent, 500=delivery failed — both OK for .invalid domain)",
|
||||
success, f"HTTP {resp.status_code}"
|
||||
)
|
||||
return success
|
||||
|
||||
|
||||
def test_email_auth_key_url_bad_user():
|
||||
"""GET /v3/action/user/{user_id}/email_auth_key_url — invalid user → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/v3/action/user/AAAAAAAAAAA/email_auth_key_url",
|
||||
params={"root_url": "https://test.invalid/login"},
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 404
|
||||
print_result("Invalid user_id → 404", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth guard checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_api_key():
|
||||
"""All V3 action endpoints require x-aether-api-key — missing → 403."""
|
||||
print("\n--- auth guards ---")
|
||||
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/action/user/authenticate",
|
||||
json={"username": _test_username, "password": TEST_PASSWORD},
|
||||
headers={"x-account-id": ACCOUNT_ID}, # no API key
|
||||
)
|
||||
success = resp.status_code == 403
|
||||
print_result("No API key → 403", success, f"HTTP {resp.status_code}")
|
||||
return success
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_suite():
|
||||
start = time.time()
|
||||
print("=" * 60)
|
||||
print("E2E: V3 User Action Routes")
|
||||
print("=" * 60)
|
||||
|
||||
if not setup_test_user():
|
||||
print("\n[ABORT] Setup failed — cannot run tests.\n")
|
||||
return
|
||||
|
||||
results = []
|
||||
|
||||
# authenticate
|
||||
results.append(test_authenticate_username_password())
|
||||
results.append(test_authenticate_wrong_password())
|
||||
results.append(test_authenticate_unknown_user())
|
||||
results.append(test_authenticate_missing_fields())
|
||||
results.append(test_authenticate_auth_key_flow())
|
||||
|
||||
# verify_password
|
||||
results.append(test_verify_password_by_user_id())
|
||||
results.append(test_verify_password_by_username())
|
||||
results.append(test_verify_password_wrong())
|
||||
results.append(test_verify_password_no_identifier())
|
||||
|
||||
# change_password (order matters — each test assumes the password left by the previous)
|
||||
results.append(test_change_password_no_verification()) # TEST → NEW
|
||||
results.append(test_change_password_with_verification()) # NEW → TEST
|
||||
results.append(test_change_password_wrong_current()) # bad → 403 (no change)
|
||||
results.append(test_change_password_too_short()) # bad → 422
|
||||
results.append(test_change_password_bad_user()) # 404
|
||||
|
||||
# new_auth_key
|
||||
results.append(test_new_auth_key())
|
||||
results.append(test_new_auth_key_bad_user())
|
||||
|
||||
# email_auth_key_url
|
||||
results.append(test_email_auth_key_url())
|
||||
results.append(test_email_auth_key_url_bad_user())
|
||||
|
||||
# auth guards
|
||||
results.append(test_no_api_key())
|
||||
|
||||
teardown_test_user(_test_user_id)
|
||||
|
||||
elapsed = time.time() - start
|
||||
passed = sum(1 for r in results if r)
|
||||
total = len(results)
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Results: {passed}/{total} passed ({elapsed:.2f}s)")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_suite()
|
||||
511
tests/e2e/test_e2e_v3_user_auth_routes.py
Normal file
511
tests/e2e/test_e2e_v3_user_auth_routes.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""
|
||||
E2E Tests: User Auth Routes (app/routers/user.py)
|
||||
==================================================
|
||||
Covers the active legacy user routes that are marked for migration to V3:
|
||||
- PATCH /user/{user_id}/change_password
|
||||
- GET /user/{user_id}/new_auth_key
|
||||
- GET /user/authenticate ← KNOWN BUG: decorator accidentally commented out
|
||||
- POST /user/verify_password
|
||||
- GET /user/lookup
|
||||
- GET /user/lookup_email
|
||||
- GET /user/lookup_username
|
||||
- GET /user/{user_id}/email_auth_key_url
|
||||
|
||||
Run from project root:
|
||||
./environment/bin/python3 tests/e2e/test_e2e_v3_user_auth_routes.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import requests
|
||||
|
||||
sys.path.append(os.getcwd())
|
||||
|
||||
# --- Configuration ---
|
||||
API_ROOT = "https://dev-api.oneskyit.com"
|
||||
API_KEY = "PMM4n50teUCaOMMTN8qOJA"
|
||||
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo account
|
||||
|
||||
# Standard headers for V3 CRUD (create/delete the test user)
|
||||
V3_HEADERS = {
|
||||
"x-aether-api-key": API_KEY,
|
||||
"x-account-id": ACCOUNT_ID,
|
||||
}
|
||||
# Legacy routes use the same headers (Common_Route_Params reads x-account-id)
|
||||
LEGACY_HEADERS = V3_HEADERS
|
||||
|
||||
TEST_PASSWORD = "TestAuth1234!" # >= 10 chars
|
||||
NEW_PASSWORD = "NewTestPwd5678!" # used after change_password
|
||||
|
||||
# Populated during setup
|
||||
_test_user_id = None # Vision ID (random string)
|
||||
_test_username = None
|
||||
_test_email = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def print_result(label, success, message=""):
|
||||
status = "✅ PASS" if success else "❌ FAIL"
|
||||
print(f" [{status}] {label}" + (f" — {message}" if message else ""))
|
||||
|
||||
|
||||
def assert_vision_id(obj_dict, field_name="user_id"):
|
||||
"""Returns True if the given field is a string (Vision ID), not an int."""
|
||||
val = obj_dict.get(field_name)
|
||||
return isinstance(val, str) and 11 <= len(val) <= 22
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup / Teardown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_test_user():
|
||||
"""Create a temporary test user via V3 CRUD. Returns the Vision ID or None."""
|
||||
global _test_user_id, _test_username, _test_email
|
||||
|
||||
ts = int(time.time())
|
||||
_test_username = f"test_auth_e2e_{ts}"
|
||||
_test_email = f"test_auth_e2e_{ts}@test.invalid"
|
||||
|
||||
payload = {
|
||||
"account_id": ACCOUNT_ID,
|
||||
"username": _test_username,
|
||||
"name": "E2E Auth Test User",
|
||||
"email": _test_email,
|
||||
"new_password": TEST_PASSWORD,
|
||||
"enable": True,
|
||||
"allow_auth_key": True, # needed for new_auth_key / email_auth_key_url tests
|
||||
}
|
||||
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/v3/crud/user/",
|
||||
json=payload,
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print(f" [SETUP ❌] Failed to create test user — HTTP {resp.status_code}")
|
||||
print(f" {resp.text[:300]}")
|
||||
return None
|
||||
|
||||
data = resp.json().get("data", {})
|
||||
_test_user_id = data.get("user_id") or data.get("id")
|
||||
|
||||
if not _test_user_id:
|
||||
print(f" [SETUP ❌] Test user created but no Vision ID returned: {data}")
|
||||
return None
|
||||
|
||||
print(f" [SETUP ✅] Test user created — user_id={_test_user_id} username={_test_username}")
|
||||
return _test_user_id
|
||||
|
||||
|
||||
def teardown_test_user(user_id):
|
||||
"""Delete the test user via V3 CRUD."""
|
||||
if not user_id:
|
||||
return
|
||||
resp = requests.delete(
|
||||
f"{API_ROOT}/v3/crud/user/{user_id}",
|
||||
headers=V3_HEADERS,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print(f" [TEARDOWN ✅] Test user deleted — user_id={user_id}")
|
||||
else:
|
||||
print(f" [TEARDOWN ❌] Failed to delete test user — HTTP {resp.status_code} {resp.text[:200]}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# change_password
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_change_password():
|
||||
"""PATCH /user/{user_id}/change_password — valid new password."""
|
||||
print("\n--- change_password ---")
|
||||
|
||||
resp = requests.patch(
|
||||
f"{API_ROOT}/user/{_test_user_id}/change_password",
|
||||
json={"password": NEW_PASSWORD},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
success = resp.status_code == 200 and resp.json().get("data") is not False
|
||||
print_result("Valid password change", success,
|
||||
f"HTTP {resp.status_code}" if not success else "")
|
||||
return success
|
||||
|
||||
|
||||
def test_change_password_too_short():
|
||||
"""PATCH /user/{user_id}/change_password — password < 10 chars → 400."""
|
||||
resp = requests.patch(
|
||||
f"{API_ROOT}/user/{_test_user_id}/change_password",
|
||||
json={"password": "short"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Short password rejected (400)", resp.status_code == 400,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
def test_change_password_missing_field():
|
||||
"""PATCH /user/{user_id}/change_password — no password field → 400."""
|
||||
resp = requests.patch(
|
||||
f"{API_ROOT}/user/{_test_user_id}/change_password",
|
||||
json={"not_password": "whatever"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Missing password field rejected (400)", resp.status_code == 400,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
def test_change_password_invalid_user():
|
||||
"""PATCH /user/{invalid_id}/change_password → 404."""
|
||||
resp = requests.patch(
|
||||
f"{API_ROOT}/user/NotARealUserID99/change_password",
|
||||
json={"password": "ValidPassword123!"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Invalid user_id rejected (404)", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# new_auth_key
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_new_auth_key():
|
||||
"""GET /user/{user_id}/new_auth_key — generates and returns a new key."""
|
||||
print("\n--- new_auth_key ---")
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/{_test_user_id}/new_auth_key",
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data", {})
|
||||
has_key = isinstance(data, dict) and bool(data.get("auth_key"))
|
||||
print_result("New auth_key generated", resp.status_code == 200 and has_key,
|
||||
f"HTTP {resp.status_code}")
|
||||
return data.get("auth_key") if has_key else None
|
||||
|
||||
|
||||
def test_new_auth_key_invalid_user():
|
||||
"""GET /user/{invalid_id}/new_auth_key → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/NotARealUserID99/new_auth_key",
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Invalid user_id rejected (404)", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_password
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _verify_result(resp) -> bool:
|
||||
"""Extract the boolean result from a legacy mk_resp response.
|
||||
Primitive data is wrapped as {"data": {"result": value}}.
|
||||
"""
|
||||
data = resp.json().get("data", {})
|
||||
if isinstance(data, dict):
|
||||
return data.get("result")
|
||||
return data
|
||||
|
||||
|
||||
def test_verify_password_by_username_correct():
|
||||
"""POST /user/verify_password — correct password via username → result True."""
|
||||
print("\n--- verify_password ---")
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/user/verify_password",
|
||||
json={"username": _test_username, "current_password": NEW_PASSWORD},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
result = _verify_result(resp)
|
||||
success = resp.status_code == 200 and result is True
|
||||
print_result("Correct password (username path)", success,
|
||||
f"HTTP {resp.status_code} result={result}")
|
||||
|
||||
|
||||
def test_verify_password_by_username_wrong():
|
||||
"""POST /user/verify_password — wrong password → result not True."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/user/verify_password",
|
||||
json={"username": _test_username, "current_password": "WrongPassword999!"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
result = _verify_result(resp)
|
||||
success = result is not True
|
||||
print_result("Wrong password rejected", success,
|
||||
f"HTTP {resp.status_code} result={result}")
|
||||
|
||||
|
||||
def test_verify_password_by_user_id():
|
||||
"""
|
||||
POST /user/verify_password — correct password via Vision ID ('id' field).
|
||||
|
||||
The handler reads user_obj.id (User_Base Vision ID field). Send the
|
||||
Vision ID as 'id' in the request body.
|
||||
"""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/user/verify_password",
|
||||
json={"id": _test_user_id, "current_password": NEW_PASSWORD},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
result = _verify_result(resp)
|
||||
success = resp.status_code == 200 and result is True
|
||||
print_result("Correct password (Vision ID / 'id' path)", success,
|
||||
f"HTTP {resp.status_code} result={result}")
|
||||
|
||||
|
||||
def test_verify_password_missing_fields():
|
||||
"""POST /user/verify_password — no user_id or username → 400."""
|
||||
resp = requests.post(
|
||||
f"{API_ROOT}/user/verify_password",
|
||||
json={"current_password": NEW_PASSWORD},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Missing user fields rejected (400)", resp.status_code == 400,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lookup, lookup_email, lookup_username
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_lookup_by_account():
|
||||
"""GET /user/lookup?for_obj_type=account&for_obj_id={account_id} — returns user list."""
|
||||
print("\n--- lookup ---")
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup",
|
||||
params={"for_obj_type": "account", "for_obj_id": ACCOUNT_ID},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
success = resp.status_code == 200 and isinstance(data, list) and len(data) > 0
|
||||
print_result("Lookup by account (list)", success, f"HTTP {resp.status_code} count={len(data) if isinstance(data, list) else 'n/a'}")
|
||||
|
||||
# Vision ID check on first result
|
||||
if success and isinstance(data, list) and data:
|
||||
has_vision_id = assert_vision_id(data[0], "user_id")
|
||||
print_result("Vision ID compliance (user_id is string)", has_vision_id,
|
||||
f"user_id={data[0].get('user_id')!r}")
|
||||
|
||||
|
||||
def test_lookup_by_person_invalid():
|
||||
"""GET /user/lookup?for_obj_type=person&for_obj_id={bad_id} → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup",
|
||||
params={"for_obj_type": "person", "for_obj_id": "NotARealUID999"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Invalid person ID rejected (404)", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
def test_lookup_bad_obj_type():
|
||||
"""GET /user/lookup?for_obj_type=invalid → 404.
|
||||
The redis lookup for for_obj_id against an unknown table returns None,
|
||||
which triggers the 404 before the 400 type-check is reached.
|
||||
"""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup",
|
||||
params={"for_obj_type": "invoice", "for_obj_id": ACCOUNT_ID},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Unsupported for_obj_type returns 404", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
def test_lookup_email():
|
||||
"""GET /user/lookup_email?email={email} — finds the test user."""
|
||||
print("\n--- lookup_email ---")
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup_email",
|
||||
params={"email": _test_email},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
found = (
|
||||
resp.status_code == 200
|
||||
and isinstance(data, dict)
|
||||
and data.get("email") == _test_email
|
||||
)
|
||||
print_result("Lookup by email (found)", found, f"HTTP {resp.status_code}")
|
||||
|
||||
if found:
|
||||
has_vision_id = assert_vision_id(data, "user_id")
|
||||
print_result("Vision ID compliance (user_id is string)", has_vision_id,
|
||||
f"user_id={data.get('user_id')!r}")
|
||||
|
||||
|
||||
def test_lookup_email_not_found():
|
||||
"""GET /user/lookup_email?email={nonexistent} → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup_email",
|
||||
params={"email": "nobody_at_all@test.invalid"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Nonexistent email → 404", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
def test_lookup_username():
|
||||
"""GET /user/lookup_username?username={username} — finds the test user."""
|
||||
print("\n--- lookup_username ---")
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup_username",
|
||||
params={"username": _test_username},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
found = (
|
||||
resp.status_code == 200
|
||||
and isinstance(data, dict)
|
||||
and data.get("username") == _test_username
|
||||
)
|
||||
print_result("Lookup by username (found)", found, f"HTTP {resp.status_code}")
|
||||
|
||||
if found:
|
||||
has_vision_id = assert_vision_id(data, "user_id")
|
||||
print_result("Vision ID compliance (user_id is string)", has_vision_id,
|
||||
f"user_id={data.get('user_id')!r}")
|
||||
|
||||
|
||||
def test_lookup_username_not_found():
|
||||
"""GET /user/lookup_username?username={nonexistent} → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/lookup_username",
|
||||
params={"username": "no_such_user_xyz_99999"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Nonexistent username → 404", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# email_auth_key_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_email_auth_key_url():
|
||||
"""
|
||||
GET /user/{user_id}/email_auth_key_url — generates auth key and sends email.
|
||||
|
||||
NOTE: The test user email uses '@test.invalid' domain, so actual mail
|
||||
delivery will fail. This test verifies the route responds correctly;
|
||||
expect HTTP 500 if the mail server rejects the send. The auth key IS
|
||||
generated and stored regardless of email success.
|
||||
"""
|
||||
print("\n--- email_auth_key_url ---")
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/{_test_user_id}/email_auth_key_url",
|
||||
params={"root_url": "https://dev-app.oneskyit.com"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
# 200 = email sent; 500 = route hit but email delivery failed (acceptable for .invalid)
|
||||
route_hit = resp.status_code in [200, 500]
|
||||
print_result("Route reachable", route_hit, f"HTTP {resp.status_code}"
|
||||
+ (" (email delivery failed — expected for .invalid domain)" if resp.status_code == 500 else ""))
|
||||
|
||||
|
||||
def test_email_auth_key_url_invalid_user():
|
||||
"""GET /user/{invalid_id}/email_auth_key_url → 404."""
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/NotARealUserID99/email_auth_key_url",
|
||||
params={"root_url": "https://dev-app.oneskyit.com"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Invalid user_id rejected (404)", resp.status_code == 404,
|
||||
f"HTTP {resp.status_code}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG VERIFICATION: user_authenticate route
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_authenticate():
|
||||
"""
|
||||
GET /user/authenticate — authenticate with username + password.
|
||||
|
||||
Note: The @router.get() decorator was accidentally commented out in a
|
||||
prior version (user.py line 226). That bug has been fixed. This test
|
||||
verifies the route is reachable and returns user data on success.
|
||||
"""
|
||||
print("\n--- authenticate ---")
|
||||
resp = requests.get(
|
||||
f"{API_ROOT}/user/authenticate",
|
||||
params={"username": _test_username, "password": NEW_PASSWORD},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
data = resp.json().get("data")
|
||||
success = resp.status_code == 200 and isinstance(data, dict) and bool(data.get("user_id") or data.get("id"))
|
||||
print_result("authenticate (username+password)", success, f"HTTP {resp.status_code}")
|
||||
|
||||
if success:
|
||||
has_vision_id = assert_vision_id(data, "user_id")
|
||||
print_result("Vision ID compliance (user_id is string)", has_vision_id,
|
||||
f"user_id={data.get('user_id')!r}")
|
||||
|
||||
# Wrong password should be rejected
|
||||
resp2 = requests.get(
|
||||
f"{API_ROOT}/user/authenticate",
|
||||
params={"username": _test_username, "password": "WrongPassword000!"},
|
||||
headers=LEGACY_HEADERS,
|
||||
)
|
||||
print_result("Wrong password rejected", resp2.status_code in [200, 404] and resp2.json().get("data") is not True,
|
||||
f"HTTP {resp2.status_code}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
suite_start = time.time()
|
||||
print("=" * 60)
|
||||
print("User Auth Routes E2E Test Suite")
|
||||
print(f"API: {API_ROOT}")
|
||||
print("=" * 60)
|
||||
|
||||
# --- Setup ---
|
||||
print("\n[Setup]")
|
||||
user_id = setup_test_user()
|
||||
if not user_id:
|
||||
print("\n❌ Setup failed — cannot run tests. Aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Tests ---
|
||||
test_change_password()
|
||||
test_change_password_too_short()
|
||||
test_change_password_missing_field()
|
||||
test_change_password_invalid_user()
|
||||
|
||||
test_new_auth_key()
|
||||
test_new_auth_key_invalid_user()
|
||||
|
||||
test_verify_password_by_username_correct()
|
||||
test_verify_password_by_username_wrong()
|
||||
test_verify_password_by_user_id()
|
||||
test_verify_password_missing_fields()
|
||||
|
||||
test_lookup_by_account()
|
||||
test_lookup_by_person_invalid()
|
||||
test_lookup_bad_obj_type()
|
||||
|
||||
test_lookup_email()
|
||||
test_lookup_email_not_found()
|
||||
|
||||
test_lookup_username()
|
||||
test_lookup_username_not_found()
|
||||
|
||||
test_email_auth_key_url()
|
||||
test_email_auth_key_url_invalid_user()
|
||||
|
||||
test_authenticate()
|
||||
|
||||
# --- Teardown ---
|
||||
print("\n[Teardown]")
|
||||
teardown_test_user(user_id)
|
||||
|
||||
elapsed = time.time() - suite_start
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Suite completed in {elapsed:.2f}s")
|
||||
print("=" * 60)
|
||||
@@ -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