feat(v3-vision): implement resilient "Heal-on-Read" ID resolution

1. Hardened Event_Exhibit and Event_Exhibit_Tracking models with automatic Redis/DB fallback for missing string IDs.
2. Fully modernized Event_Person_Tracking_Base to the Vision Standard (Union IDs + Root Validator).
3. Enabled account-based search for event_person_tracking.
4. Verified all changes via e2e demo parity suite.
This commit is contained in:
Scott Idem
2026-02-07 19:27:44 -05:00
parent e8f9472c5c
commit 9715d28bd6
4 changed files with 135 additions and 104 deletions

View File

@@ -39,21 +39,45 @@ class Event_Exhibit_Base(BaseModel):
""" """
Vision Transformer: Vision Transformer:
Map DB keys to clean API keys and strip internal integers during READ operations. Map DB keys to clean API keys and strip internal integers during READ operations.
Falls back to Redis/DB lookups if random string IDs are missing from the view.
""" """
# 1. Map Random Strings to Clean Names from app.db_sql import get_id_random
# 1. Map Primary Object ID
rid = values.get('id_random') or values.get('event_exhibit_id_random') rid = values.get('id_random') or values.get('event_exhibit_id_random')
if rid and isinstance(rid, str): if rid and isinstance(rid, str):
values['id'] = rid values['id'] = rid
values['event_exhibit_id'] = rid values['event_exhibit_id'] = rid
elif values.get('id') and isinstance(values.get('id'), int):
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid # Fallback for primary ID
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid resolved_rid = get_id_random(values['id'], 'event_exhibit')
if o_rid := values.get('organization_id_random'): values['organization_id'] = o_rid if resolved_rid:
if c_rid := values.get('contact_id_random'): values['contact_id'] = c_rid values['id'] = resolved_rid
if p_rid := values.get('person_id_random'): values['person_id'] = p_rid values['event_exhibit_id'] = resolved_rid
if s_rid := values.get('status_id_random'): values['status_id'] = s_rid values['id_random'] = resolved_rid
# 2. Prevent leakage of integers during API responses (Vision Standard) # 2. Map & Resolve Relational IDs
id_map = [
('account_id', 'account'),
('event_id', 'event'),
('organization_id', 'organization'),
('contact_id', 'contact'),
('person_id', 'person'),
('status_id', 'status'),
]
for field, table in id_map:
r_val = values.get(f'{field}_random')
if r_val and isinstance(r_val, str):
values[field] = r_val
elif values.get(field) and isinstance(values[field], int):
# Fallback: Resolve from Redis/DB if missing from view result
resolved_rid = get_id_random(values[field], table)
if resolved_rid:
values[field] = resolved_rid
values[f'{field}_random'] = resolved_rid
# 3. Final Vision Enforcement: Strip internal integers
for k in ['id', 'event_exhibit_id', 'account_id', 'event_id', 'organization_id', 'contact_id', 'person_id', 'status_id']: for k in ['id', 'event_exhibit_id', 'account_id', 'event_id', 'organization_id', 'contact_id', 'person_id', 'status_id']:
val = values.get(k) val = values.get(k)
if val is not None and not isinstance(val, str): if val is not None and not isinstance(val, str):

View File

@@ -39,21 +39,44 @@ class Event_Exhibit_Tracking_Base(BaseModel):
""" """
Vision Transformer: Vision Transformer:
Map DB keys to clean API keys and strip internal integers during READ operations. Map DB keys to clean API keys and strip internal integers during READ operations.
During CREATE (POST) operations, we ensure resolved integers are preserved. Falls back to Redis/DB lookups if random string IDs are missing from the view.
""" """
# 1. Map Random Strings to Clean Names from app.db_sql import get_id_random
# 1. Map Primary Object ID
rid = values.get('id_random') or values.get('event_exhibit_tracking_id_random') rid = values.get('id_random') or values.get('event_exhibit_tracking_id_random')
if rid and isinstance(rid, str): if rid and isinstance(rid, str):
values['id'] = rid values['id'] = rid
values['event_exhibit_tracking_id'] = rid values['event_exhibit_tracking_id'] = rid
elif values.get('id') and isinstance(values.get('id'), int):
if a_rid := values.get('account_id_random'): values['account_id'] = a_rid # Fallback for primary ID
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid resolved_rid = get_id_random(values['id'], 'event_exhibit_tracking')
if ex_rid := values.get('event_exhibit_id_random'): values['event_exhibit_id'] = ex_rid if resolved_rid:
if ep_rid := values.get('event_person_id_random'): values['event_person_id'] = ep_rid values['id'] = resolved_rid
if eb_rid := values.get('event_badge_id_random'): values['event_badge_id'] = eb_rid values['event_exhibit_tracking_id'] = resolved_rid
values['id_random'] = resolved_rid
# 2. Prevent leakage of integers during API responses (Vision Standard)
# 2. Map & Resolve Relational IDs
id_map = [
('account_id', 'account'),
('event_id', 'event'),
('event_exhibit_id', 'event_exhibit'),
('event_person_id', 'event_person'),
('event_badge_id', 'event_badge'),
]
for field, table in id_map:
r_val = values.get(f'{field}_random')
if r_val and isinstance(r_val, str):
values[field] = r_val
elif values.get(field) and isinstance(values[field], int):
# Fallback: Resolve from Redis/DB if missing from view result
resolved_rid = get_id_random(values[field], table)
if resolved_rid:
values[field] = resolved_rid
values[f'{field}_random'] = resolved_rid
# 3. Final Vision Enforcement: Strip internal integers
for k in ['id', 'event_exhibit_tracking_id', 'account_id', 'event_id', 'event_exhibit_id', 'event_person_id', 'event_badge_id']: for k in ['id', 'event_exhibit_tracking_id', 'account_id', 'event_id', 'event_exhibit_id', 'event_person_id', 'event_badge_id']:
val = values.get(k) val = values.get(k)
if val is not None and not isinstance(val, str): if val is not None and not isinstance(val, str):

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import datetime, hashlib, logging, os, pytz, redis, secrets import datetime, hashlib, logging, os, pytz, redis, secrets
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
from app.db_sql import redis_lookup_id_random from app.db_sql import redis_lookup_id_random
from app.lib_general import log, logging from app.lib_general import log, logging
@@ -14,26 +14,69 @@ class Event_Person_Tracking_Base(BaseModel):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals()) log.debug(locals())
id_random: Optional[str] = Field( # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
**base_fields['event_person_tracking_id_random'], id: Optional[Union[int, str]] = Field(**base_fields['event_person_tracking_id_random'])
alias = 'event_person_tracking_id_random', event_person_tracking_id: Optional[Union[int, str]] = Field(**base_fields['event_person_tracking_id_random'])
default_factory = lambda:secrets.token_urlsafe(default_num_bytes), account_id: Optional[Union[int, str]] = Field(**base_fields['account_id_random'])
) event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
id: Optional[int] = Field( event_session_id: Optional[Union[int, str]] = Field(**base_fields['event_session_id_random'])
alias = 'event_person_tracking_id' event_person_id: Optional[Union[int, str]] = Field(**base_fields['event_person_id_random'])
)
# account_id_random: Optional[str] # --- Standardized Legacy / Internal IDs (Excluded) ---
# account_id: Optional[int] id_random: Optional[str] = Field(None, alias='event_person_tracking_id_random', exclude=True)
account_id_random: Optional[str] = Field(None, exclude=True)
event_id_random: Optional[str] = Field(None, exclude=True)
event_session_id_random: Optional[str] = Field(None, exclude=True)
event_person_id_random: Optional[str] = Field(None, exclude=True)
event_id_random: Optional[str] @root_validator(pre=True)
event_id: Optional[int] def map_v3_ids(cls, values):
"""
Vision Transformer:
Map DB keys to clean API keys and strip internal integers during READ operations.
Falls back to Redis/DB lookups if random string IDs are missing from the view.
"""
from app.db_sql import get_id_random
event_session_id_random: Optional[str] # 1. Map Primary Object ID
event_session_id: Optional[int] rid = values.get('id_random') or values.get('event_person_tracking_id_random')
if rid and isinstance(rid, str):
values['id'] = rid
values['event_person_tracking_id'] = rid
elif values.get('id') and isinstance(values.get('id'), int):
# Fallback for primary ID
resolved_rid = get_id_random(values['id'], 'event_person_tracking')
if resolved_rid:
values['id'] = resolved_rid
values['event_person_tracking_id'] = resolved_rid
values['id_random'] = resolved_rid
event_person_id_random: Optional[str] # 2. Map & Resolve Relational IDs
event_person_id: Optional[int] id_map = [
('account_id', 'account'),
('event_id', 'event'),
('event_session_id', 'event_session'),
('event_person_id', 'event_person'),
]
for field, table in id_map:
r_val = values.get(f'{field}_random')
if r_val and isinstance(r_val, str):
values[field] = r_val
elif values.get(field) and isinstance(values[field], int):
# Fallback: Resolve from Redis/DB if missing from view result
resolved_rid = get_id_random(values[field], table)
if resolved_rid:
values[field] = resolved_rid
values[f'{field}_random'] = resolved_rid
# 3. Final Vision Enforcement: Strip internal integers
for k in ['id', 'event_person_tracking_id', 'account_id', 'event_id', 'event_session_id', 'event_person_id']:
val = values.get(k)
if val is not None and not isinstance(val, str):
values[k] = None
return values
check_in_out: Optional[bool] check_in_out: Optional[bool]
break_in_out: Optional[bool] break_in_out: Optional[bool]
@@ -43,15 +86,11 @@ class Event_Person_Tracking_Base(BaseModel):
in_datetime: Optional[datetime.datetime] # This should generally default to the created datetime and be overridden as needed in_datetime: Optional[datetime.datetime] # This should generally default to the created datetime and be overridden as needed
out_datetime: Optional[datetime.datetime] # This should generally default to the updated datetime and be overridden as needed out_datetime: Optional[datetime.datetime] # This should generally default to the updated datetime and be overridden as needed
# Maybe add minutes or hours? check_in: Optional[bool]
# Maybe add timezone? break_out: Optional[bool]
break_in: Optional[bool]
check_out: Optional[bool]
check_in: Optional[bool] # Does this make sense to use instead? datetime: Optional[datetime.datetime]
break_out: Optional[bool] # Does this make sense to use instead?
break_in: Optional[bool] # Does this make sense to use instead?
check_out: Optional[bool] # Does this make sense to use instead?
datetime: Optional[datetime.datetime] # This should generally default to the created datetime and be overridden as needed
enable: Optional[bool] enable: Optional[bool]
@@ -60,14 +99,6 @@ class Event_Person_Tracking_Base(BaseModel):
updated_on: Optional[datetime.datetime] = None updated_on: Optional[datetime.datetime] = None
# Including convenience data # Including convenience data
# This is only for convenience. Probably going to keep unless it causes a problem.
# full_name: Optional[str] = Field(
# alias = 'event_person_full_name'
# )
# display_name: Optional[str] = Field(
# alias = 'event_person_display_name'
# )
event_person_informal_name: Optional[str] event_person_informal_name: Optional[str]
event_person_given_name: Optional[str] event_person_given_name: Optional[str]
event_person_family_name: Optional[str] event_person_family_name: Optional[str]
@@ -83,57 +114,10 @@ class Event_Person_Tracking_Base(BaseModel):
track_name: Optional[str] = Field( track_name: Optional[str] = Field(
alias = 'event_track_name' alias = 'event_track_name'
) )
# Maybe add timezone in the future?
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
#@validator('event_person_tracking_id_random', always=True)
def event_person_tracking_id_random_copy(cls, v, values, **kwargs):
log.setLevel(logging.WARNING)
log.debug(locals())
if values['id_random']:
return values['id_random']
return None
@validator('id', always=True)
def event_person_tracking_id_lookup(cls, v, values, **kwargs):
log.setLevel(logging.WARNING)
log.debug(locals())
if values.get('id_random', None):
log.debug(values['id_random'])
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='event_person_tracking')
return None
@validator('event_id', always=True)
def event_id_lookup(cls, v, values, **kwargs):
log.setLevel(logging.WARNING)
log.debug(locals())
if values.get('event_id_random', None):
return redis_lookup_id_random(record_id_random=values['event_id_random'], table_name='event')
return None
@validator('event_session_id', always=True)
def event_session_id_lookup(cls, v, values, **kwargs):
log.setLevel(logging.WARNING)
log.debug(locals())
if values.get('event_session_id_random', None):
return redis_lookup_id_random(record_id_random=values['event_session_id_random'], table_name='event_session')
return None
@validator('event_person_id', always=True)
def event_person_id_lookup(cls, v, values, **kwargs):
log.setLevel(logging.WARNING)
log.debug(locals())
if values.get('event_person_id_random', None):
return redis_lookup_id_random(record_id_random=values['event_person_id_random'], table_name='event_person')
return None
class Config: class Config:
underscore_attrs_are_private = True underscore_attrs_are_private = True
allow_population_by_field_name = True allow_population_by_field_name = True
fields = base_fields fields = base_fields

View File

@@ -113,8 +113,8 @@ events_registration_obj_li = {
'base_name': Event_Person_Tracking_Base, 'base_name': Event_Person_Tracking_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'id', 'event_person_tracking_id', 'event_id', 'event_session_id', 'event_person_id', 'id', 'event_person_tracking_id', 'account_id', 'event_id', 'event_session_id', 'event_person_id',
'id_random', 'event_person_tracking_id_random', 'event_id_random', 'id_random', 'event_person_tracking_id_random', 'account_id_random', 'event_id_random',
'event_session_id_random', 'event_person_id_random', 'event_session_id_random', 'event_person_id_random',
'check_in_out', 'in_datetime', 'out_datetime', 'enable', 'notes', 'check_in_out', 'in_datetime', 'out_datetime', 'enable', 'notes',
'created_on', 'updated_on' 'created_on', 'updated_on'