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:
@@ -83,7 +83,15 @@ def load_event_file_obj(
|
||||
enabled = enabled,
|
||||
):
|
||||
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:
|
||||
event_file_obj.hosted_file = {}
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
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.debug(locals())
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['account_id_random'],
|
||||
alias = 'account_id_random',
|
||||
# default_factory = lambda:secrets.token_urlsafe(default_num_bytes),
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'account_id'
|
||||
)
|
||||
# account_id: Optional[int] = Field(
|
||||
# )
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
|
||||
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='account_id_random', exclude=True)
|
||||
|
||||
code: Optional[str]
|
||||
name: Optional[str]
|
||||
@@ -77,28 +73,29 @@ class Account_Base(BaseModel):
|
||||
|
||||
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
|
||||
|
||||
@validator('id', always=True)
|
||||
def account_id_lookup(cls, v, values, **kwargs):
|
||||
log.setLevel(logging.WARNING)
|
||||
log.debug(locals())
|
||||
|
||||
if values['id_random']:
|
||||
log.debug(values['id_random'])
|
||||
return redis_lookup_id_random(record_id_random=values['id_random'], table_name='account')
|
||||
return None
|
||||
|
||||
# @validator('account_id', always=True)
|
||||
# def account_id_duplicate(cls, v, values, **kwargs):
|
||||
# log.setLevel(logging.DEBUG)
|
||||
# log.debug(locals())
|
||||
|
||||
# if values['id']:
|
||||
# log.debug(values['id'])
|
||||
# return values['id']
|
||||
# return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
if rid := values.get('id_random') or values.get('account_id_random'):
|
||||
values['id'] = rid
|
||||
values['account_id'] = rid
|
||||
|
||||
# 2. Final Vision Enforcement: Strip internal integers from public fields
|
||||
for k in ['id', 'account_id']:
|
||||
val = values.get(k)
|
||||
if val is not None:
|
||||
# If it's not a valid random string ID
|
||||
if not isinstance(val, str) or len(val) < 11:
|
||||
values[k] = None
|
||||
|
||||
return values
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
allow_population_by_field_name = True
|
||||
# ### END ### API Account Models ### Account_Base() ###
|
||||
|
||||
@@ -14,15 +14,21 @@ class Address_Base(BaseModel):
|
||||
log.setLevel(logging.INFO) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
|
||||
log.debug(locals())
|
||||
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
# Standardized Vision IDs (Strings) ---
|
||||
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'])
|
||||
contact_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
|
||||
|
||||
# Standardized Polymorphic Target
|
||||
for_type: Optional[str]
|
||||
for_id_random: Optional[str]
|
||||
for_id: Optional[int] = Field(None, exclude=True)
|
||||
for_id: Optional[str] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
# --- 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()
|
||||
|
||||
@@ -42,7 +48,7 @@ class Address_Base(BaseModel):
|
||||
country_name: Optional[str] # From country lookup table
|
||||
country: Optional[str] # Avoid using
|
||||
|
||||
lu_time_zone_id: Optional[str]
|
||||
lu_time_zone_id: Optional[str] = Field(None, exclude=True)
|
||||
timezone: Optional[str]
|
||||
|
||||
latitude: Optional[str]
|
||||
@@ -70,20 +76,47 @@ class Address_Base(BaseModel):
|
||||
Vision Transformer:
|
||||
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'):
|
||||
values['id'] = rid
|
||||
values['address_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if c_rid := values.get('contact_id_random'):
|
||||
values['contact_id'] = c_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'account_id', 'contact_id']:
|
||||
if k in values and not isinstance(values[k], str):
|
||||
del values[k]
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('contact_id', 'contact'),
|
||||
]
|
||||
|
||||
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)):
|
||||
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
|
||||
|
||||
|
||||
@@ -26,9 +26,16 @@ class Contact_Base(BaseModel):
|
||||
# NOTE: Linked Address ID is actually the old contact.address_id (Legacy?)
|
||||
linked_address_id: Optional[str] = Field(None, **base_fields['address_id_random'])
|
||||
|
||||
# Standardized Polymorphic Target
|
||||
for_type: Optional[str]
|
||||
for_id: Optional[int]
|
||||
for_id_random: Optional[Union[str,None]] = None # lambda:get_id_random(values.get('for_id'), table_name=values.get('for_type')),
|
||||
for_id: Optional[str] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
# --- 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]
|
||||
title: Optional[str]
|
||||
@@ -87,65 +94,51 @@ class Contact_Base(BaseModel):
|
||||
Vision Transformer:
|
||||
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'):
|
||||
values['id'] = rid
|
||||
values['contact_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if ad_rid := values.get('address_id_random'):
|
||||
values['address_id'] = ad_rid
|
||||
if lad_rid := values.get('linked_address_id_random'):
|
||||
values['linked_address_id'] = lad_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
for k in ['id', 'contact_id', 'account_id', 'address_id', 'linked_address_id']:
|
||||
if k in values and not isinstance(values[k], str) and values[k] is not None:
|
||||
del values[k]
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('address_id', 'address'),
|
||||
('linked_address_id', 'address'),
|
||||
]
|
||||
|
||||
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)):
|
||||
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
|
||||
|
||||
@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:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = False
|
||||
|
||||
@@ -3,7 +3,7 @@ import datetime, pytz
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
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.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'])
|
||||
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
|
||||
|
||||
# Internal Integer IDs (Excluded from API)
|
||||
# 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)
|
||||
|
||||
# Standardized Polymorphic Target
|
||||
for_type: Optional[str]
|
||||
for_id: Optional[str] # Random ID string
|
||||
for_id_random: Optional[str] # Svelte often uses this name
|
||||
for_id_int: Optional[Union[int, str]] = Field(None, alias='for_id', exclude=True)
|
||||
for_id: Optional[str] = Field(**base_fields['obj_id_random'])
|
||||
|
||||
# --- 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]
|
||||
name: Optional[str]
|
||||
@@ -82,28 +80,50 @@ class Data_Store_Base(BaseModel):
|
||||
if isinstance(v, str) and v.upper() == 'NULL':
|
||||
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'):
|
||||
values['id'] = rid
|
||||
values['data_store_id'] = rid
|
||||
|
||||
if a_rid := values.get('account_id_random'):
|
||||
values['account_id'] = a_rid
|
||||
if p_rid := values.get('person_id_random'):
|
||||
values['person_id'] = p_rid
|
||||
if u_rid := values.get('user_id_random'):
|
||||
values['user_id'] = u_rid
|
||||
# 2. Map & Resolve Relational IDs
|
||||
id_map = [
|
||||
('account_id', 'account'),
|
||||
('person_id', 'person'),
|
||||
('user_id', 'user'),
|
||||
]
|
||||
|
||||
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'):
|
||||
values['for_id'] = f_rid
|
||||
values['for_id_random'] = f_rid
|
||||
|
||||
# 2. Prevent "Collision Population"
|
||||
# We only want strings in our primary ID fields.
|
||||
# If the key exists and isn't a string, it's a DB integer; remove it
|
||||
# so it doesn't fail length validation on the string fields.
|
||||
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: Strip internal integers from public fields
|
||||
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):
|
||||
del values[k]
|
||||
val = values.get(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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import datetime, pytz
|
||||
|
||||
from typing import Dict, List, Optional, Set, Union
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator
|
||||
from typing import Dict, List, Optional, Set, Union, ClassVar
|
||||
from pydantic import BaseModel, EmailStr, Field, Json, PrivateAttr, ValidationError, validator, root_validator
|
||||
|
||||
from app.db_sql import redis_lookup_id_random
|
||||
from app.lib_general import log, logging
|
||||
@@ -15,19 +15,14 @@ class Event_Badge_Template_Base(BaseModel):
|
||||
|
||||
# log.info('Using base template')
|
||||
|
||||
id_random: Optional[str] = Field(
|
||||
**base_fields['event_badge_template_id_random'],
|
||||
alias = 'event_badge_template_id_random',
|
||||
)
|
||||
id: Optional[int] = Field(
|
||||
alias = 'event_badge_template_id'
|
||||
)
|
||||
# --- Standardized Vision IDs (Strings) ---
|
||||
id: Optional[str] = Field(None, **base_fields['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'])
|
||||
|
||||
# account_id_random: Optional[str]
|
||||
# account_id: Optional[int]
|
||||
|
||||
event_id_random: Optional[str]
|
||||
event_id: Optional[int]
|
||||
# --- Standardized Legacy / Internal IDs (Excluded) ---
|
||||
id_random: Optional[str] = Field(None, alias='event_badge_template_id_random', exclude=True)
|
||||
event_id_random: Optional[str] = Field(None, exclude=True)
|
||||
|
||||
name: 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)
|
||||
|
||||
@validator('id', always=True)
|
||||
def event_badge_template_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event_badge_template')
|
||||
return None
|
||||
|
||||
@validator('event_id', always=True)
|
||||
def event_id_lookup(cls, v, values, **kwargs):
|
||||
if isinstance(v, int) and v > 0: return v
|
||||
elif id_random := values.get('event_id_random'):
|
||||
return redis_lookup_id_random(record_id_random=id_random, table_name='event')
|
||||
return None
|
||||
@root_validator(pre=True)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
Vision Transformer:
|
||||
Map DB keys to clean API keys and strip internal integers.
|
||||
"""
|
||||
# 1. Map Random Strings to Clean Names
|
||||
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
|
||||
|
||||
# 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:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ class Event_File_Base(BaseModel):
|
||||
event_session_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)
|
||||
def map_v3_ids(cls, values):
|
||||
"""
|
||||
@@ -102,9 +105,20 @@ class Event_File_Base(BaseModel):
|
||||
val = values.get(k)
|
||||
if val is not None and not isinstance(val, str):
|
||||
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
|
||||
|
||||
|
||||
for_type: Optional[str]
|
||||
|
||||
filename: Optional[str]
|
||||
@@ -114,7 +128,7 @@ class Event_File_Base(BaseModel):
|
||||
title: 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]
|
||||
|
||||
# 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
|
||||
updated_on: Optional[datetime.datetime] = None
|
||||
|
||||
# Including convenience data
|
||||
# This is only for convenience. Probably going to keep unless it causes a problem.
|
||||
hosted_file_hash_sha256: Optional[str] = Field(
|
||||
alias = 'hash_sha256'
|
||||
)
|
||||
hosted_file_subdirectory_path: Optional[str] = Field( # NOTE: This will frequently only contain numbers, but it still needs to be a string
|
||||
alias = 'subdirectory_path',
|
||||
exclude = True
|
||||
)
|
||||
hosted_file_content_type: Optional[str] = Field(
|
||||
alias = 'content_type'
|
||||
)
|
||||
hosted_file_size: Optional[str] = Field(
|
||||
alias = 'file_size'
|
||||
)
|
||||
# Including convenience data for Hosted Files (top-level properties)
|
||||
# These fields provide direct access to frequently needed properties from the associated
|
||||
# hosted file, effectively flattening some aspects of the nested 'hosted_file' object.
|
||||
#
|
||||
# IMPORTANT: These fields are designed to be populated directly from the SQL View
|
||||
# (e.g., `v_event_file_simple`) via JOINs. They should **NOT** have Pydantic `alias`
|
||||
# definitions here if the view provides them with matching names (e.g., `hosted_file_hash_sha256`).
|
||||
# Pydantic's default mapping will handle them directly from the incoming data dictionary
|
||||
# (the `sql_result` in `api_crud_v3.py`).
|
||||
# The `root_validator` does **NOT** populate these top-level fields; its role is
|
||||
# solely to conditionally load the *nested* `hosted_file` object.
|
||||
hosted_file_hash_sha256: Optional[str]
|
||||
hosted_file_subdirectory_path: Optional[str]
|
||||
hosted_file_content_type: Optional[str]
|
||||
hosted_file_size: Optional[str]
|
||||
|
||||
lu_event_file_purpose_name: Optional[str] = Field(
|
||||
alias = 'file_purpose_name'
|
||||
@@ -194,6 +208,6 @@ class Event_File_Base(BaseModel):
|
||||
|
||||
class Config:
|
||||
underscore_attrs_are_private = True
|
||||
allow_population_by_field_name = True
|
||||
allow_population_by_field_name = False
|
||||
fields = base_fields
|
||||
# ### END ### API Event File Models ### Event_File_Base() ###
|
||||
|
||||
@@ -67,6 +67,7 @@ events_general_obj_li = {
|
||||
'base_name': Event_File_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_id', 'event_file_id', 'hosted_file_id',
|
||||
'event_file_id_random', 'hosted_file_id_random', 'event_id_random',
|
||||
'event_exhibit_id_random', 'event_location_id_random',
|
||||
@@ -114,6 +115,7 @@ events_general_obj_li = {
|
||||
'base_name': Event_Cfg_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_cfg_id_random', 'event_id_random',
|
||||
'status', 'notes', 'updated_on'
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Abstract_In,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_abstract_id_random', 'event_id_random', 'event_person_id_random',
|
||||
'code', 'external_id', 'name', 'description', 'abstract', 'enable',
|
||||
'hide', 'priority', 'sort', 'group', 'notes', 'created_on', 'updated_on'
|
||||
@@ -41,6 +42,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Location_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_location_id_random', 'event_id_random', 'code', 'name',
|
||||
'description', 'location_type', 'internal_use', 'enable', 'hide',
|
||||
'public', 'public_hide', 'hide_event_launcher', 'priority', 'sort',
|
||||
@@ -63,6 +65,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Presentation_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_presentation_id_random', 'event_id_random',
|
||||
'event_abstract_id_random', 'event_location_id_random',
|
||||
'event_session_id_random', 'event_track_id_random', 'code', 'name',
|
||||
@@ -97,6 +100,7 @@ events_presentation_obj_li = {
|
||||
],
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_presenter_id_random', 'event_id_random',
|
||||
'event_person_id_random', 'event_presentation_id_random',
|
||||
'event_session_id_random', 'person_id_random', 'code', 'informal_name',
|
||||
@@ -123,6 +127,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Session_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_session_id_random', 'event_id_random',
|
||||
'event_location_id_random', 'event_track_id_random', 'code', 'name',
|
||||
'description', 'type_code', 'start_datetime', 'end_datetime',
|
||||
@@ -145,6 +150,7 @@ events_presentation_obj_li = {
|
||||
'base_name': Event_Track_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'account_id', 'account_id_random',
|
||||
'event_track_id_random', 'event_id_random',
|
||||
'event_location_id_random', 'name', 'description', 'track_type',
|
||||
'enable', 'hide', 'poc_agree', 'file_count', 'file_count_all', 'public', 'public_hide', 'hide_event_launcher',
|
||||
|
||||
@@ -48,9 +48,9 @@ events_registration_obj_li = {
|
||||
'base_name': Event_Badge_Template_Base,
|
||||
# V3 Search Security:
|
||||
'searchable_fields': [
|
||||
'id', 'event_badge_template_id', 'event_id',
|
||||
'id_random', 'event_badge_template_id_random', 'event_id_random', 'name',
|
||||
'description', 'layout', 'notes', 'enable',
|
||||
'id', 'event_badge_template_id', 'event_id', 'account_id',
|
||||
'id_random', 'event_badge_template_id_random', 'event_id_random', 'account_id_random',
|
||||
'name', 'description', 'layout', 'notes', 'enable',
|
||||
'created_on', 'updated_on'
|
||||
],
|
||||
},
|
||||
|
||||
@@ -110,6 +110,7 @@ async def get_obj(
|
||||
obj_type_l1: str = Path(min_length=2, max_length=50),
|
||||
obj_id: str = Path(min_length=11, max_length=22),
|
||||
view: str = Query('default'),
|
||||
inc_hosted_file: Optional[bool] = Query(False), # Added inc_hosted_file parameter
|
||||
account: AccountContext = Depends(get_account_context_optional),
|
||||
serialization: SerializationParams = Depends(),
|
||||
delay: DelayParams = Depends(),
|
||||
@@ -150,6 +151,11 @@ async def get_obj(
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
else:
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
- [x] **IDAA Baseline:** Remove `public_read` from Event, CMS, and Archive objects.
|
||||
- [x] **Detailed Feedback:** Implement descriptive 403 Forbidden reasons.
|
||||
- [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.
|
||||
- [ ] **Step 2:** Refactor `api_crud_v2.py` (Reduce file size < 800 lines).
|
||||
- [ ] **Step 3:** Coordination (Verify Frontend uses `x-account-id` instead of token).
|
||||
- [x] **Polymorphic For_ID Patterns:** Add ID Vision to Address, Contact, and DataStore objects.
|
||||
- [x] **Event File Hash_SHA256 Fix:** Populate hosted_file_hash_sha256 correctly.
|
||||
- [ ] **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)
|
||||
- **Status:** **ENFORCED**.
|
||||
@@ -20,8 +27,10 @@
|
||||
- **Zoom Events Integration:** Implement cron synchronization for OAuth2 ticket retrieval.
|
||||
- **Aether V4 Architecture:** Migration to V4 core standards (Lifecycle fields).
|
||||
|
||||
## 📝 Session Notes (Feb 13, 2026)
|
||||
- **Resolved:** Critical "Fail Open" search leak where missing context returned all records.
|
||||
- **Hardened:** Removed `public_read` from Events, Presentations, Posts, and Files.
|
||||
- **Standardized:** Updated 10+ core models with Vision Transformer pattern.
|
||||
- **Verification:** Security Audit Suite verified at 100% pass rate.
|
||||
## 📝 Session Notes (Feb 19, 2026)
|
||||
- **Resolved:** Fixed integer ID leakage in `Event_Badge_Template_Base` and `Event_File_Base`.
|
||||
- **Hardened:** Whitelisted `account_id` searching for all Event Objects (Presentation, General, Registration).
|
||||
- **Verified:** SQL Views `v_event_session` and `v_event_session_w_file_count` confirmed to have `account_id_random`.
|
||||
- **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.
|
||||
|
||||
@@ -33,3 +33,4 @@ Before starting work:
|
||||
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.
|
||||
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.
|
||||
@@ -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:
|
||||
1. Verify `x-aether-api-key` is correct.
|
||||
|
||||
@@ -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_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_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_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. |
|
||||
|
||||
94
tests/e2e/test_e2e_v3_core_vision_parity.py
Normal file
94
tests/e2e/test_e2e_v3_core_vision_parity.py
Normal 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)
|
||||
@@ -10,6 +10,7 @@ API_KEY = "PMM4n50teUCaOMMTN8qOJA" # Agent API Key
|
||||
# Note: These IDs are extracted from real active records.
|
||||
TARGETS = [
|
||||
("event_badge", "JPUG-87-80-88"),
|
||||
("event_badge_template", "gDcA4kVb5B0"),
|
||||
("event_exhibit", "xK_9yEj1bQY"),
|
||||
("event_exhibit_tracking", "KVypw_xntSY"),
|
||||
("event_file", "a2pPIT_W28o") # Regression Target for Relational ID bug
|
||||
|
||||
Reference in New Issue
Block a user