Files
OSIT-AE-API-FastAPI/app/lib_redis_helpers.py
Scott Idem b63131e3fa fix(v3-nested): support aliases in nested CRUD routes
1. Added 'entry' alias for 'journal_entry' in object definitions.\n2. Updated nested router to resolve physical table names from the registry before ID resolution.\n3. Updated ID resolution helpers to recognize 'entry' prefix.\nThis resolves 404 errors when using shorter aliases in nested paths (e.g., /journal/{id}/entry/).
2026-02-06 14:13:22 -05:00

249 lines
10 KiB
Python

"""
Redis-based ID resolution and caching helpers for Aether.
"""
import datetime
import random
import redis
import logging
from app.config import settings
log = logging.getLogger(__name__)
def redis_lookup_id_random(
record_id_random: int|str,
table_name: str,
check_int_id: bool = False,
log_lvl: int = logging.WARNING, # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
minutes: int = 30, # Expire the Redis key after 8 minutes
reset_rate: int = 10, # 1 in 10 chance of resetting the Redis key
) -> str|int|bool|None:
"""
Looks up a record ID in Redis, falling back to SQL if not found.
Resolves 'id_random' (URL-safe string) to internal integer 'id'.
"""
from app.db_sql import sql_select, get_id_random
log.setLevel(log_lvl)
if isinstance(record_id_random, str) and len(record_id_random) >= 11 and len(record_id_random) <= 22: pass
elif isinstance(record_id_random, int):
record_id = record_id_random
if check_int_id:
log.info(f'Checking the int ID if exists. Table Name: {table_name} ID: {record_id}')
if get_id_random_result := get_id_random(
record_id = record_id,
table_name = table_name,
):
log.info(f'The int ID exists. Returning the int ID. ID Random: {get_id_random_result}')
return record_id
else:
log.info(f'The int ID does not exists. Returning False. Table Name: {table_name} ID: {record_id}')
return False
else:
log.debug(f'Not checking if the int ID exists. Returning the int ID. ID: {record_id}')
return record_id
elif record_id_random is None:
log.info(f'No record ID was passed. Returning None')
return None
else:
log.error(f'Unexpected data type or string format: {type(record_id_random)} Expected type is a string 11 or 22 characters long.')
return False
if record_id_random and table_name:
if len(record_id_random) < 11:
log.error(f'The length of id_random is too short: {record_id_random} ({len(record_id_random)} chars)')
return False
elif len(record_id_random) > 22:
log.error(f'The length of id_random is too long: {record_id_random} ({len(record_id_random)} chars)')
return False
elif record_id_random:
log.error(f'Missing table_name to select from for id_random "{record_id_random}"')
return False
elif table_name:
log.error(f'Missing id_random to select from table "{table_name}"')
return False
else:
log.error('Missing table_name and record_id_random')
return False
r = redis.Redis(host=settings.REDIS['server'], port=settings.REDIS['port'], db=7, password=None, decode_responses=True)
key_name = f'{table_name}:{record_id_random}'
record_id = r.get(key_name)
if record_id and random.randint(1, reset_rate) == 1:
log.warning(f'Redis: Randomly (1/{reset_rate}) setting record_id to None. Key="{key_name}" value="{record_id}" TTL={r.ttl(key_name)} seconds')
record_id = None
if record_id:
r.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
log.info(f'Redis: Entry found for: Key="{key_name}" value="{record_id}" TTL={r.ttl(key_name)} seconds')
return int(record_id)
elif table_name:
data = { 'id_random': record_id_random }
sql = f"SELECT id FROM `{table_name}` AS `table` WHERE `table`.id_random = :id_random;"
if select_results := sql_select(sql=sql, data=data):
log.debug(f'SQL: SELECT result: {select_results}')
if isinstance(select_results, dict):
log.info(f"""SQL: Found ID Random for: {str(record_id_random)} = {str(select_results.get('id'))}""")
if record_id := select_results.get('id'):
r.setex(key_name, datetime.timedelta(minutes=minutes), value=record_id)
return int(record_id)
else:
log.error('The SQL result was not what was expected. The ID field was not found.')
return False
else:
log.error(f'SQL: More than one record found in "{table_name}". Duplicate id_random!')
return redis_lookup_id_random(record_id_random=record_id_random, table_name=table_name)
else:
log.warning(f'SQL: ID Random "{record_id_random}" not found in "{table_name}". Returning None.')
return None
log.error('Unexpected state in redis_lookup_id_random.')
return False
def get_id_random(
record_id: int,
table_name: str,
log_lvl: int = logging.WARNING, # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
) -> str|bool|None:
"""
Looks up the 'id_random' for a given internal integer ID.
"""
from app.db_sql import sql_select
log.setLevel(log_lvl)
data = { 'id': record_id }
sql = f"SELECT id_random FROM `{table_name}` AS `table` WHERE `table`.id = :id;"
if select_results := sql_select(sql=sql, data=data):
if isinstance(select_results, dict):
if record_id_random := select_results.get('id_random'):
return str(record_id_random)
else:
log.error('The SQL result was not what was expected.')
return False
elif isinstance(select_results, list):
log.exception('More than one record may have been found. Duplicate ID!')
return False
else:
log.exception(f'Got an unexpected result while trying to look up the ID.')
return False
elif select_results is None:
return None
else:
return False
def reset_redis():
"""Flushes the Redis database used for ID caching."""
r = redis.Redis(host=settings.REDIS['server'], port=settings.REDIS['port'], db=7, password=None, decode_responses=True)
r.flushdb()
return True
def lookup_id_random_pop(
obj_data: dict,
log_lvl: int = logging.WARNING, # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
):
"""
Look up and resolve id_random values to their id
Remove the unneeded *_id_random key from the dict
"""
log.setLevel(log_lvl)
# Common prefixes for ID resolution
id_prefixes = [
'account', 'activity_log', 'address', 'address_location', 'archive',
'contact', 'contact_1', 'contact_2', 'cont_edu_cert', 'cont_edu_cert_person',
'entry', 'event', 'event_id_random_only', 'event_abstract', 'event_badge',
'event_badge_template', 'event_exhibit', 'event_file', 'event_location',
'event_person', 'event_person_profile', 'event_presentation',
'event_presenter', 'event_registration', 'event_session', 'event_track',
'grant', 'hosted_file', 'journal', 'journal_entry', 'membership_group',
'membership_person_group', 'membership_person', 'membership_type',
'membership_person_type', 'order', 'order_line', 'order_cart',
'order_cart_line', 'organization', 'page', 'person', 'poc_event_person',
'poc_person', 'post', 'product', 'sponsorship', 'sponsorship_cfg',
'site', 'user'
]
for prefix in id_prefixes:
key_random = f'{prefix}_id_random'
key_id = f'{prefix}_id'
# Table name mapping
table = prefix
if prefix == 'address_location': table = 'address'
elif prefix in ['contact_1', 'contact_2']: table = 'contact'
elif prefix == 'entry': table = 'journal_entry'
elif prefix == 'event_id_random_only': table = 'event'
elif prefix == 'poc_event_person': table = 'event_person'
elif prefix == 'poc_person': table = 'person'
resolved_id = None
# Scenario A: Legacy suffix (e.g., account_id_random: "abc")
if key_random in obj_data:
resolved_id = redis_lookup_id_random(record_id_random=obj_data[key_random], table_name=table)
obj_data.pop(key_random)
# Scenario B: Vision naming (e.g., account_id: "abc")
# Only resolve if it's a string of the correct length (random ID format)
elif key_id in obj_data and isinstance(obj_data[key_id], str) and 11 <= len(obj_data[key_id]) <= 22:
resolved_id = redis_lookup_id_random(record_id_random=obj_data[key_id], table_name=table)
if resolved_id is not None:
# Set the target ID field
target_id_key = key_id
if prefix == 'event_id_random_only': target_id_key = 'event_id_only'
obj_data[target_id_key] = resolved_id
# Removed the short prefix version (e.g., obj_data['account'] = 1)
# as it causes 'Unknown column' errors in direct table inserts.
# Polymorphic links
polymorphic = [
('for_type', 'for_id_random', 'for_id'),
('link_to_type', 'link_to_id_random', 'link_to_id'),
('object_type', 'object_id_random', 'object_id'),
('to_object_type', 'to_object_id_random', 'to_object_id'),
('from_object_type', 'from_object_id_random', 'from_object_id')
]
for type_key, rand_key, id_key in polymorphic:
# Handle random key if present
if type_key in obj_data and rand_key in obj_data:
obj_data[id_key] = redis_lookup_id_random(
record_id_random=obj_data.get(rand_key),
table_name=obj_data.get(type_key)
)
obj_data.pop(rand_key)
# Handle Vision naming (id_key contains the string)
elif type_key in obj_data and id_key in obj_data and isinstance(obj_data[id_key], str) and 11 <= len(obj_data[id_key]) <= 22:
obj_data[id_key] = redis_lookup_id_random(
record_id_random=obj_data.get(id_key),
table_name=obj_data.get(type_key)
)
return obj_data
def get_account_id_w_for_type_id(
for_type: str, # This is the table name
for_id: int|str,
) -> bool|int|None:
"""Helper to find an account_id associated with an object."""
from app.db_sql import sql_select
log.setLevel(logging.WARNING)
if fid := redis_lookup_id_random(record_id_random=for_id, table_name=for_type):
data = {'for_id': fid}
sql = f"SELECT account_id FROM `{for_type}` WHERE id = :for_id LIMIT 1;"
if result := sql_select(data=data, sql=sql):
return result.get('account_id')
return False
return None