fix(v3-actions): implement from_hosted_file and harden vision IDs

1. Implemented specialized 'from_hosted_file' action for Event Files.\n2. Fixed ValueError in Pydantic models by removing default/default_factory conflict.\n3. Hardened integer stripping to strictly enforce Vision Standards.\n4. Updated documentation for the new action route.
This commit is contained in:
Scott Idem
2026-02-06 16:23:18 -05:00
parent 64d73c4d5c
commit 8270f7ff7a
6 changed files with 180 additions and 142 deletions

View File

@@ -17,16 +17,16 @@ class Event_Badge_Base(BaseModel):
log.debug(locals()) log.debug(locals())
# --- Standardized Vision IDs (Strings for API, Integers for DB) --- # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random']) id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random']) event_badge_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
# NOTE: This should only be used when the event_person record can not be created. And records before 2022. # NOTE: This should only be used when the event_person record can not be created. And records before 2022.
event_id_only: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) event_id_only: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
event_badge_template_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_template_id_random']) event_badge_template_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_template_id_random'])
event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random']) event_person_id: Optional[Union[int, str]] = Field(**base_fields['event_person_id_random'])
person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random']) person_id: Optional[Union[int, str]] = Field(**base_fields['person_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) --- # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True) id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True)

View File

@@ -16,14 +16,14 @@ class Event_Exhibit_Base(BaseModel):
log.debug(locals()) log.debug(locals())
# --- Standardized Vision IDs (Strings for API, Integers for DB) --- # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random']) id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
event_exhibit_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random']) event_exhibit_id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random']) account_id: Optional[Union[int, str]] = Field(**base_fields['account_id_random'])
event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
organization_id: Optional[Union[int, str]] = Field(None, **base_fields['organization_id_random']) organization_id: Optional[Union[int, str]] = Field(**base_fields['organization_id_random'])
contact_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random']) contact_id: Optional[Union[int, str]] = Field(**base_fields['contact_id_random'])
person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random']) person_id: Optional[Union[int, str]] = Field(**base_fields['person_id_random'])
status_id: Optional[Union[int, str]] = Field(None, **base_fields['status_id_random']) status_id: Optional[Union[int, str]] = Field(**base_fields['status_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) --- # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='event_exhibit_id_random', exclude=True) id_random: Optional[str] = Field(None, alias='event_exhibit_id_random', exclude=True)

View File

@@ -18,13 +18,13 @@ class Event_Exhibit_Tracking_Base(BaseModel):
log.debug(locals()) log.debug(locals())
# --- Standardized Vision IDs (Strings for API, Integers for DB) --- # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_tracking_id_random']) id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_tracking_id_random'])
event_exhibit_tracking_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_tracking_id_random']) event_exhibit_tracking_id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_tracking_id_random'])
account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random']) account_id: Optional[Union[int, str]] = Field(**base_fields['account_id_random'])
event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
event_exhibit_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random']) event_exhibit_id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random']) event_person_id: Optional[Union[int, str]] = Field(**base_fields['event_person_id_random'])
event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random']) event_badge_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random'])
# --- Standardized Legacy / Internal IDs (Excluded) --- # --- Standardized Legacy / Internal IDs (Excluded) ---
id_random: Optional[str] = Field(None, alias='event_exhibit_tracking_id_random', exclude=True) id_random: Optional[str] = Field(None, alias='event_exhibit_tracking_id_random', exclude=True)

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
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 get_id_random, 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
@@ -16,38 +16,71 @@ class Event_File_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())
id_random: Optional[str] = Field( # --- Standardized Vision IDs (Strings for API, Integers for DB) ---
# **base_fields['event_file_id_random'], id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
alias = 'event_file_id_random', event_file_id: Optional[Union[int, str]] = Field(**base_fields['event_file_id_random'])
) hosted_file_id: Optional[Union[int, str]] = Field(**base_fields['hosted_file_id_random'])
id: Optional[int] = Field(
alias = 'event_file_id' # Generic Relational target
) for_id: Optional[Union[int, str]] = Field(**base_fields['obj_id_random'])
event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random'])
event_exhibit_id: Optional[Union[int, str]] = Field(**base_fields['event_exhibit_id_random'])
event_location_id: Optional[Union[int, str]] = Field(**base_fields['event_location_id_random'])
event_presentation_id: Optional[Union[int, str]] = Field(**base_fields['event_presentation_id_random'])
event_presenter_id: Optional[Union[int, str]] = Field(**base_fields['event_presenter_id_random'])
event_session_id: Optional[Union[int, str]] = Field(**base_fields['event_session_id_random'])
event_track_id: Optional[Union[int, str]] = Field(**base_fields['event_track_id_random'])
hosted_file_id_random: Optional[str] # --- Standardized Legacy / Internal IDs (Excluded) ---
hosted_file_id: Optional[int] id_random: Optional[str] = Field(None, alias='event_file_id_random', exclude=True)
hosted_file_id_random: Optional[str] = Field(None, exclude=True)
for_id_random: Optional[str] = Field(None, exclude=True)
event_id_random: Optional[str] = Field(None, exclude=True)
event_exhibit_id_random: Optional[str] = Field(None, exclude=True)
event_location_id_random: Optional[str] = Field(None, exclude=True)
event_presentation_id_random: Optional[str] = Field(None, exclude=True)
event_presenter_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)
@root_validator(pre=True)
def map_v3_ids(cls, values):
"""
Vision Transformer:
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('event_file_id_random')
if rid and isinstance(rid, str):
values['id'] = rid
values['event_file_id'] = rid
if h_rid := values.get('hosted_file_id_random'): values['hosted_file_id'] = h_rid
if f_rid := values.get('for_id_random'): values['for_id'] = f_rid
if e_rid := values.get('event_id_random'): values['event_id'] = e_rid
if ex_rid := values.get('event_exhibit_id_random'): values['event_exhibit_id'] = ex_rid
if loc_rid := values.get('event_location_id_random'): values['event_location_id'] = loc_rid
if pres_rid := values.get('event_presentation_id_random'): values['event_presentation_id'] = pres_rid
if pr_rid := values.get('event_presenter_id_random'): values['event_presenter_id'] = pr_rid
if s_rid := values.get('event_session_id_random'): values['event_session_id'] = s_rid
if t_rid := values.get('event_track_id_random'): values['event_track_id'] = t_rid
# 2. Prevent leakage of integers during API responses (Vision Standard)
id_fields = [
'id', 'event_file_id', 'hosted_file_id', 'for_id', 'event_id',
'event_exhibit_id', 'event_location_id', 'event_presentation_id',
'event_presenter_id', 'event_session_id', 'event_track_id'
]
for k in id_fields:
val = values.get(k)
if val is not None and not isinstance(val, str):
values[k] = None
return values
# NOTE: Handling this outside of the Pydantic model and model validation. See below as well. -STI 2021-09-10
for_type: Optional[str] for_type: Optional[str]
for_id: Optional[int] # NOTE: This is reversed with for_id_random
for_id_random: Optional[str] # NOTE: This is reversed with for_id
# for_id_random: Optional[str] = None # Need to override value from common_field_schema.py
# for_id: Optional[int]
event_id_random: Optional[str]
event_id: Optional[int]
event_exhibit_id_random: Optional[str]
event_exhibit_id: Optional[int]
event_location_id_random: Optional[str]
event_location_id: Optional[int]
event_presentation_id_random: Optional[str]
event_presentation_id: Optional[int]
event_presenter_id_random: Optional[str]
event_presenter_id: Optional[int]
event_session_id_random: Optional[str]
event_session_id: Optional[int]
event_track_id_random: Optional[str]
event_track_id: Optional[int]
filename: Optional[str] filename: Optional[str]
filename_no_ext: Optional[str] # Currently created with a view filename_no_ext: Optional[str] # Currently created with a view
@@ -134,96 +167,6 @@ class Event_File_Base(BaseModel):
_processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now) _processed_at: datetime.datetime = PrivateAttr(default_factory=datetime.datetime.now)
#@validator('event_file_id_random', always=True)
def event_file_id_random_copy(cls, v, values, **kwargs):
if values['id_random']:
return values['id_random']
return None
@validator('id', always=True)
def event_file_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_file')
return None
@validator('hosted_file_id', always=True)
def hosted_file_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('hosted_file_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='hosted_file')
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
@validator('event_exhibit_id', always=True)
def event_exhibit_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('event_exhibit_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event_exhibit')
return None
@validator('event_location_id', always=True)
def event_location_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('event_location_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event_location')
return None
@validator('event_presentation_id', always=True)
def event_presentation_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('event_presentation_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presentation')
return None
@validator('event_presenter_id', always=True)
def event_presenter_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('event_presenter_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event_presenter')
return None
@validator('event_session_id', always=True)
def event_session_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('event_session_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event_session')
return None
@validator('event_track_id', always=True)
def event_track_id_lookup(cls, v, values, **kwargs):
if isinstance(v, int) and v > 0: return v
elif id_random := values.get('event_track_id_random'):
return redis_lookup_id_random(record_id_random=id_random, table_name='event_track')
return None
# NOTE: I kind of give up on this. Handling this outside of Pydantic and before the data is even attempted to be loaded into the Event_File_Base model. -STI 2021-09-10
# NOTE: This validator will try to find and "set" the for_id_random value. However, The value is not really "set" in Pydantic. To get this value, exclude_unset=True when returning a dict from the model.
@validator('for_id_random', always=True)
def for_id_random_lookup(cls, v, values, **kwargs):
log.setLevel(logging.WARNING)
log.debug(locals())
if isinstance(v, str): return v
elif values.get('for_id') and values['for_type']:
return get_id_random(record_id=values['for_id'], table_name=values['for_type'])
return None
# @validator('for_id', always=True)
# def for_id_lookup(cls, v, values, **kwargs):
# log.setLevel(logging.DEBUG)
# log.debug(locals())
# if values.get('for_id_random', None) and values['for_type']:
# return redis_lookup_id_random(record_id_random=values['for_id_random'], table_name=values['for_type'])
# # return None
# else: return v
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

View File

@@ -198,6 +198,84 @@ async def upload_event_file_action(
return mk_resp(data=event_file_results, status_message=f"Successfully processed {len(event_file_results)} event files.") return mk_resp(data=event_file_results, status_message=f"Successfully processed {len(event_file_results)} event files.")
@router.post('/from_hosted_file/{hosted_file_id}', response_model=Resp_Body_Base)
async def create_event_file_from_hosted_file_action(
event_file_obj: Event_File_Base,
hosted_file_id: str = Path(..., min_length=11, max_length=22),
inc_hosted_file: bool = Query(False),
return_obj: bool = Query(True),
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(),
):
"""
Specialized Action: Create Event File from Existing Hosted File.
This endpoint allows the frontend to associate an ALREADY UPLOADED hosted_file
with an event-specific context (e.g., event_session, exhibit).
Matches V3 Vision ID Standard:
- Accepts string ID in path.
- Resolves relational IDs in body via Event_File_Base root_validator.
"""
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
# 1. Verify physical file exists
hf_rec = load_hosted_file_obj(hosted_file_id=hosted_file_id)
if not hf_rec:
raise HTTPException(status_code=404, detail=f"Hosted file '{hosted_file_id}' not found.")
# 2. Prepare event_file data
# The Event_File_Base model now standardizes all IDs to Union[int, str]
# and its root_validator handles the string->int resolution during instantiation.
ef_data = event_file_obj.dict(exclude_unset=True)
ef_data['hosted_file_id'] = hosted_file_id # Inject the path ID
# Re-instantiate to trigger hardened Vision resolution
validated_ef = Event_File_Base(**ef_data)
# 3. Standard Generic Linking (Ensures hosted_file_link exists)
# We need the integers for the method call
hf_id_int = redis_lookup_id_random(record_id_random=hosted_file_id, table_name='hosted_file')
link_to_id_int = redis_lookup_id_random(record_id_random=validated_ef.for_id, table_name=validated_ef.for_type)
create_hosted_file_link(
account_id = account.account_id,
hosted_file_id = hf_id_int,
link_to_type = validated_ef.for_type,
link_to_id = link_to_id_int
)
# 4. Create Event File record
res_ef_id = create_event_file_obj(event_file_obj_new=validated_ef)
if res_ef_id is True:
# Update instead of insert - find the ID
lookup_res = sql_select(
table_name='event_file',
data={
'hosted_file_id': hf_id_int,
'for_type': validated_ef.for_type,
'for_id': link_to_id_int
}
)
if lookup_res: res_ef_id = lookup_res.get('id')
if not isinstance(res_ef_id, int):
raise HTTPException(status_code=400, detail="Failed to create event_file record.")
# 5. Return result
if return_obj:
enriched_ef = load_event_file_obj(event_file_id=res_ef_id, inc_hosted_file=inc_hosted_file, model_as_dict=True)
# Vision Transformer: Ensure clean ID for frontend
if enriched_ef and not isinstance(enriched_ef.get('id'), str):
rid = get_id_random(res_ef_id, table_name='event_file')
enriched_ef['id'] = rid
enriched_ef['event_file_id'] = rid
return mk_resp(data=enriched_ef)
return mk_resp(data={"event_file_id": get_id_random(res_ef_id, 'event_file')})
@router.get('/{event_file_id}/download') @router.get('/{event_file_id}/download')
async def download_event_file_action( async def download_event_file_action(
response: Response, response: Response,

View File

@@ -151,6 +151,23 @@ While `hosted_file` handles generic storage, `event_file` actions are context-aw
**Path**: `GET /v3/action/event_file/{id}/download` **Path**: `GET /v3/action/event_file/{id}/download`
*Semantic alias for the universal hosted_file downloader.* *Semantic alias for the universal hosted_file downloader.*
### C. Link Existing Hosted File
**Path**: `POST /v3/action/event_file/from_hosted_file/{hosted_file_id}`
Use this when a file is already in standard storage (e.g., from a previous session or general archive) and you want to link it to an Event object without re-uploading.
| Parameter | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `hosted_file_id` | Path | Yes | String ID of the physical file. |
| `for_type` | Body | Yes | Target object type (e.g., `event_session`). |
| `for_id` | Body | Yes | String ID of the target object. |
| `event_id` | Body | No | Optional event context. |
**Features:**
- **Vision IDs:** Accepts string IDs in both the path and JSON body.
- **Relational Integrity:** Automatically creates both `hosted_file_link` and `event_file` records.
- **Enriched Return:** Returns the full `event_file` object.
--- ---
## 6. Hosted File Management (Legacy) ## 6. Hosted File Management (Legacy)