21 Commits

Author SHA1 Message Date
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
27 changed files with 782 additions and 867 deletions

View File

@@ -1,5 +1,5 @@
# Aether API v3.x (FastAPI)
# Aether API v3.00.xx (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

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

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

@@ -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,7 @@ 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
# Including convenience data
# This is only for convenience. Probably going to keep unless it causes a problem.
@@ -193,7 +194,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 +207,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

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

@@ -168,7 +168,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 +189,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.
@@ -496,7 +521,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 +582,7 @@ async def delete_obj(
):
"""
Delete Object.
Supports:
- Soft Delete: `method='hide'` or `method='disable'`.
- Hard Delete: `method='delete'`.

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
@@ -472,4 +472,303 @@ 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 _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}'")
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,
},
}
# 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}')
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)

View File

@@ -23,7 +23,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)])
@@ -62,13 +62,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(user.router, tags=['User'], dependencies=[Depends(DeprecationParams)])
app.include_router(util_email.router, tags=['Utility: Email'])
# 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

@@ -44,6 +44,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

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

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