52 Commits

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 10:15:13 -04:00
Scott Idem
c8377a2b22 Version bump because things are in a good state overall. 2026-05-13 17:33:33 -04:00
Scott Idem
f6ba339276 Add archive_content fields and docs 2026-05-07 18:42:43 -04:00
Scott Idem
ed66ba4bd4 fix(post): retain topic_id and note review 2026-05-01 18:20:10 -04:00
Scott Idem
44e4f5c4e6 feat: migrate email send to V3 action; deprecate api.py legacy endpoints
- Add /v3/action/email/send router (api_v3_actions_email.py) replacing /util/email/send
- Disable util_email router in registry; register new email action router
- Mark /api/request_jwt and /api/temp_token as deprecated (TODO: remove)
- Guide: add §8 Email Send Action, mark Axonius section EXPIRED, renumber §9-§11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 14:44:28 -04:00
Scott Idem
c378040ad4 Updated docs 2026-04-30 17:14:42 -04:00
Scott Idem
b590bc09a0 fix: require root_url on email_auth_key_url; correct frontend guide for user auth endpoints
- Make root_url a required query param on GET /v3/action/user/{id}/email_auth_key_url
  (previously Optional[str]=None, which produced a malformed link in the emailed URL)
- Update GUIDE__AE_API_V3_for_Frontend.md: document root_url as required, add magic link
  URL format, note valid_email=True side effect, add 404 error, expand 403 conditions
  for authenticate, add 400 for verify_password when no password is set
- Add test_e2e_v3_user_action_routes.py and test_e2e_v3_user_auth_routes.py to tests/README.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:34:49 -04:00
Scott Idem
e71906b59a Saving stress testing 2026-04-19 13:57:31 -04:00
Scott Idem
3d89e95c24 fix(P2): add OperationalError retry to sql_insert, sql_select, sql_insert_or_update
All three were missing the transient-connection retry that sql_update and
run_sql_select already had. On OperationalError (stale/dropped connection),
each now retries once with a fresh engine.connect() without disposing the pool.

IntegrityError (duplicate key, FK violation, NOT NULL) continues to return
None without retrying — the same data would fail again and None signals a
data conflict to callers, distinct from False (error) or an int (success).

sql_insert_or_update retry is safe because ON DUPLICATE KEY UPDATE is idempotent.
sql_insert retry is safe because OperationalError means MariaDB rolled back.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:41:26 -04:00
Scott Idem
3db5f7c749 fix(P3): guard startup db connection with try/except in lib_sql_core
Wraps the deprecated global `db = engine.connect()` in a try/except so
a Docker startup race (MariaDB not yet ready) no longer crashes the
Gunicorn worker before it can serve any requests.

Sets db=None on failure; reconnect_db() on the lifespan bootstrap path
re-establishes it once credentials are confirmed.

TODO (P3 full): migrate lib_schema_v3.py:39 and lib_api_crud_v3.py:166
off the global db to engine.connect() context managers, then remove it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 19:28:28 -04:00
Scott Idem
55debc8009 feat: add stress_list_queries tool and document in tests/README
Concurrent read-only stress test against V3 list endpoints.
Improvements over initial version: --base-url, --limit CLI flags,
interpolated percentile calculation (accurate on small sample sizes),
and pre-sorted times passed to overall summary.
README: added tools table with quick-reference usage examples.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:12:01 -04:00
Scott Idem
ace00929f2 feat: expose DB pool_size and max_overflow as env vars (P4)
Added AE_DB_POOL_SIZE and AE_DB_POOL_MAX_OVERFLOW to config.py with
defaults matching prior hardcoded values (10/20). Wired into settings.DB
property so create_ae_engine() reads them without fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 18:08:01 -04:00
Scott Idem
c7444a8a89 fix: remove pool-nuking reconnect_db() from OperationalError retry paths
On OperationalError, sql_update and run_sql_select were calling
sql_connect() → reconnect_db() which disposes the entire connection
pool mid-flight, killing other in-flight connections under concurrency.

Removed the sql_connect() calls; the existing retry blocks already open
a fresh engine.connect() context manager, and pool_pre_ping=True handles
stale connection detection. Also drops the now-unused sql_connect import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 17:24:47 -04:00
Scott Idem
8f1fe5d4df Fixing this:
#3 (zombie import) is genuinely a 2-line fix — remove the import from api.py:10 and move db_connection.py to trash. Zero functional change since db is only in a commented-out line.
2026-04-16 20:09:12 -04:00
Scott Idem
c0626e061e fix: remove account_id injection from nested POST handler
Child objects in the nested endpoint inherit account context from their
parent via the FK relationship and do not carry their own account_id
column (e.g. event_badge, journal_entry). Injecting account_id into
data_to_insert would cause INSERT failures for any child whose table
has no account_id column but whose model has the field (from the view).

The original code set account_id in obj_data before model instantiation,
where the root_validator immediately stripped it — a harmless no-op.
The previous commit turned that dead line into a live injection by moving
it after serialization, which would break journal_entry creates on
non-bypass auth. Removed entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 20:22:20 -04:00
Scott Idem
dfb5289188 fix: skip account_id injection when model excludes it from DB writes
After the sanitize_payload order fix, account_id was being re-injected
into data_to_insert for models that explicitly list account_id in
fields_to_exclude_from_db (e.g. event_badge, event_device). Those tables
have no account_id column, causing INSERT failures.

Guard the post-sanitize account_id injection in both api_crud_v3.py and
api_crud_v3_nested.py by checking fields_to_exclude_from_db first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:09:11 -04:00
Scott Idem
0ecc5a97d5 fix: resolve secondary FKs in nested POST (event_badge_template_id)
In the nested POST handler (api_crud_v3_nested.py), sanitize_payload was
running before model instantiation. For secondary FK fields like
event_badge_template_id, sanitize_payload resolved the random string →
integer, then the model's root_validator stripped the integer back to None
(Vision ID anti-leakage guard). Only the parent FK survived because it was
explicitly re-injected after serialization.

Fix: moved sanitize_payload to run on data_to_insert after serialization,
matching the flat V3 POST pattern (api_crud_v3.py). Also moved account_id
injection to after sanitize_payload, fixing a latent bug where account_id
was silently written as NULL on non-bypass auth.

Adds regression test to test_e2e_v3_demo_parity.py that creates an
event_badge via nested POST with event_badge_template_id and verifies the
field is non-None in the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 19:00:51 -04:00
Scott Idem
516865b7d8 Updated docs 2026-04-10 11:56:44 -04:00
Scott Idem
7f9666dc1e fix: authenticate_passcode — priority ordering, full role flags, per-role TTL, min_length 2026-04-10 11:53:58 -04:00
Scott Idem
f9f588ddf2 docs: add temporary Axonius Zoom CSV Upload section (Apr 2026) 2026-04-08 12:38:36 -04:00
Scott Idem
ea25bf78d4 import: map marketing consent CSV column to event_badge.agree_to_tc and allow_tracking 2026-04-07 19:59:51 -04:00
Scott Idem
c837d465ca chore: remove temporary debug logging from event_badge_methods
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:10:39 -04:00
Scott Idem
2659047d24 fix: sql_update record_id missing on Vision ID models — update path now works
All create_update_*_v4 functions for event_badge, event_person,
event_person_profile, event_presenter, and event_presentation were
calling sql_update without record_id. Vision ID models use Optional[str]
IDs with a root_validator that strips integer values, so the serialized
dict contained no id key and sql_update could not identify the row.

Fix: pass record_id=<integer_id> explicitly to sql_update in all five
functions. Also fix walrus-operator false-negative: None return from
sql_update (0 rows affected — record unchanged) is not an error and
should not abort sub-object cascade; use explicit `is False` check.

Also broadens Axonius badge_type_code mapping to substring match so
future ticket name variants still resolve correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:50:04 -04:00
Scott Idem
18374f855f event: Zoom CSV import — temporary Axonius badge_type_code mapping (attendee/sponsor) 2026-04-07 15:36:23 -04:00
Scott Idem
e5acefe8f6 model: event_badge_template — treat other_json as Json to match DB 2026-04-07 13:27:30 -04:00
Scott Idem
082163b5df Spaces gone 2026-04-07 13:03:27 -04:00
Scott Idem
e35fdb4f67 event: Zoom CSV import — finalize mapping and cleanup (staged changes) 2026-04-07 13:02:34 -04:00
Scott Idem
02a2be7275 event: ensure event_id preserved on event_person insert by converting to id_random when available 2026-04-07 13:00:49 -04:00
Scott Idem
eba3456b7b model: event_badge_template — add background_image_path and cfg_json fields 2026-04-07 13:00:49 -04:00
Scott Idem
987b552157 event: Zoom CSV import — check for existing event_person by event_id+external_id before create; handle duplicates 2026-04-07 11:41:54 -04:00
Scott Idem
7ad158883a event: Zoom CSV import — force registrant email as external_id; ignore placeholder Unique identifier 2026-04-07 11:35:28 -04:00
Scott Idem
2b608d7a1a event: Zoom CSV import — default Axonius badge template 21 (temporary) 2026-04-07 11:23:36 -04:00
Scott Idem
535fc9f2b5 event: Zoom CSV import — use email as fallback external_id; populate address/phone fields 2026-04-07 10:58:08 -04:00
Scott Idem
8e9fb88e5a General file clean up. 2026-04-02 17:10:35 -04:00
Scott Idem
42eaa6676e Version bump just because. 2026-04-02 16:51:34 -04:00
Scott Idem
b5c50fd116 Changed the expiration time from 1 hour to 2 hours. 2026-04-02 15:57:36 -04:00
Scott Idem
2a1f270db6 feat(jitsi): add JWT token E2E test suite and improve api.py comments
- Add tests/e2e/test_e2e_jitsi_token.py: verifies moderator/attendee claims,
  room isolation, input validation, and exp claim correctness
- Update Jitsi section comment in api.py with actionable secret rotation TODO
  (must update JWT_APP_SECRET here AND in dgr_zone_jitsi .env, then restart
  prosody + jicofo)
2026-04-02 12:57:44 -04:00
Scott Idem
ebc5db96da fix(jitsi): allow non-moderators to request Jitsi tokens
Removed the 403 guard that blocked non-moderators. is_moderator is
already passed through to the token payload, so participants get
"moderator": false as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:46:27 -04:00
Scott Idem
153c2ce6dd models: add default_qry_str to event, session, presenter models 2026-03-31 16:24:25 -04:00
Scott Idem
9faf22d841 models: add default_qry_str to Journal_Entry_Base for API responses 2026-03-31 16:18:17 -04:00
Scott Idem
293f447a1c chore(site_domain): flesh out TODO stubs in legacy lookup routes
Uncommented and completed access_key + referrer handling in
lookup_site_domain_fqdn() and the GET /site/domain/fqdn/{fqdn} route.
These routes are disabled in registry.py and not currently active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:48:28 -04:00
Scott Idem
4629e1ec63 feat(site_domain): restore access_key enforcement for FQDN lookups
- api_crud_v3: strip falsy access_key values; restrict keyless queries
  to public domains (both site_access_key and site_domain_access_key
  must be NULL/empty); 75-line recursive block replaced with ~16 lines
- lib_sql_search: expand virtual 'access_key' field into priority SQL —
  site_access_key first, site_domain_access_key as fallback
- cms.py: add site_domain_access_key to site_domain searchable_fields
- docs: update frontend guide with access key behavior and examples
- e2e test: expand to cover all valid/invalid access key scenarios (15/15)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 14:46:33 -04:00
Scott Idem
1f9cbb0a1f Commit remaining changes: logging upgrades and E2E test 2026-03-27 11:47:35 -04:00
Scott Idem
7f87f32b70 Add INFO logging for nested parent-resolution and add E2E nested-create test for event_badge 2026-03-27 11:30:08 -04:00
56 changed files with 3338 additions and 964 deletions

View File

@@ -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.

View File

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

View File

@@ -43,9 +43,15 @@ def create_ae_engine(uri: str):
engine = create_ae_engine(db_uri)
# DEPRECATED: Global shared 'db' connection. Use engine.connect() in context managers instead.
# Keeping for legacy compatibility but will phase out usage in crud lib.
db = engine.connect()
# DEPRECATED: Global shared 'db' connection. Still used by lib_schema_v3.py and lib_api_crud_v3.py.
# TODO (P3 full fix): migrate those two call sites to engine.connect() context managers, then remove this.
# Bare connect guarded so a Docker startup race (MariaDB not yet ready) doesn't crash the worker.
# If this fails, db=None — callers that hit it before reconnect_db() runs will raise AttributeError.
try:
db = engine.connect()
except Exception:
log.warning("DB SQL Core: Initial db connection failed at startup (MariaDB not ready?). Will retry via reconnect_db().")
db = None
log.info('DB SQL Core: Initializing engine...')

View File

@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
from app.log import log, logger_reset
# CRITICAL: Import the core module to access current global state
from app import lib_sql_core
from app.lib_sql_core import sql_connect, set_last_sql_error
from app.lib_sql_core import set_last_sql_error
# log.setLevel(logging.DEBUG) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
@@ -63,11 +63,29 @@ def sql_insert(
return result_insert.lastrowid
return False
except IntegrityError as e:
# Data constraint violation (duplicate key, FK mismatch, NOT NULL) — do NOT retry;
# the same data would fail again. Return None so callers can distinguish from errors.
if trans: trans.rollback()
log.error('Integrity error (likely duplicate). Returning None')
log.debug(e)
set_last_sql_error(e)
return None
except OperationalError:
# Transient connection failure. The broken connection rolls back on MariaDB's side,
# so retrying with a fresh connection is safe.
if trans: trans.rollback()
log.warning('Operational error in sql_insert. Retrying once with fresh connection...')
try:
with lib_sql_core.engine.connect() as conn:
trans = conn.begin()
result_insert = conn.execute(sql_insert_stmt, data)
trans.commit()
if result_insert.rowcount == 1 and result_insert.lastrowid > 0:
return result_insert.lastrowid
return False
except Exception as e:
set_last_sql_error(e)
return False
except Exception as e:
if trans: trans.rollback()
log.error('Unknown exception in sql_insert. Returning False')
@@ -138,7 +156,6 @@ def sql_update(
except OperationalError:
if trans: trans.rollback()
log.error('Operational error (gone away?). Retrying once...')
sql_connect()
try:
with lib_sql_core.engine.connect() as conn:
trans = conn.begin()
@@ -199,6 +216,19 @@ def sql_insert_or_update(
res = conn.execute(stmt, data)
trans.commit()
return res.lastrowid if res.lastrowid > 0 else True
except OperationalError:
# ON DUPLICATE KEY UPDATE is idempotent — safe to retry.
if trans: trans.rollback()
log.warning('Operational error in sql_insert_or_update. Retrying once...')
try:
with lib_sql_core.engine.connect() as conn:
trans = conn.begin()
res = conn.execute(stmt, data)
trans.commit()
return res.lastrowid if res.lastrowid > 0 else True
except Exception as e:
set_last_sql_error(e)
return False
except Exception as e:
if trans: trans.rollback()
log.exception(e)
@@ -309,6 +339,21 @@ def sql_select(
return [] if as_list else None
rows = result.all()
except OperationalError:
# Transient connection failure — reads are always safe to retry.
log.error('Operational error in sql_select. Retrying once with fresh connection...')
try:
with lib_sql_core.engine.connect() as conn:
result = conn.execute(stmt, data)
if not result:
return [] if as_list else None
if hasattr(result, 'returns_rows') and not result.returns_rows:
return [] if as_list else None
rows = result.all()
except Exception as e:
log.error(f"SQL Fetch Error on retry: {e}")
set_last_sql_error(e)
return False
except Exception as e:
log.error(f"SQL Fetch Error: {e}")
set_last_sql_error(e)
@@ -343,7 +388,6 @@ def run_sql_select(
return conn.execute(sql, data)
except (OperationalError, ProgrammingError) as e:
log.error(f'DB Error: {e}. Retrying once...')
sql_connect()
try:
with lib_sql_core.engine.connect() as conn:
return conn.execute(sql, data)

View File

@@ -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:

View File

@@ -96,7 +96,7 @@ app = FastAPI(
# debug = True,
title = 'Aether API',
description = 'One Sky IT\'s Aether API v3.0 using FastAPI.',
version = '3.00.03',
version = '3.00.10',
operationsSorter = 'method',
lifespan = lifespan,
)

View File

@@ -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:

View File

@@ -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() ###

View File

@@ -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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -147,8 +147,9 @@ def get_site_domain_rec_list(
# ### BEGIN ### API Site Domain Methods ### lookup_site_domain_fqdn() ###
def lookup_site_domain_fqdn(
fqdn: str,
# TODO: Accept access_key as an argument for validation (str|None)
# access_key: Optional[str] = None,
# Accept access_key as an argument for validation (str|None)
access_key: Optional[str] = None,
referrer: Optional[str] = None,
enabled: str = 'enabled', # enabled, disabled, all
limit: int = 100,
offset: int = 0,
@@ -158,22 +159,37 @@ def lookup_site_domain_fqdn(
data = {}
data['fqdn'] = fqdn
# TODO: If access_key is provided, add it to the data dict for SQL parameterization
# if access_key is not None:
# data['access_key'] = access_key
# If access_key is provided, add it to the data dict for SQL parameterization
data['domain_access_key'] = access_key
if referrer:
data['required_referrer'] = referrer
sql_enabled, data['enable'] = sql_enable_part(table_name='site_domain', enabled=enabled) # Reasonably safe return str and bool
sql_limit = sql_limit_offset_part(limit=limit, offset=offset) # Reasonably safe return str
# TODO: Add access_key to WHERE clause if provided, e.g.:
# WHERE site_domain.fqdn = :fqdn AND (:access_key IS NULL OR site_domain.access_key = :access_key)
# Build access key / referrer SQL similar to router.lookup_fqdn behavior
if access_key and referrer:
sql_access_key_referrer = """
AND site_domain.domain_access_key = :domain_access_key
AND site_domain.required_referrer = :required_referrer
"""
elif access_key:
sql_access_key_referrer = """
AND site_domain.domain_access_key = :domain_access_key
AND (site_domain.required_referrer IS NULL OR site_domain.required_referrer = '')
"""
else:
sql_access_key_referrer = """
AND (site_domain.domain_access_key IS NULL OR site_domain.domain_access_key = '')
AND (site_domain.required_referrer IS NULL OR site_domain.required_referrer = '')
"""
sql = f"""
SELECT `site_domain`.id AS 'site_domain_id', `site_domain`.id_random AS 'site_domain_id_random'
FROM `v_site_domain` AS site_domain
WHERE
site_domain.fqdn = :fqdn
-- TODO: Add access_key check here for stricter validation
-- AND (:access_key IS NULL OR site_domain.access_key = :access_key)
{sql_access_key_referrer}
{sql_enabled}
ORDER BY `site_domain`.fqdn ASC, `site_domain`.access_key ASC, `site_domain`.required_referrer ASC, `site_domain`.created_on DESC, `site_domain`.updated_on DESC
{sql_limit};

View File

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

View File

@@ -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:

View File

@@ -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]

View File

@@ -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:

View File

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

View File

@@ -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

View File

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

View File

@@ -42,6 +42,23 @@ class Site_Domain_Base(BaseModel):
created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None
# Convenience fields from v_site_domain view (joined from account/site)
account_code: Optional[str] = None
account_name: Optional[str] = None
account_enable: Optional[bool] = None
account_enable_from: Optional[datetime.datetime] = None
account_enable_to: Optional[datetime.datetime] = None
site_enable_from: Optional[datetime.datetime] = None
site_enable_to: Optional[datetime.datetime] = None
site_domain_access_key: Optional[str] = None
logo_path: Optional[str] = None
style_href: Optional[str] = None
script_src: Optional[str] = None
google_tracking_id: Optional[str] = None
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@root_validator(pre=True)
@@ -55,18 +72,18 @@ class Site_Domain_Base(BaseModel):
if rid := values.get('id_random') or values.get('site_domain_id_random'):
values['id'] = rid
values['site_domain_id'] = rid
if s_rid := values.get('site_id_random'):
values['site_id'] = s_rid
if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
# 2. Prevent "Collision Population"
for k in ['id', 'site_id', 'account_id']:
if k in values and not isinstance(values[k], str):
del values[k]
return values
class Config:
@@ -98,7 +115,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
enable: Optional[bool]
hide: Optional[bool] = None
notes: Optional[str] = None
created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None
@@ -133,7 +150,7 @@ class Site_Domain_FQDN_ID_Base(BaseModel):
values['site_id'] = s_rid
if a_rid := values.get('account_id_random'):
values['account_id'] = a_rid
for k in ['id', 'site_id', 'account_id']:
if k in values and not isinstance(values[k], str):
del values[k]

View File

@@ -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'
],
},

View File

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

View File

@@ -33,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',

View File

@@ -33,8 +33,8 @@ other_obj_li = {
],
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
'id', 'account_id', 'id_random', 'sponsorship_id_random', 'account_id_random',
'name', 'description', 'website_url', 'level_str', 'enable', 'hide',
'priority', 'group', 'created_on', 'updated_on'
],
},
@@ -50,8 +50,8 @@ other_obj_li = {
'base_name': Sponsorship_Cfg_Base,
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
'id', 'account_id', 'id_random', 'sponsorship_cfg_id_random', 'account_id_random',
'name', 'description', 'enable', 'hide', 'priority', 'sort', 'group',
'notes', 'created_on', 'updated_on'
],
},
@@ -86,9 +86,9 @@ other_obj_li = {
],
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
'archive_type_id_random', 'archive_type', 'name', 'description',
'filename', 'original_location', 'enable', 'hide', 'priority',
'id', 'account_id', 'id_random', 'archive_id_random', 'account_id_random',
'archive_type_id_random', 'archive_type', 'name', 'description',
'filename', 'original_location', 'enable', 'hide', 'priority',
'sort', 'group', 'notes', 'created_on', 'updated_on'
],
},
@@ -108,7 +108,7 @@ other_obj_li = {
'searchable_fields': [
'id', 'account_id', 'archive_id', 'hosted_file_id',
'id_random', 'archive_content_id_random', 'account_id_random', 'archive_id_random',
'archive_content_type', 'lu_media_type', 'name', 'description',
'archive_content_type', 'lu_media_type', 'external_id', 'code', 'name', 'description',
'filename', 'file_extension', 'original_location', 'original_url',
'enable', 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
],
@@ -137,9 +137,9 @@ other_obj_li = {
],
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
'hash_sha256', 'title', 'description', 'filename', 'extension',
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
'id', 'account_id', 'id_random', 'hosted_file_id_random', 'account_id_random',
'hash_sha256', 'title', 'description', 'filename', 'extension',
'content_type', 'enable', 'hide', 'priority', 'sort', 'group',
'notes', 'created_on', 'updated_on'
],
},
@@ -157,8 +157,8 @@ other_obj_li = {
'base_name': Hosted_File_Link_Base,
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
'id', 'account_id', 'hosted_file_id', 'id_random', 'account_id_random',
'hosted_file_id_random', 'link_to_type', 'link_to_id_random',
'created_on', 'updated_on'
],
},
@@ -226,8 +226,8 @@ other_obj_li = {
'base_name': Grant_Base,
# V3 Search Security:
'searchable_fields': [
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
'code', 'name', 'description', 'enable', 'hide', 'priority', 'sort',
'id', 'account_id', 'id_random', 'grant_id_random', 'account_id_random',
'code', 'name', 'description', 'enable', 'hide', 'priority', 'sort',
'group', 'notes', 'created_on', 'updated_on'
],
},

View File

@@ -4,15 +4,15 @@ from typing import Dict, List, Optional, Set, Union
from sqlalchemy import text
import json
import time
import secrets
# import secrets
import jwt as pyjwt # Avoid conflict with app.lib_jwt
from app.db_connection import db
# from app.db_connection import db
from app.lib_general import sign_jwt, decode_jwt, log, logging
from app.config import settings
from app.db_sql import sql_insert, sql_update, sql_select, redis_lookup_id_random, get_id_random
from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
# from app.routers.api_crud import delete_obj_template, get_obj_template, get_obj_li_template, patch_obj_template, post_obj_template
from app.routers.dependencies_v3 import DeprecationParams
from app.models.api_models import Api_Base
from app.models.response_models import Resp_Body_Base, mk_resp
@@ -21,10 +21,21 @@ router = APIRouter()
# --- Passcode Authentication ---
ROLE_PRIORITY = ['super', 'manager', 'administrator', 'trusted', 'public', 'authenticated']
ROLE_TTL = {
'super': 8 * 3600, # 8 hours
'manager': 24 * 3600, # 24 hours
'administrator': 48 * 3600, # 48 hours
'trusted': 48 * 3600, # 48 hours
'public': 24 * 3600, # 24 hours
'authenticated': 12 * 3600, # 12 hours
}
class PasscodeAuthRequest(BaseModel):
"""Request model for site-based passcode authentication."""
site_id: str = Field(..., description="The random string ID of the site")
passcode: str = Field(..., description="The passcode to verify")
passcode: str = Field(..., min_length=5, description="The passcode to verify")
@router.post('/authenticate_passcode', response_model=Resp_Body_Base)
async def authenticate_passcode(
@@ -54,10 +65,11 @@ async def authenticate_passcode(
except Exception as e:
log.error(f"Failed to parse access_code_kv_json for site {site_id}: {e}")
# 3. Verify Passcode and Resolve Role
# 3. Verify passcode in explicit priority order (highest privilege wins)
matched_role = None
for role, code in access_codes.items():
if str(code) == str(passcode):
for role in ROLE_PRIORITY:
code = access_codes.get(role)
if code and str(code) == str(passcode):
matched_role = role
break
@@ -70,22 +82,25 @@ async def authenticate_passcode(
if account_id_int := record.get('account_id'):
account_id_random = get_id_random(record_id=account_id_int, table_name='account')
# 5. Mint JWT
# 5. Mint JWT with complete role flags and per-role TTL
payload = {
'account_id': account_id_random,
'account_id': account_id_random,
'super': (matched_role == 'super'),
'manager': (matched_role == 'manager'),
'administrator': (matched_role == 'administrator'),
'manager': (matched_role == 'manager'),
'super': (matched_role == 'super'),
'trusted': (matched_role == 'trusted'),
'public': (matched_role == 'public'),
'authenticated': (matched_role == 'authenticated'),
'json_str': json.dumps({
'auth_type': 'passcode',
'site_id': site_id,
'role': matched_role
'site_id': site_id,
'role': matched_role
})
}
token = sign_jwt(
secret_key=settings.JWT_KEY,
ttl=3600 * 24, # 24 hour session
ttl=ROLE_TTL[matched_role],
**payload
)
@@ -99,6 +114,8 @@ async def authenticate_passcode(
# --- JWT Request ---
# DEPRECATED — no V3 replacement needed; passcode→JWT is the V3 auth pattern (/api/authenticate_passcode).
# No frontend references found. Safe to remove after confirming no live traffic. TODO: remove.
@router.get('/request_jwt', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def request_jwt(
x_aether_signing_key: Optional[str] = Header(None, min_length=22, max_length=22),
@@ -152,6 +169,7 @@ async def request_jwt(
token = sign_jwt(secret_key=signing_key, public_key=x_aether_api_key, ttl=max_ttl, max_renew=max_renew, **payload)
return mk_resp(data={ 'jwt': token })
# DEPRECATED — no active use identified. TODO: remove after confirming no live traffic.
@router.get('/temp_token', response_model=Resp_Body_Base, dependencies=[Depends(DeprecationParams)])
async def get_api_temp_token(
x_aether_api_key: Optional[str] = Header(None),
@@ -168,7 +186,9 @@ async def get_api_temp_token(
# --- Jitsi Token ---
# NOTE: This is still actively used by IDAA for their video conferences using self hosted Jitsi. Thi is actually live. We do need to change the app secret once things have stabilized.
# NOTE: Actively used by IDAA for video conferences on self-hosted Jitsi (jitsi.dgrzone.com).
# JWT_APP_ID and JWT_APP_SECRET must match the values in the Jitsi server .env file.
# TODO: Rotate JWT_APP_SECRET — update it here AND in /mnt/nfs_dgr_storage/env/dgr_zone_jitsi/.env (JWT_APP_SECRET) then restart prosody + jicofo.
JWT_APP_ID = "my_jitsi_app_id"
JWT_APP_SECRET = "my_jitsi_app_secret-9876543210"
@@ -187,14 +207,12 @@ class JitsiTokenRequest(BaseModel):
@router.post("/jitsi_token")
async def create_jitsi_jwt(request_data: JitsiTokenRequest = Body(...)):
log.setLevel(logging.INFO)
if not request_data.is_moderator:
raise HTTPException(status_code=403, detail="JWT generation is only permitted for moderators.")
try:
payload = {
"aud": JWT_APP_ID, "iss": JWT_APP_ID, "sub": JITSI_DOMAIN,
"room": request_data.room,
"exp": int(time.time()) + 3600,
"exp": int(time.time()) + 7200, # 2 hour expiry
"config": request_data.config or {},
"context": {
"user": {

View File

@@ -61,16 +61,16 @@ async def get_obj_schema(
):
"""
Dynamic Schema Introspection.
Allows the frontend (e.g., Svelte/React apps) to retrieve the structure of an object type on the fly.
Returns:
- Database column definitions (types, defaults, nullability).
- Pydantic model field definitions (validation rules, aliases).
This enables dynamic form generation without hardcoding schemas in the frontend.
"""
schema_info = get_object_schema_info(obj_type, view, variant)
if "error" in schema_info:
status_code = 400 if "not found" in schema_info["error"] else 500
return mk_resp(data=False, status_code=status_code, response=response, status_message=schema_info["error"])
@@ -87,7 +87,7 @@ async def validate_obj_payload(
):
"""
Dry-Run Payload Validation.
Verifies that a payload is valid according to the Pydantic model
without performing any database operations.
"""
@@ -118,7 +118,7 @@ async def get_obj(
):
"""
Retrieve a Single Object.
1. Resolves the public `id_random` (string) to the internal `id` (integer).
2. Performs a SQL SELECT.
3. Enforces Multi-Tenant access checks.
@@ -149,10 +149,10 @@ async def get_obj(
if account.auth_method == 'guest' or (account.account_id is None and not account.super):
reason = account.auth_error or "Account context required."
return mk_resp(data=False, status_code=403, response=response, status_message=reason)
if not check_account_access(sql_result, account, obj_name):
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied. Record belongs to another account.")
# Pass inc_hosted_file to the Pydantic model if applicable
if obj_name == 'event_file' and inc_hosted_file:
sql_result['inc_hosted_file'] = True
@@ -181,7 +181,7 @@ async def get_obj_li(
):
"""
List Objects (Pagination & Filtering).
Supports:
- Standard filtering (enabled/hidden).
- Advanced filtering via JSON Payload (`jp`) param (Search, Fulltext, AND/OR queries).
@@ -199,7 +199,7 @@ async def get_obj_li(
and_like_dict_obj = None
or_like_dict_obj = None
and_in_dict_li_obj = None
jp_obj = safe_json_loads(urllib.parse.unquote(jp)) if jp else None
if jp_obj:
if jp_obj.get('qry'): qry_dict_li = jp_obj['qry']
@@ -213,7 +213,7 @@ async def get_obj_li(
obj_name = obj_type_l1
if obj_name not in obj_type_kv_li:
return mk_resp(data=False, status_code=400, response=response, status_message=f"Object type '{obj_name}' not found.")
obj_cfg = obj_type_kv_li[obj_name]
if obj_name == 'site' and not (for_obj_type == 'account' and for_obj_id):
@@ -232,7 +232,7 @@ async def get_obj_li(
order_by_li = filter_order_by(order_by_li, base_name, table_name)
status_filter = get_supported_filters(base_name, status_filter)
if not obj_cfg.get('public_read', False):
and_qry_dict_obj = apply_forced_account_filter(and_qry_dict_obj, account, base_name, obj_name, table_name=table_name)
@@ -278,10 +278,10 @@ async def get_obj_li(
if sql_result is False:
# Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error())
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
status_code = 400 if db_err.category == "database_schema" else 500
return mk_resp(data=False, status_code=status_code, response=response, status_message="Listing failed due to database error.", details=db_err.dict())
if sql_result:
@@ -308,7 +308,7 @@ async def search_obj_li(
):
"""
Search Objects (POST).
Advanced search endpoint using `SearchQuery` body.
- Security: Guests can access specific objects (e.g., site_domain) if permitted.
- Filtering: Supports dynamic AND/OR filters built from the frontend.
@@ -343,6 +343,31 @@ async def search_obj_li(
status_filter = get_supported_filters(base_name, status_filter)
searchable_fields = obj_cfg.get('searchable_fields')
# site_domain access-key enforcement:
# - site_access_key (site-level) takes priority; site_domain_access_key used as fallback.
# - A domain is public only if site_domain_access_key is NULL/empty (and site_access_key is also unset).
# - Falsy access_key values (empty string, None) are stripped — treated as "no key".
# - When a key IS provided, lib_sql_search handles the SQL expansion (see process_filter).
if obj_name == 'site_domain':
# Sanity check: drop access_key filters with falsy values
if search_query.and_filters:
search_query.and_filters = [
f for f in search_query.and_filters
if not (isinstance(f, SearchFilter) and f.field == 'access_key' and not f.value)
]
key_fields = {'access_key', 'site_access_key', 'site_domain_access_key'}
has_key_filter = any(
isinstance(f, SearchFilter) and f.field in key_fields
for f in (search_query.and_filters or [])
)
if not has_key_filter:
if search_query.and_filters is None:
search_query.and_filters = []
for col in ('site_access_key', 'site_domain_access_key'):
search_query.and_filters.append(SearchQuery.parse_obj({
'or': [{'field': col, 'op': 'is_null'}, {'field': col, 'op': 'eq', 'value': ''}]
}))
if for_obj_type == 'account' and for_obj_id:
if not account.super and for_obj_id != account.account_id_random:
return mk_resp(data=False, status_code=403, response=response, status_message="Access denied to requested account.")
@@ -388,10 +413,10 @@ async def search_obj_li(
if sql_result is False:
# Standardized rich error bubbling
db_err = format_db_error(get_last_sql_error())
# If it's a schema error (like Unknown Column), it's a 400 Bad Request
status_code = 400 if db_err.category == "database_schema" else 500
return mk_resp(data=False, status_code=status_code, response=response, status_message="Search failed due to database error.", details=db_err.dict())
if sql_result:
@@ -414,7 +439,7 @@ async def post_obj(
):
"""
Create Object.
1. Injects `account_id` for ownership.
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields.
- If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
@@ -462,9 +487,13 @@ async def post_obj(
# Enforce account ownership AFTER sanitize_payload so the integer account_id goes straight
# to the DB without conflicting with Vision ID string constraints in the model.
# Guard: skip if the model explicitly excludes account_id from DB writes (e.g. event_badge,
# event_device — the column does not exist in those tables).
if not account.super and account.auth_method != 'bypass' and account.account_id:
if 'account_id' in input_model.__fields__:
data_to_insert['account_id'] = account.account_id
excluded = getattr(input_model, 'fields_to_exclude_from_db', [])
if 'account_id' not in excluded:
data_to_insert['account_id'] = account.account_id
if sql_insert_result := sql_insert(data=data_to_insert, table_name=table_name_insert):
new_obj_id = sql_insert_result
@@ -496,7 +525,7 @@ async def patch_obj(
):
"""
Update Object (Partial).
1. Resolves ID and checks access permissions.
2. **Sanitizes Payload**: Resolves `*_id_random` -> `*_id`, removes virtual fields, and view-only fields.
- If `x-ae-ignore-extra-fields: true` header is provided, unknown fields are stripped.
@@ -557,7 +586,7 @@ async def delete_obj(
):
"""
Delete Object.
Supports:
- Soft Delete: `method='hide'` or `method='disable'`.
- Hard Delete: `method='delete'`.

View File

@@ -85,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.")
@@ -92,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)
@@ -182,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
@@ -257,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'))
@@ -277,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:
@@ -296,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):
@@ -351,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')))

View File

@@ -0,0 +1,78 @@
"""
Aether API V3 - Email Action Router
-------------------------------------
Handles transactional email sending.
Routes:
POST /send — send a transactional email
Replaces: POST /util/email/send (legacy — see util_email.py)
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, Query, Response
from pydantic import BaseModel, Field
from app.lib_email import send_email
from app.lib_general_v3 import AccountContext, get_account_context
from app.models.response_models import Resp_Body_Base, mk_resp
log = logging.getLogger(__name__)
router = APIRouter()
class EmailSendRequest(BaseModel):
from_email: str = Field(..., description="Sender email address")
from_name: Optional[str] = None
to_email: str = Field(..., description="Recipient email address")
to_name: Optional[str] = None
cc_email: Optional[str] = None
cc_name: Optional[str] = None
bcc_email: Optional[str] = None
bcc_name: Optional[str] = None
subject: str = Field(..., description="Email subject line")
body_html: str = Field(..., description="HTML email body")
body_text: Optional[str] = None
@router.post('/send', response_model=Resp_Body_Base)
async def action_email_send(
req: EmailSendRequest,
test: bool = Query(False, description="Simulate send without delivering"),
account_ctx: AccountContext = Depends(get_account_context),
response: Response = Response,
):
log.setLevel(logging.INFO)
success = send_email(
from_email=req.from_email,
from_name=req.from_name,
to_email=req.to_email,
to_name=req.to_name,
cc_email=req.cc_email or '',
cc_name=req.cc_name or '',
bcc_email=req.bcc_email or '',
bcc_name=req.bcc_name or '',
subject=req.subject,
body_text=req.body_text,
body_html=req.body_html,
test=test,
)
if success:
status_code = 200
status_message = f'Email sent to <{req.to_email}>.'
else:
status_code = 400
status_message = f'Email failed to send to <{req.to_email}>.'
log.info(status_message)
resp_data = {
'from_email': req.from_email,
'to_email': req.to_email,
'subject': req.subject[:40],
}
return mk_resp(data=resp_data, status_code=status_code, response=response, status_message=status_message)

View File

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

View File

@@ -271,7 +271,7 @@ async def action_new_auth_key(
@router.get('/{user_id}/email_auth_key_url', response_model=Resp_Body_Base)
async def action_email_auth_key_url(
user_id: str = Path(min_length=11, max_length=22),
root_url: Optional[str] = Query(None, min_length=10, max_length=200),
root_url: str = Query(..., min_length=10, max_length=200),
key_param_name: str = Query('auth_key', min_length=2, max_length=30),
account: AccountContext = Depends(get_account_context),
):

View File

@@ -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() ###

View File

@@ -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() ###

View File

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

View File

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

View File

@@ -5,7 +5,8 @@ from app.routers import (
data_store,
event_badge_importing,
event_importing,
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_user, lookup_v3,
api_v3_actions_email,
api_v3_actions_hosted_file, api_v3_actions_event_file, api_v3_actions_event_exhibit, api_v3_actions_e_zoom, api_v3_actions_e_novi_mailman, api_v3_actions_idaa, api_v3_actions_user, lookup_v3,
user,
util_email, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
)
@@ -23,7 +24,7 @@ def setup_routers(app: FastAPI):
app.include_router(api.router, prefix='/api', tags=['API'])
# app.include_router(flask_cfg.router, prefix='/flask_cfg', tags=['Flask CFG'], dependencies=[Depends(DeprecationParams)])
app.include_router(importing.router, prefix='/importing', tags=['Importing'], dependencies=[Depends(DeprecationParams)])
# app.include_router(importing.router, prefix='/importing', tags=['Importing'], dependencies=[Depends(DeprecationParams)])
# app.include_router(sql.router, tags=['SQL']) # LEGACY (disabled) - raw SQL select endpoint, testing only
# app.include_router(account.router, tags=['Account'], dependencies=[Depends(DeprecationParams)])
@@ -50,7 +51,9 @@ def setup_routers(app: FastAPI):
app.include_router(api_v3_actions_event_exhibit.router, prefix='/v3/action/event_exhibit', tags=['Event Exhibit (V3 Actions)'])
app.include_router(api_v3_actions_e_zoom.router, prefix='/v3/action/e_zoom', tags=['Zoom Events (V3 Actions)'])
app.include_router(api_v3_actions_e_novi_mailman.router, prefix='/v3/action/e_novi_mailman', tags=['Novi-Mailman Bridge (V3 Actions)'])
app.include_router(api_v3_actions_idaa.router, prefix='/v3/action/idaa', tags=['IDAA Actions (V3)'])
app.include_router(api_v3_actions_user.router, prefix='/v3/action/user', tags=['User (V3 Actions)'])
app.include_router(api_v3_actions_email.router, prefix='/v3/action/email', tags=['Email (V3 Actions)'])
# app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # LEGACY (disabled) - superseded by /v3/lookup
app.include_router(lookup_v3.router, prefix='/v3/lookup', tags=['Lookup V3'])
@@ -62,13 +65,14 @@ def setup_routers(app: FastAPI):
# app.include_router(qr.router, tags=['QR'], dependencies=[Depends(DeprecationParams)])
# app.include_router(site.router, tags=['Site'], dependencies=[Depends(DeprecationParams)])
# app.include_router(site_domain.router, tags=['Site Domain'], dependencies=[Depends(DeprecationParams)])
app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
app.include_router(util_email.router, tags=['Utility: Email'])
# app.include_router(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
# app.include_router(util_email.router, tags=['Utility: Email']) # LEGACY (disabled) - superseded by /v3/action/email/send
# app.include_router(websockets.router, tags=['Websockets']) # LEGACY (disabled) - superseded by Websockets V3
# app.include_router(websockets_redis.router, tags=['Websockets (Redis)']) # LEGACY (disabled) - superseded by Websockets V3
app.include_router(websockets_v3.router, prefix='/v3', tags=['Websockets V3'])
app.include_router(e_confex.router, prefix='/e/confex', tags=['External Service: Confex'])
app.include_router(e_cvent.router, prefix='/e/cvent', tags=['External Service: Cvent'])
app.include_router(e_impexium.router, prefix='/e/impexium', tags=['External Service: Impexium'])
app.include_router(e_stripe.router, prefix='/e/stripe', tags=['External Service: Stripe'])
# ALERT: Temporarily commenting these out until needed for external service integrations. They can be re-enabled as needed.
# app.include_router(e_confex.router, prefix='/e/confex', tags=['External Service: Confex'])
# app.include_router(e_cvent.router, prefix='/e/cvent', tags=['External Service: Cvent'])
# app.include_router(e_impexium.router, prefix='/e/impexium', tags=['External Service: Impexium'])
# app.include_router(e_stripe.router, prefix='/e/stripe', tags=['External Service: Stripe'])

View File

@@ -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

View File

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

View File

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

View File

@@ -19,8 +19,15 @@ Required for any non-public data (Journals, Badges, Users, etc.).
* **Header:** `x-account-id: <account_id>`
2. **Administrative Bypass**: For authorized scripts needing global access.
* **Header:** `x-no-account-id: bypass`
* **Scope:** Narrow escape hatch only. Keep it limited to allowlisted bootstrap/public/global-default paths and prefer `x-account-id` or JWT-backed requests everywhere else.
3. **Token Access**: Provide a **JWT** in the query string.
* **Query Param:** `?jwt=<token>`
4. **Important Distinction:** A query parameter named `key` is **not** an account-context bypass signal.
* `key` may be used by specific endpoints/business logic, but it must **not** cause the frontend to remove `x-account-id`.
* Only explicit `x-no-account-id: bypass` should strip account context.
> [!NOTE]
> The `x-no-account-id` path should continue to shrink over time. If you need a new use, document why `x-account-id` or JWT cannot cover it and mark the use as temporary unless it is a hard bootstrap/global-default requirement.
> [!CAUTION]
> **UNSUPPORTED HEADERS:** The header `x-aether-api-token` is **NOT recognized** by the V3 API. If you send it, the backend will treat you as a guest and block access to private data.
@@ -44,6 +51,37 @@ When the frontend first loads and doesn't know the `account_id`, it performs a "
* Returns 200 + a list containing the `account_id` (random string ID) and `site_id` (random string ID).
* ** デザイン Choice:** If the domain is not found, it returns **200 OK with an empty list `[]`**. It is NOT a 404.
> **Access Key Support**
>
> Some client deployments restrict their domain via an access key passed in the browser URL (e.g. `?key=abc123`). The frontend reads this param and forwards it as `access_key` in the POST body.
>
> **How to pass the key:**
> ```json
> {
> "and": [
> { "field": "fqdn", "op": "eq", "value": "client.example.com" },
> { "field": "access_key", "op": "eq", "value": "abc123" }
> ]
> }
> ```
> If `key` is absent, empty, or falsy — **omit `access_key` from the payload entirely**. Do not send `"access_key": ""`.
>
> **Server behavior:**
> - `site_access_key` (site-level key) takes priority. If set, all domains under that site require it.
> - `site_domain_access_key` (domain-level key) is used as fallback when `site_access_key` is not set.
> - A domain is **public** only when **both** key columns are NULL/empty.
> - Falsy `access_key` values are ignored server-side as a safety net.
> - Match → `200` with the record. No match → `200` with empty list `[]`.
> - Do **not** use `access_code_kv_json` for this — that field is for UI features only.
>
> | Browser URL | `access_key` in payload | Result |
> |---|---|---|
> | `https://dev-demo.oneskyit.com` | *(omit)* | ✅ Returns record (public) |
> | `https://client.example.com/?key=correct` | `"correct"` | ✅ Returns record |
> | `https://client.example.com/` | *(omit)* | ❌ Empty (key required) |
> | `https://client.example.com/?key=wrong` | `"wrong"` | ❌ Empty (wrong key) |
> | `https://client.example.com/?key=` | *(omit — strip empty)* | ❌ Empty (key required) |
>
---
## 3. Standard CRUD Patterns
@@ -68,6 +106,22 @@ Modify data in the system.
* **Header:** `x-ae-ignore-extra-fields: true`
* **Behavior:** When set to `true`, the backend will automatically strip any fields from the payload that are not defined in the object's model before attempting to save to the database.
#### `*_json` field serialization — do NOT pre-stringify in route/component code
The frontend API wrappers (`src/lib/ae_api/api_post__crud_obj.ts` for V3, `src/lib/api/api.ts` for legacy CRUD) automatically serialize any field whose name ends in `_json` (e.g. `cfg_json`, `data_json`) before sending. They pretty-print with 2-space indent via an internal `serialize_json_field_pretty()` helper.
**Pass `*_json` fields as plain JS objects from routes and components.** The serialization layer handles the rest.
```ts
// ✅ Correct — pass as plain object; V3 wrapper serializes it
await update_ae_obj__site({ site_id, data_kv: { cfg_json: { jitsi_token_endpoint: url } } });
// ❌ Wrong — double-encodes the JSON string (the wrapper would stringify an already-stringified value)
await update_ae_obj__site({ site_id, data_kv: { cfg_json: JSON.stringify({ jitsi_token_endpoint: url }) } });
```
The V3 wrapper (`api_post__crud_obj.ts`) only serializes when `typeof value === 'object'`, so it will not double-encode a plain string. The legacy wrapper (`api.ts`) stringifies unconditionally, so pre-stringifying there **will** produce double-encoded JSON. In both cases, the right answer is to pass the raw object and let the layer handle it.
### D. ID Fields in Responses (Vision ID Convention)
> [!IMPORTANT]
@@ -262,6 +316,108 @@ Frontend guidance:
---
## 8. Email Send Action
Send a transactional email via the Aether API.
- **Method:** `POST`
- **Path:** `/v3/action/email/send`
- **Auth:** `x-aether-api-key` + `x-account-id` (or `x-no-account-id` / `?jwt=`)
**Request body:**
```json
{
"from_email": "noreply@example.com",
"from_name": "Example App",
"to_email": "user@example.com",
"to_name": "Alice Smith",
"subject": "Your login link",
"body_html": "<p>Click <a href=\"...\">here</a> to log in.</p>",
"body_text": "Visit ... to log in.",
"cc_email": null,
"bcc_email": null
}
```
**Query params:**
| Parameter | Type | Default | Description |
|---|---|---|---|
| `test` | bool | `false` | Simulate send without delivering |
**Response:** `data` contains `{ from_email, to_email, subject }` (first 40 chars of subject). `400` if delivery failed.
> **Replaces:** `POST /util/email/send` (disabled as of May 2026).
---
## Axonius Zoom CSV Upload (Temporary — Apr 2026, EXPIRED)
Purpose: Staff-only quick upload to upsert Event Person + Event Badge records from a Zoom Events registrant CSV.
- **Endpoint:** `POST /event/{event_id}/badge/import/zoom_csv`
- **Auth:** include `x-aether-api-key` (if required) and account context via `x-account-id: <ACCOUNT_ID>`. Admin bypass (`x-no-account-id: bypass`) or `?jwt=<token>` are accepted per site policy.
- **Request:** `multipart/form-data` with single file field `file` (Zoom CSV). Query params:
- `begin_at` (int, default `0`)
- `end_at` (int, default `20000`)
- `return_detail` (bool, default `false`)
- Delimiter is auto-detected; Zoom CSV layout: row 1 = metadata, row 2 = blank, row 3 = headers (the backend skips the first two rows).
Behavior / notes:
- The handler forces `Registrant email` to be used as the `external_id`. `Unique identifier` is used as `external_registration_id` only when it is meaningful (placeholders like `N/A`, `NA`, `UNKNOWN` are ignored).
- Per-ticket custom fields are parsed (Organization, Job title, Phone, Address lines, City, State/Province, Postal/Zip, Country, etc.).
- Marketing-consent values are mapped to `agree_to_tc` and `allow_tracking`.
- TEMP AXONIUS MAPPING: the import temporarily defaults `event_badge_template_id` to `21` and `event_badge_template_id_random` to `RKYp2HcQm9o`. Ticket-name → `badge_type_code` mapping is applied for some labels (e.g., contains "sponsor" → `sponsor`; contains "attend"/"attendee" → `attendee`). This mapping is temporary (April 2026) — surface this to staff.
- Rows missing `Registrant email` are skipped.
- The server upserts via existing backend methods and creates/updates `event_person`, `event_person_profile`, and `event_badge` records as needed.
Frontend guidance:
- UI must be staff-only and should validate an `event_id` is selected.
- For large files, use `begin_at`/`end_at` to process in chunks.
- Prefer `return_detail=false` for large imports to reduce payload size.
Common errors:
- `403` — missing/invalid account context or API key.
- `404` — event not found.
- `500` — file save or processing error.
Example curl (replace placeholders):
```bash
curl -v -X POST "https://api.example.com/event/<EVENT_ID>/badge/import/zoom_csv?begin_at=0&end_at=20000&return_detail=false" \
-H "x-aether-api-key: <API_KEY>" \
-H "x-account-id: <ACCOUNT_ID>" \
-F "file=@/path/to/zoom_export.csv"
```
Sample success (summary mode, `return_detail=false`):
```json
{
"data": [
{
"event_id": "xK9mP3qRtL2",
"event_id_random": "xK9mP3qRtL2",
"external_id": "alice@example.com",
"given_name": "Alice",
"family_name": "Smith",
"email": "alice@example.com"
}
],
"meta": {
"status_code": 200,
"status_name": "OK",
"success": true,
"data_type": "list",
"data_list_count": 1
}
}
```
Sample success (detailed, `return_detail=true`) — `data` contains full `event_person` objects with nested `event_badge` (may include temporary `event_badge_template_id`: `21` and `event_badge_template_id_random`: `RKYp2HcQm9o`).
Paste this section into the guide as a temporary Axonius-specific note (April 2026). Consider linking staff to a sample Zoom CSV for QA.
---
## 7. User Actions (`/v3/action/user/`)
Stateful user account operations that are not standard CRUD. All require `x-aether-api-key`.
@@ -303,7 +459,7 @@ or:
**Response on success:** Full user object (same shape as `GET /v3/crud/user/{id}`).
**Errors:** `400` missing credentials, `403` wrong password or account disabled, `404` user not found.
**Errors:** `400` missing credentials, `403` wrong password / account disabled / account not yet enabled / account expired, `404` user not found.
> **Auth key flow:** Auth keys are one-time-use — the key is cleared from the DB immediately on successful authentication. Request a new one via `GET /v3/action/user/{id}/new_auth_key`.
@@ -323,7 +479,7 @@ Check a user's current password without changing it.
```
or use `"username"` instead of `"user_id"` to look up by username within the account.
**Response:** `data: true` on match. `403` on mismatch, `404` if user not found.
**Response:** `data: true` on match. `400` if the user has no password set, `403` on mismatch, `404` if user not found.
---
@@ -376,10 +532,19 @@ Generate a new auth key and email a one-time login link to the user's email addr
| Parameter | Type | Default | Description |
|---|---|---|---|
| `root_url` | `string` | `null` | Base URL the login link is built from. |
| `root_url` | `string` | *(required)* | Base URL the login link is built from. Must be provided — if omitted the link in the email will be malformed (`None?...`). |
| `key_param_name` | `string` | `auth_key` | Query param name used for the auth key in the generated link. |
**Response:** `data: true` on success (email sent). `500` if delivery failed (check account email config and that the user account is enabled with `allow_auth_key = true`).
> [!IMPORTANT]
> `root_url` is **required in practice**. The FastAPI query param accepts `null` but the email builder does not guard against it — omitting it produces a broken link in the email.
**Magic link URL format (default `key_param_name`):**
```
{root_url}?user_id={user_id_random}&auth_key={auth_key}&valid_email=True
```
The frontend at `root_url` should read these query params and call `POST /v3/action/user/authenticate` with `{ "user_id": "...", "auth_key": "..." }`. Note that `valid_email=True` is **always** injected — authenticating via a magic link automatically marks the user's email as verified.
**Response:** `data: true` on success (email sent). `404` if user not found. `500` if delivery failed — common causes: account email not configured, user `enable = false`, or `allow_auth_key = false`.
---
@@ -405,7 +570,7 @@ Results are automatically scoped to the `x-account-id` provided in the request.
---
## 9. Event Exhibit Tracking Export (Leads Export)
## 10. Event Exhibit Tracking Export (Leads Export)
Allows an exhibitor to download all lead-capture records for their exhibit as a CSV or XLSX file.
@@ -473,10 +638,67 @@ const url = URL.createObjectURL(blob);
---
## 10. Troubleshooting 403 Forbidden
## 12. IDAA: Server-Side Novi Member Verification
Verifies a Novi AMS member UUID by proxying the Novi API call through the Aether backend. This eliminates false "Access Denied" failures for members on hotel/conference WiFi, VPNs, and Cloudflare-filtered networks — the Novi call originates from the server's IP, not the member's browser IP.
- **Method:** `GET`
- **Path:** `/v3/action/idaa/novi_member/{uuid}`
- **Auth:** Standard V3 (`x-aether-api-key` + `x-account-id` or `?jwt=`)
### Request
| Parameter | Location | Required | Description |
|---|---|---|---|
| `uuid` | Path | Yes | Novi member UUID (from Novi AMS) |
### Response on success (`200 OK`)
```json
{
"data": {
"verified": true,
"full_name": "Alice S.",
"email": "alice+member@idaa.org"
}
}
```
- `full_name`: `"{FirstName} {LastName[0]}."` format. Falls back to the Novi `Name` field if first/last are absent.
- `email`: Novi `Email` field with space → `+` normalization applied (Novi quirk — `alice member@idaa.org` → `alice+member@idaa.org`).
### Error responses
| Status | Meaning | Frontend action |
|---|---|---|
| `404` | UUID not found in Novi, or Novi returned 200 with no identity data (empty-member anti-pattern — member may have just joined) | Treat as denied / not a member |
| `429` | Novi rate limit hit | Surface as `'rate_limited'`; advise retry |
| `503` | Novi unreachable or Novi 5xx error | Surface as `'api_error'`; advise retry |
### Migration from direct Novi call
The frontend's `+layout.svelte:verify_novi_uuid()` currently calls Novi directly from the browser. Replace that `fetch()` with this endpoint. Response code mapping:
| Direct Novi result | This endpoint returns | Frontend state |
|---|---|---|
| `200` with identity data | `200` | `verified` |
| `200` with no identity data | `404` | `denied` |
| `404` | `404` | `denied` |
| `429` | `429` | `'rate_limited'` |
| Network error / Novi 5xx | `503` | `'api_error'` |
### Caching
Verified results are cached in Redis (`idaa:novi_member:{uuid}`, 4-hour TTL). `404` results are **never** cached so recently-joined members are not incorrectly denied on their next attempt.
---
## 11. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct.
2. Ensure you are sending `x-account-id` and NOT `x-aether-api-token`.
3. Verify the record actually belongs to the account ID you are sending.
4. Check if the object is marked `public_read: True` in the registry. (Posts and Archive Content allow guest access; Journals and Badges do not).
5. Confirm the frontend is not treating `params.key` as an implicit bypass and stripping `x-account-id`.
6. If list/search endpoints work but `GET /v3/crud/{obj_type}/{id}` still returns 403, this is likely endpoint-level policy (e.g., requires stronger auth like JWT) rather than a transport/header bug.

View 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 (~50150ms), 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 |

View File

@@ -12,6 +12,16 @@
- [x] **Config Refactor:** Switch `app/config.py` to `pydantic-settings` to use direct Env Vars (Stop mounting config files).
- [x] **Locking:** Generate a `requirements.lock` for bit-identical builds.
## 🔌 DB Connection Hardening (April 2026 Audit)
> Identified during pre-show review. Issues 1 and 2 likely explain observed random connection lags.
- [x] **[P1] Remove zombie `db_connection.py` import** — `app/routers/api.py` imports `db` from `app/db_connection.py`, creating a parasitic second SQLAlchemy engine at startup that is never updated by `reconnect_db()` after bootstrap. The imported `db` is only used in a commented-out line (`api.py:268`). Fix: remove the import; delete or archive `db_connection.py`.
- [x] **[P1] Fix retry mechanism in `sql_update` / `run_sql_select`** — On `OperationalError`, both call `sql_connect()``reconnect_db()` which calls `engine.dispose()`, nuking the entire connection pool mid-flight. Under concurrent requests this kills other in-flight connections. Fix: remove the `sql_connect()` retry call; SQLAlchemy's `pool_pre_ping=True` already handles stale connections — just open a fresh `engine.connect()` for the retry without disposing the pool.
- [x] **[P2] Add retry logic to `sql_insert` and `sql_select`** — Added `OperationalError` retry (single fresh connection attempt) to `sql_insert`, `sql_select`, and `sql_insert_or_update`. `IntegrityError` (duplicate key, FK violation) correctly bypasses retry and returns `None` — retrying the same data would fail again.
- [x] **[P3] Guard `db = engine.connect()` in `lib_sql_core.py` with try/except** — Wrapped in try/except; sets `db = None` on failure so Docker startup race no longer crashes the worker.
- [ ] **[P3 full]** Migrate `lib_schema_v3.py:39` and `lib_api_crud_v3.py:166` off the global `db` to `engine.connect()` context managers, then remove the global `db` entirely.
- [x] **[P4] Expose `pool_size` / `max_overflow` as env vars** — `create_ae_engine()` calls `settings.DB.get('pool_size', 10)` but `settings.DB` property doesn't include those keys, so they're always hardcoded 10/20. Add `AE_DB_POOL_SIZE` / `AE_DB_POOL_MAX_OVERFLOW` to `config.py`.
## 📋 Feature Tasks
- [x] **Core Isolation:** Harden `apply_forced_account_filter` to Fail-Closed.
- [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects.
@@ -24,10 +34,48 @@
- [x] Whitelist `account_id` in all Event search definitions.
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
- [x] **V3 Uniform Lookup System:** Phase 1 & 2 Complete.
- [x] **Restore alt-view convenience fields lost in v1→v3 migration (May 2026):** `site_domain` (`account_name`, `account_code`, `account_enable`, `account_enable_from/to`, `site_enable_from/to`, `site_domain_access_key`, `logo_path`, `style_href`, `script_src`, `google_tracking_id`) and `event_session` (`event_presentation_li_qry_str`, `event_presenter_li_qry_str`). Fields added to Pydantic models and `searchable_fields`. Alt-view fields require `?view=alt` for search.
- [ ] Verify SQL Views join in all required `_random` IDs for performance.
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
- [ ] **Step 3:** Frontend V3 WebSocket integration test — queued after IDAA-specific work. Backend is ready (auth wired, heartbeat presence refresh confirmed, unit tests passing). Frontend guide updated at `GUIDE__AE_API_V3_for_Frontend_websockets.md`.
## 🔌 IDAA: Server-Side Novi Verification (Mini Project)
> **Status: P1P4 Complete (May 2026).** Endpoint live at `GET /v3/action/idaa/novi_member/{uuid}`. P5 (frontend migration) is the remaining step.
> Rationale and frontend integration notes: `aether_app_sveltekit/documentation/CLIENT__IDAA_and_customized_mods.md` → "Planned: Server-Side Novi Verification"
**Goal:** Proxy the Novi member-verification call server-to-server (FastAPI → Novi) so members' browser IPs are no longer in the call path.
- [x] **[P1] New router:** `app/routers/api_v3_actions_idaa.py`
- Route: `GET /v3/action/idaa/novi_member/{uuid}`
- Required auth: `Depends(get_account_context)` — valid API key + any account context (x-account-id, JWT, or bypass). This is the standard V3 gate.
- Reads `novi_idaa_api_key` / `novi_api_root_url` from site `cfg_json` via `_load_idaa_cfg()` (same as Mailman bridge)
- Calls Novi: `GET {novi_api_root_url}/customers/{uuid}` with `Authorization: Basic {api_key}`
- Normalize email: `.replace(' ', '+')` (Novi quirk — see Novi-Mailman bridge notes)
- Build display name: `"{FirstName} {LastName[0]}."` format, fall back to `Name` field
- Returns `{ "verified": true, "full_name": "...", "email": "..." }` on success
- Returns `404` if Novi 200 with no identity data (empty-member anti-pattern)
- Returns `429` if Novi rate limits; `503` if Novi unreachable or 5xx
- Business logic in `app/methods/idaa_novi_verify_methods.py`
- [x] **[P2] Redis cache:**
- Key: `idaa:novi_member:{uuid}` — TTL 4 hours
- Note: `account_id` dropped from key — Novi credentials are hardcoded to the IDAA site; same UUID always returns the same data regardless of caller, so per-caller scoping wastes Redis space and halves hit rate.
- Cache only verified (200) results — do NOT cache 404 (member may have just joined)
- Uses `redis_client` from `lib_redis_helpers.py` directly
- [x] **[P3] Register in registry:** Added to `routers/registry.py` at `/v3/action/idaa` tag `IDAA Actions (V3)`. Confirmed live — endpoint appears in `/openapi.json`.
- [x] **[P4] Tests:** `tests/unit/test_unit_idaa_novi_verify.py` — 9 tests, all passing.
- Mock Novi responses (200/empty-200/404/429/503/unreachable)
- Verify Redis cache is set on 200, hit bypasses Novi call
- Verify email normalization (space → +)
- Verify display name format (5 cases)
- [x] **[P5] Coordinate with Frontend Agent** — **Complete (2026-05-19)**
- Frontend replaced direct `fetch()` to Novi in `+layout.svelte:verify_novi_uuid()`
- Response codes mapped: 200 → verified, 404 → denied, 429 → `'rate_limited'`, 503 → auto-retried (3s, once) then `'api_error'`
- 503 auto-retry added same session to match network-error retry path
## 🛡️ Security & Privacy Baseline (IDAA)
- **Status:** **ENFORCED**.
- **Maintenance:** Run `tests/e2e/test_e2e_v3_security_audit.py` after ANY router or registry change.
@@ -51,6 +99,7 @@
- **Webhook approach abandoned** — cron is simpler; Novi webhook payload format is unknown and Novi hasn't been configured to send webhooks.
- **Remaining:** Set production group→list mappings in `cfg_json`, configure cron schedule, rotate Mailman `restadmin` password.
- [ ] **Lookup System Batch 2:** Migration of `post_topic`, `user_status`, `file_purpose`.
- [ ] **Post Topic Storage Review:** Revisit whether IDAA BB posts should keep the lookup-based `topic_id`/`topic_name` shape or eventually flatten to saved topic text if the list stays fixed.
- [ ] **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval.
## 📝 Session Notes (March 11, 2026)

View File

@@ -7,7 +7,7 @@ This directory contains the automated and manual test scripts for the Aether Fas
- **`unit/`**: Isolated logic tests. These use heavy mocking to bypass database and network requirements. Fast and safe to run in any environment.
- **`integration/`**: Local environment tests. These verify component interactions, often requiring a connection to the local MariaDB/Redis instance.
- **`e2e/` (End-to-End)**: Network-based API tests. these use the `requests` library to call the live API endpoints at `https://dev-api.oneskyit.com`.
- **`tools/`**: Utility scripts for administrative tasks like registry generation or Docker exploration.
- **`tools/`**: Utility scripts for administrative tasks like registry generation, Docker exploration, and performance stress testing.
- **`archive/`**: Legacy or deprecated scripts kept for historical reference.
## 📜 Standardized E2E Suite (`tests/e2e/`)
@@ -19,6 +19,8 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `test_e2e_v3_search_engine.py` | **Primary Search**: Basic operators, Registry fields, Nested search, and Filter bypass. |
| `test_e2e_v3_security_audit.py` | **Core Security**: Verifies multi-tenant isolation, cross-account write blocking, and ID Vision compliance. |
| `test_e2e_v3_auth_security.py` | **Primary Auth**: Site bootstrap, Passcode-to-JWT, and permission boundaries. |
| `test_e2e_v3_user_action_routes.py` | **V3 User Actions**: Sign-in (username+password and auth-key flow), verify password, change password, new auth key, email magic link, and auth guards. |
| `test_e2e_v3_user_auth_routes.py` | **Legacy User Routes**: Tests the pre-V3 `/user/*` endpoints (change_password, new_auth_key, verify_password, lookup, email_auth_key_url, authenticate). |
| `test_e2e_v3_actions_file_lifecycle.py` | **Primary Actions**: Upload, Download (ID/Hash/Streaming), and physical Deletion. |
| `test_e2e_v3_data_store_lookup.py` | **V3 Parity**: Verifies code-based lookups and latency simulation. |
| `test_e2e_redis_extensive.py` | **Redis Stress**: Benchmarks bidirectional ID caching across thousands of records. |
@@ -31,6 +33,7 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `test_e2e_v3_action_novi_mailman.py` | **Novi-Mailman Bridge — Connections**: Verifies Novi AMS and Mailman 3 API credentials are valid (IDAA). Run first before the lists test. |
| `test_e2e_v3_action_novi_mailman_lists.py` | **Novi-Mailman Bridge — List Operations**: Full member lifecycle — read roster, subscribe, verify, unsubscribe — against `mm3@idaa.org`, `mm3@dgrzone.com`, `mm3@oneskyit.com`. |
| `test_e2e_v3_action_event_exhibit_tracking_export.py` | **Exhibit Leads Export**: Auth/permission guards, CSV column structure, XLSX bytes, and `return_file` mode for the V3 tracking export action. |
| `test_e2e_v3_action_idaa_novi_verify.py` | **IDAA Novi Member Verify**: Auth guard, 200 verified, 404 not-found, 429 rate-limit, 503 unreachable, Redis cache hit, email normalization. (not yet written — add when endpoint is stable) |
| `test_e2e_v3_accounts.py` | CRUD verification for the core Account object. |
| `test_e2e_v3_schema.py` | Network verification of the V3 metadata discovery endpoint. |
| `test_e2e_agent_bridge.py` | Verifies container diagnostics and log streaming routes. |
@@ -38,6 +41,28 @@ These consolidated scripts are the primary verification tool for the V3 API.
---
## 🔧 Tools (`tests/tools/`)
| Script | Description |
| :--- | :--- |
| `stress_list_queries.py` | **Read-only concurrency stress test.** Fires N worker threads making R sequential requests across all V3 list endpoints. Reports per-endpoint p50/p95/max latency and error counts. CLI: `--workers` (default 10), `--requests` (default 5), `--limit` (default 20), `--base-url` (default dev API). Exit code 1 on any error. |
| `tool_generate_registry.py` | Generates the object type registry from source definitions. |
| `tool_mcp_docker_explorer.py` | Explores running Docker containers via the MCP bridge. |
**Stress test quick reference:**
```bash
# Baseline (10 workers, 5 rounds, 400 total requests)
./environment/bin/python3 tests/tools/stress_list_queries.py
# Heavy load (35 workers, 5 rounds, 1400 total requests)
./environment/bin/python3 tests/tools/stress_list_queries.py --workers 35 --requests 5
# Target a different environment
./environment/bin/python3 tests/tools/stress_list_queries.py --base-url https://api.oneskyit.com --workers 5
```
---
## 🛠️ Shared Helpers
- **`mock_config_helper.py`**: A critical utility that mocks `app.config.settings` before other modules are imported. Use this in unit tests.
@@ -54,8 +79,10 @@ Tests exist to be used — run the relevant suite whenever you touch backend cod
| Nested router (`api_crud_v3_nested.py`) changes | `test_e2e_v3_demo_parity.py` |
| Search / filter changes | `test_e2e_v3_search_engine.py` |
| Auth / account context changes | `test_e2e_v3_security_audit.py`, `test_e2e_v3_auth_security.py` |
| User action route changes (sign-in, password, magic link) | `test_e2e_v3_user_action_routes.py` |
| File upload / download changes | `test_e2e_v3_actions_file_lifecycle.py` |
| Novi-Mailman bridge changes | `test_e2e_v3_action_novi_mailman.py`, `test_e2e_v3_action_novi_mailman_lists.py` |
| IDAA Novi member verify changes | `tests/unit/test_unit_idaa_novi_verify.py`, `test_e2e_v3_action_idaa_novi_verify.py` (e2e pending) |
| Event exhibit tracking export changes | `test_e2e_v3_action_event_exhibit_tracking_export.py` |
| Any backend change before frontend hand-off | All of the above |
@@ -86,6 +113,16 @@ To maintain a "nice" and readable test suite, follow these patterns in all new P
./environment/bin/python3 tests/e2e/test_e2e_v3_search_engine.py
```
### Running unit tests with pytest
```bash
./environment/bin/python3 -m pytest tests/unit/ -v
```
`pytest` and `pytest-asyncio` are dev-only dependencies (not in `requirements.txt`). After rebuilding the venv (e.g. following an OS Python update), reinstall them:
```bash
./environment/bin/pip install pytest pytest-asyncio
```
### Path Requirements
Always run test scripts from the **project root** directory. Most scripts include `sys.path.append(os.getcwd())` to ensure local imports work correctly.
@@ -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.

View File

@@ -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.")

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

View File

@@ -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):

View 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())

View File

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

View File

@@ -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.")

View File

@@ -0,0 +1,152 @@
"""
Read-only concurrent stress test against V3 list endpoints.
Fires N workers each making R sequential requests across a set of
list endpoints, then prints per-endpoint latency stats and an
overall error summary.
Usage (from project root):
./environment/bin/python3 tests/tools/stress_list_queries.py
./environment/bin/python3 tests/tools/stress_list_queries.py --workers 20 --requests 10
./environment/bin/python3 tests/tools/stress_list_queries.py --base-url https://api.oneskyit.com --workers 5
"""
import argparse
import math
import statistics
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
DEFAULT_BASE_URL = "https://test-api.oneskyit.com"
API_KEY = "nT0jPeiCfxSifkiDZur9jA"
ACCOUNT_ID = "_XY7DXtc9MY" # One Sky IT Demo
HEADERS = {
"x-aether-api-key": API_KEY,
"x-account-id": ACCOUNT_ID,
}
# Read-only list endpoints to hammer. Each is a (label, path) tuple.
ENDPOINTS = [
("event list", "/v3/crud/event/"),
("event_session list", "/v3/crud/event_session/"),
("event_badge list", "/v3/crud/event_badge/"),
("event_file list", "/v3/crud/event_file/"),
("person list", "/v3/crud/person/"),
("journal list", "/v3/crud/journal/"),
("hosted_file list", "/v3/crud/hosted_file/"),
("data_store list", "/v3/crud/data_store/"),
]
def percentile(sorted_times: list[float], pct: float) -> float:
"""Return the pct-th percentile of a pre-sorted list (0100)."""
if not sorted_times:
return 0.0
k = (len(sorted_times) - 1) * pct / 100
lo, hi = int(math.floor(k)), int(math.ceil(k))
return sorted_times[lo] + (sorted_times[hi] - sorted_times[lo]) * (k - lo)
def do_request(label: str, url: str, session: requests.Session) -> dict:
t0 = time.perf_counter()
try:
r = session.get(url, headers=HEADERS, timeout=15)
elapsed = (time.perf_counter() - t0) * 1000
return {"label": label, "status": r.status_code, "ms": elapsed, "error": None}
except Exception as e:
elapsed = (time.perf_counter() - t0) * 1000
return {"label": label, "status": 0, "ms": elapsed, "error": str(e)}
def worker(worker_id: int, requests_per_worker: int, base_url: str, limit: int) -> list[dict]:
results = []
with requests.Session() as session:
for _ in range(requests_per_worker):
for label, path in ENDPOINTS:
url = f"{base_url}{path}?limit={limit}"
results.append(do_request(label, url, session))
return results
def print_result(label, success, message=""):
icon = "" if success else ""
suffix = f"{message}" if message else ""
print(f" [{icon}] {label}{suffix}")
def main():
parser = argparse.ArgumentParser(description="Concurrent read-only stress test")
parser.add_argument("--workers", type=int, default=10, help="Concurrent worker threads (default: 10)")
parser.add_argument("--requests", type=int, default=5, help="Requests per worker per endpoint (default: 5)")
parser.add_argument("--limit", type=int, default=20, help="?limit= param on each list request (default: 20)")
parser.add_argument("--base-url", type=str, default=DEFAULT_BASE_URL, help=f"API base URL (default: {DEFAULT_BASE_URL})")
args = parser.parse_args()
total_requests = args.workers * args.requests * len(ENDPOINTS)
print(f"\n🔥 Stress Test: {args.workers} workers × {args.requests} rounds × {len(ENDPOINTS)} endpoints = {total_requests} total requests")
print(f" Target: {args.base_url} limit={args.limit}\n")
all_results: list[dict] = []
suite_start = time.perf_counter()
with ThreadPoolExecutor(max_workers=args.workers) as pool:
futures = [pool.submit(worker, wid, args.requests, args.base_url, args.limit) for wid in range(args.workers)]
for f in as_completed(futures):
all_results.extend(f.result())
suite_elapsed = time.perf_counter() - suite_start
# --- Per-endpoint stats ---
print("" * 60)
print(f"{'Endpoint':<35} {'OK':>5} {'ERR':>5} {'p50ms':>7} {'p95ms':>7} {'maxms':>7}")
print("" * 60)
by_label: dict[str, list[dict]] = {}
for r in all_results:
by_label.setdefault(r["label"], []).append(r)
any_fail = False
for label, _ in ENDPOINTS:
rows = by_label.get(label, [])
ok = [r for r in rows if r["status"] in (200, 201, 404) and not r["error"]]
err = [r for r in rows if r not in ok]
times = sorted(r["ms"] for r in ok)
p50 = statistics.median(times) if times else 0
p95 = percentile(times, 95)
mx = max(times) if times else 0
flag = "" if not err else ""
if err:
any_fail = True
print(f" {label:<33} {len(ok):>5} {len(err):>5} {p50:>7.0f} {p95:>7.0f} {mx:>7.0f}{flag}")
print("" * 60)
# --- Error detail ---
errors = [r for r in all_results if r["error"] or r["status"] not in (200, 201, 404)]
if errors:
print(f"\n{len(errors)} errors encountered:")
seen = set()
for r in errors:
key = (r["label"], r["status"], r["error"])
if key not in seen:
seen.add(key)
print(f" [{r['status']}] {r['label']}: {r['error'] or 'non-2xx/404'}")
else:
print("\n✅ Zero errors.")
# --- Overall summary ---
all_times = sorted(r["ms"] for r in all_results if not r["error"])
rps = total_requests / suite_elapsed
print(f"\n🏁 {total_requests} requests in {suite_elapsed:.2f}s ({rps:.1f} req/s)")
if all_times:
print(f" p50={statistics.median(all_times):.0f}ms "
f"p95={percentile(all_times, 95):.0f}ms "
f"max={max(all_times):.0f}ms\n")
sys.exit(1 if any_fail else 0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,219 @@
import sys
import os
import json
from unittest.mock import MagicMock, patch
# Add project root to path
sys.path.append(os.getcwd())
# Mock low-level deps BEFORE importing the target module.
# logger_reset must be a passthrough — if it stays a MagicMock the decorator
# replaces the decorated function with a MagicMock and tests get garbage results.
mock_lib_general = MagicMock()
mock_lib_general.logger_reset = lambda f: f
sys.modules['app.config'] = MagicMock()
sys.modules['app.lib_general'] = mock_lib_general
sys.modules['app.db_sql'] = MagicMock()
sys.modules['app.lib_redis_helpers'] = MagicMock()
from app.methods import idaa_novi_verify_methods as m
# ── Helpers ───────────────────────────────────────────────────────────────
def _make_cfg():
return {
'novi_api_root_url': 'https://www.idaa.org/api',
'novi_idaa_api_key': 'dGVzdGtleQ==',
}
def _novi_resp(email='alice@idaa.org', first='Alice', last='Smith', name=None):
d = {'Email': email, 'FirstName': first, 'LastName': last}
if name is not None:
d['Name'] = name
return d
def _set_redis(cached_value=None):
"""Set redis_client on the already-imported module's imported name."""
r = MagicMock()
r.get.return_value = cached_value
sys.modules['app.lib_redis_helpers'].redis_client = r
return r
# ── Cache hit bypasses Novi ───────────────────────────────────────────────
def test_cache_hit_bypasses_novi():
print('--- test_cache_hit_bypasses_novi ---')
cached = json.dumps({'status': 200, 'verified': True, 'full_name': 'Bob J.', 'email': 'bob@idaa.org'})
redis_mock = _set_redis(cached_value=cached)
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get') as mock_get:
result = m.verify_novi_member('some-uuid')
print('Result:', result)
assert result['status'] == 200
assert result['full_name'] == 'Bob J.'
mock_get.assert_not_called() # Novi was never contacted
print('PASS')
# ── Verified 200 ──────────────────────────────────────────────────────────
def test_verified_member_200():
print('--- test_verified_member_200 ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = _novi_resp()
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
print('Result:', result)
assert result['status'] == 200
assert result['verified'] is True
assert result['full_name'] == 'Alice S.'
assert result['email'] == 'alice@idaa.org'
redis_mock.setex.assert_called_once() # verified result cached
print('PASS')
# ── Email normalization: space → + ────────────────────────────────────────
def test_email_space_normalization():
print('--- test_email_space_normalization ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = _novi_resp(email='alice member@idaa.org')
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
print('Result:', result)
assert result['status'] == 200
assert result['email'] == 'alice+member@idaa.org'
print('PASS')
# ── Display name format ───────────────────────────────────────────────────
def test_display_name_format():
print('--- test_display_name_format ---')
cases = [
(_novi_resp(first='Alice', last='Smith'), 'Alice S.'),
(_novi_resp(first='Alice', last=''), 'Alice'),
(_novi_resp(first='', last='Smith', name='Dr. Alice'), 'Dr. Alice'),
(_novi_resp(first='', last='', name='Dr. Alice'), 'Dr. Alice'),
(_novi_resp(first='', last='', name=''), 'Member'),
]
for novi_data, expected_name in cases:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = novi_data
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('abc-123')
assert result['status'] == 200
assert result['full_name'] == expected_name, \
f"Expected '{expected_name}', got '{result['full_name']}' for input {novi_data}"
print('All display name cases PASS')
# ── Empty-member anti-pattern: Novi 200, no Email ─────────────────────────
def test_empty_member_returns_404():
print('--- test_empty_member_returns_404 ---')
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {} # Novi 200 with no identity data
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('ghost-uuid')
print('Result:', result)
assert result['status'] == 404
redis_mock.setex.assert_not_called() # 404 must NOT be cached
print('PASS')
# ── Novi 404 ──────────────────────────────────────────────────────────────
def test_novi_404_returns_404():
print('--- test_novi_404_returns_404 ---')
mock_resp = MagicMock()
mock_resp.status_code = 404
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('missing-uuid')
print('Result:', result)
assert result['status'] == 404
redis_mock.setex.assert_not_called()
print('PASS')
# ── Novi 429 ──────────────────────────────────────────────────────────────
def test_novi_429_returns_429():
print('--- test_novi_429_returns_429 ---')
mock_resp = MagicMock()
mock_resp.status_code = 429
redis_mock = _set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 429
redis_mock.setex.assert_not_called()
print('PASS')
# ── Novi 5xx → 503 ────────────────────────────────────────────────────────
def test_novi_5xx_returns_503():
print('--- test_novi_5xx_returns_503 ---')
mock_resp = MagicMock()
mock_resp.status_code = 502
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', return_value=mock_resp):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 503
print('PASS')
# ── Novi unreachable → 503 ────────────────────────────────────────────────
def test_novi_unreachable_returns_503():
print('--- test_novi_unreachable_returns_503 ---')
import requests as req_lib
_set_redis()
with patch.object(m, '_load_idaa_cfg', return_value=_make_cfg()), \
patch('requests.get', side_effect=req_lib.exceptions.ConnectionError('refused')):
result = m.verify_novi_member('any-uuid')
print('Result:', result)
assert result['status'] == 503
print('PASS')