feat: Implement Event File Hosted Data Fix and API Guide Update

Address critical data visibility issues for Event Files and enhance frontend documentation.

This commit resolves the persistent problem where top-level hosted file convenience fields
(e.g., , , ) were
returning as  in V3 Event File API responses, even when .

Key changes include:
- Refactored  Pydantic model:
    - Removed redundant  definitions from top-level hosted file convenience fields,
      allowing direct mapping from SQL view columns.
    - Simplified  to focus solely on conditionally loading the nested
       object, as top-level fields are now populated directly by Pydantic
      from the  view.
    - Added comprehensive comments to clarify data flow, Pydantic's behavior, and the
      expected origin of these convenience fields from SQL views.
- Updated :
    - Introduced a new section detailing how to retrieve Event File data, including the
      use of  to get both top-level convenience fields and a nested
       object.
    - Clarified all ID references as random string IDs.
    - Renumbered the troubleshooting section.
- Copied updated guide to .
- Continued ID Vision compliance audit, ensuring consistent handling of random string IDs
  across various core and event models (Account, Address, Contact, DataStore, Event Badge Template).
- Consolidated ID Vision E2E tests and updated related documentation.
- Minor updates to  and
  to support Event File data retrieval with .
This commit is contained in:
Scott Idem
2026-02-19 15:22:17 -05:00
parent 577d784fb8
commit 17a627a981
17 changed files with 391 additions and 185 deletions

View File

@@ -83,7 +83,15 @@ def load_event_file_obj(
enabled = enabled, enabled = enabled,
): ):
event_file_obj.hosted_file = hosted_file_obj event_file_obj.hosted_file = hosted_file_obj
# event_file_obj.hosted_file = hosted_file_obj.dict(by_alias=by_alias, exclude_unset=exclude_unset) # Explicitly populate convenience fields from hosted_file_obj
if hosted_file_obj.hash_sha256:
event_file_obj.hosted_file_hash_sha256 = hosted_file_obj.hash_sha256
if hosted_file_obj.subdirectory_path:
event_file_obj.hosted_file_subdirectory_path = hosted_file_obj.subdirectory_path
if hosted_file_obj.content_type:
event_file_obj.hosted_file_content_type = hosted_file_obj.content_type
if hosted_file_obj.size:
event_file_obj.hosted_file_size = str(hosted_file_obj.size) # Ensure it's a string as per model definition
else: else:
event_file_obj.hosted_file = {} event_file_obj.hosted_file = {}
else: else:

View File

@@ -1,7 +1,7 @@
import datetime, pytz import datetime, pytz
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union, ClassVar
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
from app.db_sql import redis_lookup_id_random from app.db_sql import redis_lookup_id_random
from app.lib_general import log, logging from app.lib_general import log, logging
@@ -22,16 +22,12 @@ class Account_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) ---
**base_fields['account_id_random'], id: Optional[str] = Field(None, **base_fields['account_id_random'])
alias = 'account_id_random', account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
# default_factory = lambda:secrets.token_urlsafe(default_num_bytes),
) # --- Standardized Legacy / Internal IDs (Excluded) ---
id: Optional[int] = Field( id_random: Optional[str] = Field(None, alias='account_id_random', exclude=True)
alias = 'account_id'
)
# account_id: Optional[int] = Field(
# )
code: Optional[str] code: Optional[str]
name: Optional[str] name: Optional[str]
@@ -77,28 +73,29 @@ class Account_Base(BaseModel):
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@validator('id', always=True) @root_validator(pre=True)
def account_id_lookup(cls, v, values, **kwargs): def map_v3_ids(cls, values):
log.setLevel(logging.WARNING) """
log.debug(locals()) Vision Transformer:
Map DB keys to clean API keys and strip internal integers.
if values['id_random']: """
log.debug(values['id_random']) # 1. Map Random Strings to Clean Names
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='account') if rid := values.get('id_random') or values.get('account_id_random'):
return None values['id'] = rid
values['account_id'] = rid
# @validator('account_id', always=True)
# def account_id_duplicate(cls, v, values, **kwargs): # 2. Final Vision Enforcement: Strip internal integers from public fields
# log.setLevel(logging.DEBUG) for k in ['id', 'account_id']:
# log.debug(locals()) val = values.get(k)
if val is not None:
# if values['id']: # If it's not a valid random string ID
# log.debug(values['id']) if not isinstance(val, str) or len(val) < 11:
# return values['id'] values[k] = None
# return None
return values
class Config: class Config:
underscore_attrs_are_private = True underscore_attrs_are_private = True
allow_population_by_field_name = False
fields = base_fields fields = base_fields
allow_population_by_field_name = True
# ### END ### API Account Models ### Account_Base() ### # ### END ### API Account Models ### Account_Base() ###

View File

@@ -14,15 +14,21 @@ class Address_Base(BaseModel):
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals()) log.debug(locals())
# --- Standardized Vision IDs (Strings) --- # Standardized Vision IDs (Strings) ---
id: Optional[str] = Field(None, **base_fields['address_id_random']) id: Optional[str] = Field(None, **base_fields['address_id_random'])
address_id: Optional[str] = Field(None, **base_fields['address_id_random']) address_id: Optional[str] = Field(None, **base_fields['address_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random']) account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
contact_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
# Standardized Polymorphic Target
for_type: Optional[str] for_type: Optional[str]
for_id_random: Optional[str] for_id: Optional[str] = Field(**base_fields['obj_id_random'])
for_id: Optional[int] = Field(None, exclude=True)
# --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='address_id_random', exclude=True)
account_id_random: Optional[str] = Field(None, exclude=True)
contact_id_random: Optional[str] = Field(None, exclude=True)
for_id_random: Optional[str] = Field(None, exclude=True)
#organization: Optional[Organization_Base] = Organization_Base() #organization: Optional[Organization_Base] = Organization_Base()
@@ -42,7 +48,7 @@ class Address_Base(BaseModel):
country_name: Optional[str] # From country lookup table country_name: Optional[str] # From country lookup table
country: Optional[str] # Avoid using country: Optional[str] # Avoid using
lu_time_zone_id: Optional[str] lu_time_zone_id: Optional[str] = Field(None, exclude=True)
timezone: Optional[str] timezone: Optional[str]
latitude: Optional[str] latitude: Optional[str]
@@ -70,20 +76,47 @@ class Address_Base(BaseModel):
Vision Transformer: Vision Transformer:
Map DB keys to clean API keys and strip internal integers. Map DB keys to clean API keys and strip internal integers.
""" """
# 1. Map Random Strings to Clean Names # 1. Map Primary Object ID
if rid := values.get('id_random') or values.get('address_id_random'): if rid := values.get('id_random') or values.get('address_id_random'):
values['id'] = rid values['id'] = rid
values['address_id'] = rid values['address_id'] = rid
if a_rid := values.get('account_id_random'): # 2. Map & Resolve Relational IDs
values['account_id'] = a_rid id_map = [
if c_rid := values.get('contact_id_random'): ('account_id', 'account'),
values['contact_id'] = c_rid ('contact_id', 'contact'),
]
# 2. Prevent "Collision Population"
for k in ['id', 'account_id', 'contact_id']: for field, table in id_map:
if k in values and not isinstance(values[k], str): r_val = values.get(f'{field}_random')
del values[k] if r_val and isinstance(r_val, str):
values[field] = r_val
elif values.get(field) and isinstance(values[field], (int, str)):
is_random = isinstance(values[field], str) and len(values[field]) >= 11
if not is_random:
resolved_rid = get_id_random(values[field], table)
if resolved_rid:
values[field] = resolved_rid
values[f'{field}_random'] = resolved_rid
# 3. Handle Polymorphic for_id
if f_rid := values.get('for_id_random'):
values['for_id'] = f_rid
elif values.get('for_id') and values.get('for_type'):
# Resolve based on the for_type
is_random = isinstance(values['for_id'], str) and len(values['for_id']) >= 11
if not is_random:
resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
if resolved_for_rid:
values['for_id'] = resolved_for_rid
values['for_id_random'] = resolved_for_rid
# 4. Final Vision Enforcement
for k in ['id', 'address_id', 'account_id', 'contact_id', 'for_id']:
val = values.get(k)
if val is not None:
if not isinstance(val, str) or len(val) < 11:
values[k] = None
return values return values

View File

@@ -26,9 +26,16 @@ class Contact_Base(BaseModel):
# NOTE: Linked Address ID is actually the old contact.address_id (Legacy?) # NOTE: Linked Address ID is actually the old contact.address_id (Legacy?)
linked_address_id: Optional[str] = Field(None, **base_fields['address_id_random']) linked_address_id: Optional[str] = Field(None, **base_fields['address_id_random'])
# Standardized Polymorphic Target
for_type: Optional[str] for_type: Optional[str]
for_id: Optional[int] for_id: Optional[str] = Field(**base_fields['obj_id_random'])
for_id_random: Optional[Union[str,None]] = None # lambda:get_id_random(values.get('for_id'), table_name=values.get('for_type')),
# --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='contact_id_random', exclude=True)
account_id_random: Optional[str] = Field(None, exclude=True)
address_id_random: Optional[str] = Field(None, exclude=True)
linked_address_id_random: Optional[str] = Field(None, exclude=True)
for_id_random: Optional[str] = Field(None, exclude=True)
name: Optional[str] name: Optional[str]
title: Optional[str] title: Optional[str]
@@ -87,65 +94,51 @@ class Contact_Base(BaseModel):
Vision Transformer: Vision Transformer:
Map DB keys to clean API keys and strip internal integers. Map DB keys to clean API keys and strip internal integers.
""" """
# 1. Map Random Strings to Clean Names # 1. Map Primary Object ID
if rid := values.get('id_random') or values.get('contact_id_random'): if rid := values.get('id_random') or values.get('contact_id_random'):
values['id'] = rid values['id'] = rid
values['contact_id'] = rid values['contact_id'] = rid
if a_rid := values.get('account_id_random'): # 2. Map & Resolve Relational IDs
values['account_id'] = a_rid id_map = [
if ad_rid := values.get('address_id_random'): ('account_id', 'account'),
values['address_id'] = ad_rid ('address_id', 'address'),
if lad_rid := values.get('linked_address_id_random'): ('linked_address_id', 'address'),
values['linked_address_id'] = lad_rid ]
# 2. Prevent "Collision Population" for field, table in id_map:
for k in ['id', 'contact_id', 'account_id', 'address_id', 'linked_address_id']: r_val = values.get(f'{field}_random')
if k in values and not isinstance(values[k], str) and values[k] is not None: if r_val and isinstance(r_val, str):
del values[k] values[field] = r_val
elif values.get(field) and isinstance(values[field], (int, str)):
is_random = isinstance(values[field], str) and len(values[field]) >= 11
if not is_random:
resolved_rid = get_id_random(values[field], table)
if resolved_rid:
values[field] = resolved_rid
values[f'{field}_random'] = resolved_rid
# 3. Handle Polymorphic for_id
if f_rid := values.get('for_id_random'):
values['for_id'] = f_rid
elif values.get('for_id') and values.get('for_type'):
# Resolve based on the for_type
is_random = isinstance(values['for_id'], str) and len(values['for_id']) >= 11
if not is_random:
resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
if resolved_for_rid:
values['for_id'] = resolved_for_rid
values['for_id_random'] = resolved_for_rid
# 4. Final Vision Enforcement
for k in ['id', 'contact_id', 'account_id', 'address_id', 'linked_address_id', 'for_id']:
val = values.get(k)
if val is not None:
if not isinstance(val, str) or len(val) < 11:
values[k] = None
return values return values
@validator('for_id', pre=True, always=True)
def for_id_lookup(cls, v, values, **kwargs):
log.setLevel(logging.DEBUG)
log.debug(locals())
for_type = values.get('for_type')
for_id = v # values.get('for_id')
for_id_random = values.get('for_id_random')
if for_id and for_type:
log.info(f'Got For ID: {for_id}; For Type: {for_type}')
for_id_random = get_id_random(for_id, table_name=for_type)
values['for_id_random'] = for_id_random
return for_id
elif values.get('for_id_random') and values.get('for_type'):
log.info(f'Got For ID Random: {for_id_random}; For Type: {for_type}')
return redis_lookup_id_random(record_id_random=values['for_id_random'], table_name=values['for_type'])
log.info(f'Got nothing? For ID: {for_id}; For ID Random: {for_id_random}; For Type: {for_type}')
return None
@validator('for_id_random', always=True)
def for_id_random_lookup(cls, v, values, **kwargs):
log.setLevel(logging.DEBUG)
log.debug(locals())
for_type = values.get('for_type')
for_id = values.get('for_id')
for_id_random = v
if for_id_random:
log.info(f'Got For ID Random: {for_id_random}')
return for_id_random
elif for_id and for_type:
log.info(f'Got For ID: {for_id}; For Type: {for_type}')
for_id_random = get_id_random(for_id, table_name=for_type)
log.info(f'Got ID Random: {for_id_random}')
return for_id_random
log.info(f'Got nothing? For ID: {for_id}; For ID Random: {for_id_random}; For Type: {for_type}')
return None
class Config: class Config:
underscore_attrs_are_private = True underscore_attrs_are_private = True
allow_population_by_field_name = False allow_population_by_field_name = False

View File

@@ -3,7 +3,7 @@ import datetime, pytz
from typing import Dict, List, Optional, Set, Union, ClassVar from typing import Dict, List, Optional, Set, Union, ClassVar
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_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 get_id_random, redis_lookup_id_random
from app.lib_general import log, logging from app.lib_general import log, logging
from app.models.common_field_schema import base_fields from app.models.common_field_schema import base_fields
@@ -22,18 +22,16 @@ class Data_Store_Base(BaseModel):
person_id: Optional[str] = Field(None, **base_fields['person_id_random']) person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random']) user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
# Internal Integer IDs (Excluded from API) # Standardized Polymorphic Target
# We use Optional[Union[int, str]] here to prevent validation crashes
# if the DB returns stringified integers or "NULL" strings.
id_int: Optional[Union[int, str]] = Field(None, alias='id', exclude=True)
account_id_int: Optional[Union[int, str]] = Field(None, alias='account_id', exclude=True)
person_id_int: Optional[Union[int, str]] = Field(None, alias='person_id', exclude=True)
user_id_int: Optional[Union[int, str]] = Field(None, alias='user_id', exclude=True)
for_type: Optional[str] for_type: Optional[str]
for_id: Optional[str] # Random ID string for_id: Optional[str] = Field(**base_fields['obj_id_random'])
for_id_random: Optional[str] # Svelte often uses this name
for_id_int: Optional[Union[int, str]] = Field(None, alias='for_id', exclude=True) # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='data_store_id_random', exclude=True)
account_id_random: Optional[str] = Field(None, exclude=True)
person_id_random: Optional[str] = Field(None, exclude=True)
user_id_random: Optional[str] = Field(None, exclude=True)
for_id_random: Optional[str] = Field(None, exclude=True)
code: Optional[str] code: Optional[str]
name: Optional[str] name: Optional[str]
@@ -82,28 +80,50 @@ class Data_Store_Base(BaseModel):
if isinstance(v, str) and v.upper() == 'NULL': if isinstance(v, str) and v.upper() == 'NULL':
values[k] = None values[k] = None
# 1. Map Random Strings to Clean Names # 1. Map Primary Object ID
if rid := values.get('id_random') or values.get('data_store_id_random'): if rid := values.get('id_random') or values.get('data_store_id_random'):
values['id'] = rid values['id'] = rid
values['data_store_id'] = rid values['data_store_id'] = rid
if a_rid := values.get('account_id_random'): # 2. Map & Resolve Relational IDs
values['account_id'] = a_rid id_map = [
if p_rid := values.get('person_id_random'): ('account_id', 'account'),
values['person_id'] = p_rid ('person_id', 'person'),
if u_rid := values.get('user_id_random'): ('user_id', 'user'),
values['user_id'] = u_rid ]
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, str)):
# If it's a string but doesn't look like a random ID (e.g. integer string), resolve it
is_random = isinstance(values[field], str) and len(values[field]) >= 11
if not is_random:
resolved_rid = get_id_random(values[field], table)
if resolved_rid:
values[field] = resolved_rid
values[f'{field}_random'] = resolved_rid
# 3. Handle Polymorphic for_id
if f_rid := values.get('for_id_random'): if f_rid := values.get('for_id_random'):
values['for_id'] = f_rid values['for_id'] = f_rid
values['for_id_random'] = f_rid elif values.get('for_id') and values.get('for_type'):
# Resolve based on the for_type
# 2. Prevent "Collision Population" is_random = isinstance(values['for_id'], str) and len(values['for_id']) >= 11
# We only want strings in our primary ID fields. if not is_random:
# If the key exists and isn't a string, it's a DB integer; remove it resolved_for_rid = get_id_random(values['for_id'], values['for_type'])
# so it doesn't fail length validation on the string fields. if resolved_for_rid:
values['for_id'] = resolved_for_rid
values['for_id_random'] = resolved_for_rid
# 4. Final Vision Enforcement: Strip internal integers from public fields
for k in ['id', 'data_store_id', 'account_id', 'person_id', 'user_id', 'for_id']: for k in ['id', 'data_store_id', 'account_id', 'person_id', 'user_id', 'for_id']:
if k in values and not isinstance(values[k], str): val = values.get(k)
del values[k] # If value is present but not a valid random string ID
if val is not None:
if not isinstance(val, str) or len(val) < 11:
values[k] = None
return values return values

View File

@@ -1,7 +1,7 @@
import datetime, pytz import datetime, pytz
from typing import Dict, List, Optional, Set, Union from typing import Dict, List, Optional, Set, Union, ClassVar
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
from app.db_sql import redis_lookup_id_random from app.db_sql import redis_lookup_id_random
from app.lib_general import log, logging from app.lib_general import log, logging
@@ -15,19 +15,14 @@ class Event_Badge_Template_Base(BaseModel):
# log.info('Using base template') # log.info('Using base template')
id_random: Optional[str] = Field( # --- Standardized Vision IDs (Strings) ---
**base_fields['event_badge_template_id_random'], id: Optional[str] = Field(None, **base_fields['event_badge_template_id_random'])
alias = 'event_badge_template_id_random', event_badge_template_id: Optional[str] = Field(None, **base_fields['event_badge_template_id_random'])
) event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
id: Optional[int] = Field(
alias = 'event_badge_template_id'
)
# account_id_random: Optional[str] # --- Standardized Legacy / Internal IDs (Excluded) ---
# account_id: Optional[int] id_random: Optional[str] = Field(None, alias='event_badge_template_id_random', exclude=True)
event_id_random: Optional[str] = Field(None, exclude=True)
event_id_random: Optional[str]
event_id: Optional[int]
name: Optional[str] name: Optional[str]
description: Optional[str] description: Optional[str]
@@ -83,23 +78,30 @@ class Event_Badge_Template_Base(BaseModel):
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
@validator('id', always=True) @root_validator(pre=True)
def event_badge_template_id_lookup(cls, v, values, **kwargs): def map_v3_ids(cls, values):
if isinstance(v, int) and v > 0: return v """
elif id_random := values.get('id_random'): Vision Transformer:
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge_template') Map DB keys to clean API keys and strip internal integers.
return None """
# 1. Map Random Strings to Clean Names
@validator('event_id', always=True) if rid := values.get('id_random') or values.get('event_badge_template_id_random'):
def event_id_lookup(cls, v, values, **kwargs): values['id'] = rid
if isinstance(v, int) and v > 0: return v values['event_badge_template_id'] = rid
elif id_random := values.get('event_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event') if e_rid := values.get('event_id_random'):
return None values['event_id'] = e_rid
# 2. Prevent "Collision Population" (ensure no integers leak into the clean string fields)
for k in ['id', 'event_badge_template_id', 'event_id']:
if k in values and not isinstance(values[k], str) and values[k] is not None:
del values[k]
return values
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 = False
fields = base_fields fields = base_fields

View File

@@ -46,6 +46,9 @@ class Event_File_Base(BaseModel):
event_session_id_random: Optional[str] = Field(None, exclude=True) event_session_id_random: Optional[str] = Field(None, exclude=True)
event_track_id_random: Optional[str] = Field(None, exclude=True) event_track_id_random: Optional[str] = Field(None, exclude=True)
# Internal flag to signal the model to load nested hosted_file
inc_hosted_file: Optional[bool] = Field(False, exclude=True)
@root_validator(pre=True) @root_validator(pre=True)
def map_v3_ids(cls, values): def map_v3_ids(cls, values):
""" """
@@ -102,9 +105,20 @@ class Event_File_Base(BaseModel):
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):
values[k] = None values[k] = None
# 4. Conditionally load nested 'hosted_file' object
if values.get('inc_hosted_file') and values.get('hosted_file_id'):
from app.methods.hosted_file_methods import load_hosted_file_obj
if hosted_file_obj := load_hosted_file_obj(hosted_file_id=values['hosted_file_id']):
values['hosted_file'] = hosted_file_obj
# Clean up internal inc_hosted_file flag after processing
if 'inc_hosted_file' in values:
del values['inc_hosted_file']
return values return values
for_type: Optional[str] for_type: Optional[str]
filename: Optional[str] filename: Optional[str]
@@ -114,7 +128,7 @@ class Event_File_Base(BaseModel):
title: Optional[str] title: Optional[str]
description: Optional[str] description: Optional[str]
lu_file_purpose_id: Optional[int] lu_file_purpose_id: Optional[int] = Field(None, exclude=True)
file_purpose: Optional[str] file_purpose: Optional[str]
# New internal use fields to help with logistics and planning 2022-09-15 # New internal use fields to help with logistics and planning 2022-09-15
@@ -143,21 +157,21 @@ class Event_File_Base(BaseModel):
created_on: Optional[datetime.datetime] = None created_on: Optional[datetime.datetime] = None
updated_on: Optional[datetime.datetime] = None updated_on: Optional[datetime.datetime] = None
# Including convenience data # Including convenience data for Hosted Files (top-level properties)
# This is only for convenience. Probably going to keep unless it causes a problem. # These fields provide direct access to frequently needed properties from the associated
hosted_file_hash_sha256: Optional[str] = Field( # hosted file, effectively flattening some aspects of the nested 'hosted_file' object.
alias = 'hash_sha256' #
) # IMPORTANT: These fields are designed to be populated directly from the SQL View
hosted_file_subdirectory_path: Optional[str] = Field( # NOTE: This will frequently only contain numbers, but it still needs to be a string # (e.g., `v_event_file_simple`) via JOINs. They should **NOT** have Pydantic `alias`
alias = 'subdirectory_path', # definitions here if the view provides them with matching names (e.g., `hosted_file_hash_sha256`).
exclude = True # Pydantic's default mapping will handle them directly from the incoming data dictionary
) # (the `sql_result` in `api_crud_v3.py`).
hosted_file_content_type: Optional[str] = Field( # The `root_validator` does **NOT** populate these top-level fields; its role is
alias = 'content_type' # solely to conditionally load the *nested* `hosted_file` object.
) hosted_file_hash_sha256: Optional[str]
hosted_file_size: Optional[str] = Field( hosted_file_subdirectory_path: Optional[str]
alias = 'file_size' hosted_file_content_type: Optional[str]
) hosted_file_size: Optional[str]
lu_event_file_purpose_name: Optional[str] = Field( lu_event_file_purpose_name: Optional[str] = Field(
alias = 'file_purpose_name' alias = 'file_purpose_name'
@@ -194,6 +208,6 @@ class Event_File_Base(BaseModel):
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 = False
fields = base_fields fields = base_fields
# ### END ### API Event File Models ### Event_File_Base() ### # ### END ### API Event File Models ### Event_File_Base() ###

View File

@@ -67,6 +67,7 @@ events_general_obj_li = {
'base_name': Event_File_Base, 'base_name': Event_File_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_id', 'event_file_id', 'hosted_file_id', 'event_id', 'event_file_id', 'hosted_file_id',
'event_file_id_random', 'hosted_file_id_random', 'event_id_random', 'event_file_id_random', 'hosted_file_id_random', 'event_id_random',
'event_exhibit_id_random', 'event_location_id_random', 'event_exhibit_id_random', 'event_location_id_random',
@@ -114,6 +115,7 @@ events_general_obj_li = {
'base_name': Event_Cfg_Base, 'base_name': Event_Cfg_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_cfg_id_random', 'event_id_random', 'event_cfg_id_random', 'event_id_random',
'status', 'notes', 'updated_on' 'status', 'notes', 'updated_on'
], ],

View File

@@ -20,6 +20,7 @@ events_presentation_obj_li = {
'base_name': Event_Abstract_In, 'base_name': Event_Abstract_In,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_abstract_id_random', 'event_id_random', 'event_person_id_random', 'event_abstract_id_random', 'event_id_random', 'event_person_id_random',
'code', 'external_id', 'name', 'description', 'abstract', 'enable', 'code', 'external_id', 'name', 'description', 'abstract', 'enable',
'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on' 'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
@@ -41,6 +42,7 @@ events_presentation_obj_li = {
'base_name': Event_Location_Base, 'base_name': Event_Location_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_location_id_random', 'event_id_random', 'code', 'name', 'event_location_id_random', 'event_id_random', 'code', 'name',
'description', 'location_type', 'internal_use', 'enable', 'hide', 'description', 'location_type', 'internal_use', 'enable', 'hide',
'public', 'public_hide', 'hide_event_launcher', 'priority', 'sort', 'public', 'public_hide', 'hide_event_launcher', 'priority', 'sort',
@@ -63,6 +65,7 @@ events_presentation_obj_li = {
'base_name': Event_Presentation_Base, 'base_name': Event_Presentation_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_presentation_id_random', 'event_id_random', 'event_presentation_id_random', 'event_id_random',
'event_abstract_id_random', 'event_location_id_random', 'event_abstract_id_random', 'event_location_id_random',
'event_session_id_random', 'event_track_id_random', 'code', 'name', 'event_session_id_random', 'event_track_id_random', 'code', 'name',
@@ -97,6 +100,7 @@ events_presentation_obj_li = {
], ],
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_presenter_id_random', 'event_id_random', 'event_presenter_id_random', 'event_id_random',
'event_person_id_random', 'event_presentation_id_random', 'event_person_id_random', 'event_presentation_id_random',
'event_session_id_random', 'person_id_random', 'code', 'informal_name', 'event_session_id_random', 'person_id_random', 'code', 'informal_name',
@@ -123,6 +127,7 @@ events_presentation_obj_li = {
'base_name': Event_Session_Base, 'base_name': Event_Session_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_session_id_random', 'event_id_random', 'event_session_id_random', 'event_id_random',
'event_location_id_random', 'event_track_id_random', 'code', 'name', 'event_location_id_random', 'event_track_id_random', 'code', 'name',
'description', 'type_code', 'start_datetime', 'end_datetime', 'description', 'type_code', 'start_datetime', 'end_datetime',
@@ -145,6 +150,7 @@ events_presentation_obj_li = {
'base_name': Event_Track_Base, 'base_name': Event_Track_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'account_id', 'account_id_random',
'event_track_id_random', 'event_id_random', 'event_track_id_random', 'event_id_random',
'event_location_id_random', 'name', 'description', 'track_type', 'event_location_id_random', 'name', 'description', 'track_type',
'enable', 'hide', 'poc_agree', 'file_count', 'file_count_all', 'public', 'public_hide', 'hide_event_launcher', 'enable', 'hide', 'poc_agree', 'file_count', 'file_count_all', 'public', 'public_hide', 'hide_event_launcher',

View File

@@ -48,9 +48,9 @@ events_registration_obj_li = {
'base_name': Event_Badge_Template_Base, 'base_name': Event_Badge_Template_Base,
# V3 Search Security: # V3 Search Security:
'searchable_fields': [ 'searchable_fields': [
'id', 'event_badge_template_id', 'event_id', 'id', 'event_badge_template_id', 'event_id', 'account_id',
'id_random', 'event_badge_template_id_random', 'event_id_random', 'name', 'id_random', 'event_badge_template_id_random', 'event_id_random', 'account_id_random',
'description', 'layout', 'notes', 'enable', 'name', 'description', 'layout', 'notes', 'enable',
'created_on', 'updated_on' 'created_on', 'updated_on'
], ],
}, },

View File

@@ -110,6 +110,7 @@ async def get_obj(
obj_type_l1: str = Path(min_length=2, max_length=50), obj_type_l1: str = Path(min_length=2, max_length=50),
obj_id: str = Path(min_length=11, max_length=22), obj_id: str = Path(min_length=11, max_length=22),
view: str = Query('default'), view: str = Query('default'),
inc_hosted_file: Optional[bool] = Query(False), # Added inc_hosted_file parameter
account: AccountContext = Depends(get_account_context_optional), account: AccountContext = Depends(get_account_context_optional),
serialization: SerializationParams = Depends(), serialization: SerializationParams = Depends(),
delay: DelayParams = Depends(), delay: DelayParams = Depends(),
@@ -150,6 +151,11 @@ async def get_obj(
if not check_account_access(sql_result, account, obj_name): 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.") return mk_resp(data=False, status_code=403, response=response, status_message="Access denied. Record belongs to another account.")
# Pass inc_hosted_file to the Pydantic model if applicable
if obj_name == 'event_file' and inc_hosted_file:
sql_result['inc_hosted_file'] = True
resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none) resp_data = base_name(**sql_result).dict(by_alias=serialization.by_alias, exclude_unset=serialization.exclude_unset, exclude_defaults=serialization.exclude_defaults, exclude_none=serialization.exclude_none)
return mk_resp(data=resp_data, response=response) return mk_resp(data=resp_data, response=response)
else: else:

View File

@@ -7,9 +7,16 @@
- [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects. - [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects.
- [x] **Detailed Feedback:** Implement descriptive 403 Forbidden reasons. - [x] **Detailed Feedback:** Implement descriptive 403 Forbidden reasons.
- [x] **Audit Suite:** Establish `test_e2e_v3_security_audit.py` as a permanent safeguard. - [x] **Audit Suite:** Establish `test_e2e_v3_security_audit.py` as a permanent safeguard.
- [ ] **Step 1:** Audit low-priority MariaDB models for ID Vision parity. - [x] **Polymorphic For_ID Patterns:** Add ID Vision to Address, Contact, and DataStore objects.
- [ ] **Step 2:** Refactor `api_crud_v2.py` (Reduce file size < 800 lines). - [x] **Event File Hash_SHA256 Fix:** Populate hosted_file_hash_sha256 correctly.
- [ ] **Step 3:** Coordination (Verify Frontend uses `x-account-id` instead of token). - [ ] **Step 1: ID Vision Parity Audit**
- [x] Audit Core Event Models (Badge, Session, Presentation).
- [x] Audit File/Exhibit Models (File, Template, Tracking).
- [x] Whitelist `account_id` in all Event search definitions.
- [x] Audit Relational "Low-Priority" Models (Address, Contact, DataStore).
- [ ] Audit Lookup Fields (Exclude all `lu_*_id` integers from public output).
- [ ] Verify SQL Views join in all required `_random` IDs for performance.
- [ ] **Step 2:** Coordination (Verify Frontend uses `x-account-id` instead of token).
## 🛡️ Security & Privacy Baseline (IDAA) ## 🛡️ Security & Privacy Baseline (IDAA)
- **Status:** **ENFORCED**. - **Status:** **ENFORCED**.
@@ -20,8 +27,10 @@
- **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval. - **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval.
- **Aether V4 Architecture:** Migration to V4 core standards (Lifecycle fields). - **Aether V4 Architecture:** Migration to V4 core standards (Lifecycle fields).
## 📝 Session Notes (Feb 13, 2026) ## 📝 Session Notes (Feb 19, 2026)
- **Resolved:** Critical "Fail Open" search leak where missing context returned all records. - **Resolved:** Fixed integer ID leakage in `Event_Badge_Template_Base` and `Event_File_Base`.
- **Hardened:** Removed `public_read` from Events, Presentations, Posts, and Files. - **Hardened:** Whitelisted `account_id` searching for all Event Objects (Presentation, General, Registration).
- **Standardized:** Updated 10+ core models with Vision Transformer pattern. - **Verified:** SQL Views `v_event_session` and `v_event_session_w_file_count` confirmed to have `account_id_random`.
- **Verification:** Security Audit Suite verified at 100% pass rate. - **Resolved:** Implemented polymorphic `for_id` resolution for DataStore, Address, and Contact models.
- **Resolved:** Fixed `hash_sha256` for Event Files being null on the frontend.
- **Status:** Core and Demo Vision parity suites verified at 100% pass rate.

View File

@@ -33,3 +33,4 @@ Before starting work:
1. Read `~/agents_sync/README.md` to understand the fleet status and cross-agent tasks. 1. Read `~/agents_sync/README.md` to understand the fleet status and cross-agent tasks.
2. Check `README.md` in the project root for technical specs. 2. Check `README.md` in the project root for technical specs.
3. Review your local `documentation/AGENT_TODO.md` for active tasks. 3. Review your local `documentation/AGENT_TODO.md` for active tasks.
4. You must be able to explain what needs to be done and why before you start coding. This is important, as it demonstrates understanding and ensures alignment with project goals.

View File

@@ -60,7 +60,26 @@ The primary way to retrieve data.
--- ---
## 4. Troubleshooting 403 Forbidden ## 4. Event File Data Retrieval (Hosted Files)
Event Files (`event_file`) often have associated Hosted Files (`hosted_file`) which contain binary data and metadata like SHA256 hashes, content types, and sizes. To retrieve this additional data:
* **Endpoint:** `GET /v3/crud/event_file/{event_file_id_random}`
* **Query Parameter:** Add `inc_hosted_file=true`
* Example: `/v3/crud/event_file/<event_file_id_random>?inc_hosted_file=true`
**Response Impact:**
1. **Top-Level Convenience Fields:** The response will include top-level fields for commonly needed hosted file data. These are populated directly from the SQL view via JOINs.
* `hosted_file_hash_sha256` (string)
* `hosted_file_subdirectory_path` (string)
* `hosted_file_content_type` (string)
* `hosted_file_size` (string - in bytes)
2. **Nested Hosted File Object:** A full `hosted_file` object will be nested under the `hosted_file` key. This object (`Hosted_File_Base` model) will contain all its standard fields, including `id` (random string ID), `hash_sha256`, `content_type`, `size`, etc.
---
## 5. Troubleshooting 403 Forbidden
If you receive a 403 on a valid ID: If you receive a 403 on a valid ID:
1. Verify `x-aether-api-key` is correct. 1. Verify `x-aether-api-key` is correct.

View File

@@ -24,6 +24,7 @@ These consolidated scripts are the primary verification tool for the V3 API.
| `test_e2e_redis_extensive.py` | **Redis Stress**: Benchmarks bidirectional ID caching across thousands of records. | | `test_e2e_redis_extensive.py` | **Redis Stress**: Benchmarks bidirectional ID caching across thousands of records. |
| `test_e2e_v3_event_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across event models. | | `test_e2e_v3_event_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across event models. |
| `test_e2e_v3_cms_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across CMS (post/comment) models. | | `test_e2e_v3_cms_vision_parity.py`| **Vision ID**: Verifies string-ID enforcement across CMS (post/comment) models. |
| `test_e2e_v3_core_vision_parity.py`| **Vision ID**: Verifies string-ID and polymorphic resolution across core models (Account, Person, Address, Contact, DataStore). |
| `test_e2e_v3_demo_parity.py` | **Demo Parity**: Comprehensive check for Badge, Exhibit, Tracking, and nested Journal Entries. | | `test_e2e_v3_demo_parity.py` | **Demo Parity**: Comprehensive check for Badge, Exhibit, Tracking, and nested Journal Entries. |
| `test_e2e_v3_action_event_file.py` | **Event Actions**: Specialized atomic upload and linking for event files. | | `test_e2e_v3_action_event_file.py` | **Event Actions**: Specialized atomic upload and linking for event files. |
| `test_e2e_v3_action_zoom.py` | **Zoom Integration**: Verifies OAuth and ticket sync logic for Zoom Events. | | `test_e2e_v3_action_zoom.py` | **Zoom Integration**: Verifies OAuth and ticket sync logic for Zoom Events. |

View File

@@ -0,0 +1,94 @@
import requests
import json
import sys
import os
# --- Configuration ---
BASE_URL = "https://dev-api.oneskyit.com/v3/crud"
API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
# Test Targets: (Object Type, Valid ID Random)
TARGETS = [
("account", "nqOzejLCDXM"),
("person", "STI-PyWO6ODzLV8"),
("contact", "dzGCDpaoYJA"),
("address", "gUpFV3CX5UI"),
("data_store", "tQy5v_3BIBI")
]
def get_headers():
return {
"Content-Type": "application/json",
"X-Aether-API-Key": API_KEY,
"x-no-account-id": "bypass"
}
def print_result(label, success, message=""):
status = "✅ PASS" if success else "❌ FAIL"
print(f"{status} | {label} {': ' + message if message else ''}")
def verify_core_parity(obj_type, record_id):
"""
Verifies that core objects comply with ID Vision standards.
- All *_id fields must be strings.
- Polymorphic for_id must resolve to parent random ID string.
"""
print(f"--- Testing {obj_type}: {record_id} ---")
url = f"{BASE_URL}/{obj_type}/{record_id}"
try:
response = requests.get(url, headers=get_headers())
if response.status_code == 200:
data = response.json().get('data', {})
failures = []
# 1. Vision Standard (All *_id fields must be strings)
for key, val in data.items():
# Check fields that should be strings
if key == "id" or (key.endswith("_id") and not key.endswith("external_id")):
if val is not None and not isinstance(val, str):
failures.append(f"Field '{key}' is {type(val).__name__} ({val})")
elif isinstance(val, str) and len(val) < 11 and val.isdigit():
failures.append(f"Field '{key}' looks like a stringified integer: '{val}'")
# 2. Polymorphic Check (for_id)
if "for_id" in data:
for_id = data.get("for_id")
if for_id is None:
# Some objects might have null for_id, but usually for these targets it should be set
if obj_type in ["address", "contact", "data_store"]:
failures.append("for_id is unexpectedly null")
elif not isinstance(for_id, str) or len(for_id) < 11:
failures.append(f"for_id failed resolution: {type(for_id).__name__} ({for_id})")
if not failures:
print(f" ✅ [PASS] Vision integrity verified.")
return True
else:
print(f" ❌ [FAIL] Vision integrity error:")
for f in failures:
print(f" - {f}")
return False
else:
print(f" ❌ [ERROR] Status {response.status_code}: {response.text[:200]}")
return False
except Exception as e:
print(f" 💥 [EXCEPTION] {e}")
return False
if __name__ == "__main__":
print("🚀 Starting Aether V3 Core Vision Parity Suite\n")
results = []
for obj_type, record_id in TARGETS:
results.append(verify_core_parity(obj_type, record_id))
print("-" * 40)
if all(results):
print("\n🏆 CORE SUITE SUCCESS: All core objects verified stable.")
sys.exit(0)
else:
print("\n🚨 CORE SUITE FAILURE: Some critical checks failed.")
sys.exit(1)

View File

@@ -10,6 +10,7 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
# Note: These IDs are extracted from real active records. # Note: These IDs are extracted from real active records.
TARGETS = [ TARGETS = [
("event_badge", "JPUG-87-80-88"), ("event_badge", "JPUG-87-80-88"),
("event_badge_template", "gDcA4kVb5B0"),
("event_exhibit", "xK_9yEj1bQY"), ("event_exhibit", "xK_9yEj1bQY"),
("event_exhibit_tracking", "KVypw_xntSY"), ("event_exhibit_tracking", "KVypw_xntSY"),
("event_file", "a2pPIT_W28o") # Regression Target for Relational ID bug ("event_file", "a2pPIT_W28o") # Regression Target for Relational ID bug