fix(v3-vision): allow resolved integers to pass model validation during creation

Hardened root_validators in Event and Post Comment models to use Union[int, str] for Vision ID fields. This ensures that integer IDs resolved by sanitize_payload reach the database during POST/PATCH operations while maintaining clean string outputs for clients.
This commit is contained in:
Scott Idem
2026-02-05 20:30:44 -05:00
parent 03a1569eba
commit 78f04bca50
2 changed files with 73 additions and 49 deletions

View File

@@ -17,23 +17,25 @@ from app.models.person_models import Person_Base
from app.models.user_models import User_Base from app.models.user_models import User_Base
# ### BEGIN ### API Event Models ### Event_Base() ###
class Event_Base(BaseModel): class Event_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())
# --- Standardized Vision IDs (Strings) --- # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[str] = Field(None, **base_fields['event_id_random']) # We use Union[int, str] to allow both public string IDs and resolved DB integers to pass validation.
event_id: Optional[str] = Field(None, **base_fields['event_id_random']) id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random']) event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
poc_event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random']) poc_event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
poc_person_id: Optional[str] = Field(None, **base_fields['person_id_random']) poc_person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random']) user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
address_location_id: Optional[str] = Field(None, **base_fields['address_id_random']) address_location_id: Optional[Union[int, str]] = Field(None, **base_fields['address_id_random'])
contact_1_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_1_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
contact_2_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_2_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
contact_3_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_3_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) --- # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='event_id_random', exclude=True) id_random: Optional[str] = Field(None, alias='event_id_random', exclude=True)
@@ -50,7 +52,7 @@ class Event_Base(BaseModel):
def map_v3_ids(cls, values): def map_v3_ids(cls, values):
""" """
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 during READ operations.
""" """
# 1. Map Random Strings to Clean Names # 1. Map Random Strings to Clean Names
rid = values.get('id_random') or values.get('event_id_random') rid = values.get('id_random') or values.get('event_id_random')
@@ -67,10 +69,14 @@ class Event_Base(BaseModel):
if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid
if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid
# 2. Prevent "Collision Population" or leakage of integers # 2. Prevent "Collision Population" or leakage of integers during API responses
# WE MUST NOT DELETE these if they are already integers during a POST operation
# as they have been resolved by sanitize_payload.
for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']: for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']:
if k in values and not isinstance(values[k], str): val = values.get(k)
del values[k] if val is not None and not isinstance(val, str):
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
del values[k]
return values return values
@@ -235,19 +241,19 @@ class Event_Meeting_Flat_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())
# --- Standardized Vision IDs (Strings) --- # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[str] = Field(None, **base_fields['event_id_random']) id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
event_id: Optional[str] = Field(None, **base_fields['event_id_random']) event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random']) account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
poc_event_person_id: Optional[str] = Field(None, **base_fields['event_person_id_random']) poc_event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
poc_person_id: Optional[str] = Field(None, **base_fields['person_id_random']) poc_person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random']) user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
address_location_id: Optional[str] = Field(None, **base_fields['address_id_random']) address_location_id: Optional[Union[int, str]] = Field(None, **base_fields['address_id_random'])
contact_1_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_1_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
contact_2_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_2_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
contact_3_id: Optional[str] = Field(None, **base_fields['contact_id_random']) contact_3_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) --- # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='event_id_random', exclude=True) id_random: Optional[str] = Field(None, alias='event_id_random', exclude=True)
@@ -264,7 +270,7 @@ class Event_Meeting_Flat_Base(BaseModel):
def map_v3_ids(cls, values): def map_v3_ids(cls, values):
""" """
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 during READ operations.
""" """
# 1. Map Random Strings to Clean Names # 1. Map Random Strings to Clean Names
rid = values.get('id_random') or values.get('event_id_random') rid = values.get('id_random') or values.get('event_id_random')
@@ -281,10 +287,12 @@ class Event_Meeting_Flat_Base(BaseModel):
if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid if c2_rid := values.get('contact_2_id_random'): values['contact_2_id'] = c2_rid
if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid if c3_rid := values.get('contact_3_id_random'): values['contact_3_id'] = c3_rid
# 2. Prevent "Collision Population" or leakage of integers # 2. Prevent "Collision Population" or leakage of integers during API responses
for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']: for k in ['id', 'event_id', 'account_id', 'poc_event_person_id', 'poc_person_id', 'user_id', 'address_location_id', 'contact_1_id', 'contact_2_id', 'contact_3_id']:
if k in values and not isinstance(values[k], str): val = values.get(k)
del values[k] if val is not None and not isinstance(val, str):
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
del values[k]
return values return values
@@ -443,8 +451,22 @@ class Event_Meeting_Flat_Base(BaseModel):
return v.astimezone(pytz.UTC).isoformat() return v.astimezone(pytz.UTC).isoformat()
else: return v else: return v
# Fields that are part of the model (for reading) but should not be saved to the DB table.
# These convenience fields are joined in the view.
fields_to_exclude_from_db: ClassVar[list] = [
'address_name', 'address_line_1', 'address_line_2', 'address_line_3', 'address_city',
'address_country_subdivision_code', 'address_country_subdivision_name', 'address_postal_code',
'address_country_alpha_2_code', 'address_country_name',
'contact_1_name', 'contact_1_full_name', 'contact_1_email', 'contact_1_phone_mobile',
'contact_1_phone_home', 'contact_1_phone_office', 'contact_1_phone_land', 'contact_1_phone_fax',
'contact_1_phone_other', 'contact_1_other_text',
'contact_2_name', 'contact_2_full_name', 'contact_2_email', 'contact_2_phone_mobile',
'contact_2_phone_home', 'contact_2_phone_office', 'contact_2_phone_land', 'contact_2_phone_fax',
'contact_2_phone_other', 'contact_2_other_text'
]
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
# ### END ### API Event Models ### Event_Meeting_Flat_Base() ### # ### END ### API Event Models ### Event_Meeting_Flat_Base() ###

View File

@@ -17,13 +17,14 @@ class Post_Comment_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())
# --- Standardized Vision IDs (Strings) --- # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[str] = Field(None, **base_fields['post_comment_id_random']) # We use Union[int, str] to allow both public string IDs and resolved DB integers to pass validation.
post_comment_id: Optional[str] = Field(None, **base_fields['post_comment_id_random']) id: Optional[Union[int, str]] = Field(None, **base_fields['post_comment_id_random'])
post_id: Optional[str] = Field(None, **base_fields['post_id_random']) post_comment_id: Optional[Union[int, str]] = Field(None, **base_fields['post_comment_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random']) post_id: Optional[Union[int, str]] = Field(None, **base_fields['post_id_random'])
person_id: Optional[str] = Field(None, **base_fields['person_id_random']) account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random']) person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random'])
user_id: Optional[Union[int, str]] = Field(None, **base_fields['user_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) --- # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='post_comment_id_random', exclude=True) id_random: Optional[str] = Field(None, alias='post_comment_id_random', exclude=True)
@@ -63,7 +64,8 @@ class Post_Comment_Base(BaseModel):
def map_v3_ids(cls, values): def map_v3_ids(cls, values):
""" """
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 during READ operations.
During CREATE (POST) operations, we ensure resolved integers are preserved.
""" """
# 1. Map Random Strings to Clean Names # 1. Map Random Strings to Clean Names
rid = values.get('id_random') or values.get('post_comment_id_random') rid = values.get('id_random') or values.get('post_comment_id_random')
@@ -76,19 +78,19 @@ class Post_Comment_Base(BaseModel):
if per_rid := values.get('person_id_random'): values['person_id'] = per_rid if per_rid := values.get('person_id_random'): values['person_id'] = per_rid
if u_rid := values.get('user_id_random'): values['user_id'] = u_rid if u_rid := values.get('user_id_random'): values['user_id'] = u_rid
# 2. Prevent "Collision Population" or leakage of integers in string fields # 2. Prevent "Collision Population" or leakage of integers during API responses
# Note: During a POST (create), IDs are resolved to integers. # WE MUST NOT DELETE these if they are already integers during a POST operation
# We only delete them if a string version was successfully mapped above. # as they have been resolved by sanitize_payload.
# We only delete the integer if a string version was successfully mapped above.
for k in ['id', 'post_comment_id', 'post_id', 'account_id', 'person_id', 'user_id']: for k in ['id', 'post_comment_id', 'post_id', 'account_id', 'person_id', 'user_id']:
if k in values and not isinstance(values[k], str): val = values.get(k)
# Only delete if we have a random ID counterpart indicating we are in "Read" mode if val is not None and not isinstance(val, str):
# or if the integer is not strictly required for the current operation. # If we have a random ID counterpart in the source dict, we are in "Read Mode".
# In Read Mode, we prioritize the string ID for the client.
# In "Create Mode", the random ID field won't be in 'values' after sanitize_payload
# but the integer will be in 'k'.
if values.get(f'{k}_random') or (k=='id' and values.get('id_random')): if values.get(f'{k}_random') or (k=='id' and values.get('id_random')):
del values[k] del values[k]
else:
# It's a raw integer from the DB/view result (e.g. from v_post_comment)
# We MUST delete it from the string field to avoid ValidationError
del values[k]
return values return values