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
# ### BEGIN ### API Event Models ### Event_Base() ###
class Event_Base(BaseModel):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# --- Standardized Vision IDs (Strings) ---
id: Optional[str] = Field(None, **base_fields['event_id_random'])
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
# We use Union[int, str] to allow both public string IDs and resolved DB integers to pass validation.
id: Optional[Union[int, str]] = Field(None, **base_fields['event_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_person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
address_location_id: Optional[str] = Field(None, **base_fields['address_id_random'])
poc_event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
poc_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'])
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_2_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
contact_3_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[Union[int, 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) ---
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):
"""
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
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 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']:
if k in values and not isinstance(values[k], str):
del values[k]
val = values.get(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
@@ -235,19 +241,19 @@ class Event_Meeting_Flat_Base(BaseModel):
log.setLevel(logging.WARNING) # DEBUG, INFO, WARNING, ERROR, EXCEPTION, CRITICAL
log.debug(locals())
# --- Standardized Vision IDs (Strings) ---
id: Optional[str] = Field(None, **base_fields['event_id_random'])
event_id: Optional[str] = Field(None, **base_fields['event_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[Union[int, str]] = Field(None, **base_fields['event_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_person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
address_location_id: Optional[str] = Field(None, **base_fields['address_id_random'])
poc_event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random'])
poc_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'])
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_2_id: Optional[str] = Field(None, **base_fields['contact_id_random'])
contact_3_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[Union[int, 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) ---
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):
"""
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
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 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']:
if k in values and not isinstance(values[k], str):
del values[k]
val = values.get(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
@@ -443,8 +451,22 @@ class Event_Meeting_Flat_Base(BaseModel):
return v.astimezone(pytz.UTC).isoformat()
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:
underscore_attrs_are_private = True
allow_population_by_field_name = True
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.debug(locals())
# --- Standardized Vision IDs (Strings) ---
id: Optional[str] = Field(None, **base_fields['post_comment_id_random'])
post_comment_id: Optional[str] = Field(None, **base_fields['post_comment_id_random'])
post_id: Optional[str] = Field(None, **base_fields['post_id_random'])
account_id: Optional[str] = Field(None, **base_fields['account_id_random'])
person_id: Optional[str] = Field(None, **base_fields['person_id_random'])
user_id: Optional[str] = Field(None, **base_fields['user_id_random'])
# --- Standardized Vision IDs (Strings for API, Integers for DB) ---
# We use Union[int, str] to allow both public string IDs and resolved DB integers to pass validation.
id: Optional[Union[int, str]] = Field(None, **base_fields['post_comment_id_random'])
post_comment_id: Optional[Union[int, str]] = Field(None, **base_fields['post_comment_id_random'])
post_id: Optional[Union[int, str]] = Field(None, **base_fields['post_id_random'])
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_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) ---
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):
"""
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
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 u_rid := values.get('user_id_random'): values['user_id'] = u_rid
# 2. Prevent "Collision Population" or leakage of integers in string fields
# Note: During a POST (create), IDs are resolved to integers.
# We only delete them if a string version was successfully mapped above.
# 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.
# 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']:
if k in values and not isinstance(values[k], str):
# Only delete if we have a random ID counterpart indicating we are in "Read" mode
# or if the integer is not strictly required for the current operation.
val = values.get(k)
if val is not None and not isinstance(val, str):
# 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')):
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