From 8270f7ff7a5e230b6bdbf3a0933f527212142356 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Fri, 6 Feb 2026 16:23:18 -0500 Subject: [PATCH] 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. --- app/models/event_badge_models.py | 14 +- app/models/event_exhibit_models.py | 16 +- app/models/event_exhibit_tracking_models.py | 14 +- app/models/event_file_models.py | 183 +++++++------------- app/routers/api_v3_actions_event_file.py | 78 +++++++++ documentation/GUIDE__V3_FRONTEND_API.md | 17 ++ 6 files changed, 180 insertions(+), 142 deletions(-) diff --git a/app/models/event_badge_models.py b/app/models/event_badge_models.py index 643f30f..e85f9bf 100644 --- a/app/models/event_badge_models.py +++ b/app/models/event_badge_models.py @@ -17,16 +17,16 @@ class Event_Badge_Base(BaseModel): log.debug(locals()) # --- Standardized Vision IDs (Strings for API, Integers for DB) --- - id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random']) - event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random']) - event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) + id: Optional[Union[int, str]] = Field(**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(**base_fields['event_id_random']) # 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_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random']) - person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_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(**base_fields['event_person_id_random']) + person_id: Optional[Union[int, str]] = Field(**base_fields['person_id_random']) # --- Standardized Legacy / Internal IDs (Excluded) --- id_random: Optional[str] = Field(None, alias='event_badge_id_random', exclude=True) diff --git a/app/models/event_exhibit_models.py b/app/models/event_exhibit_models.py index b2db489..3ec743d 100644 --- a/app/models/event_exhibit_models.py +++ b/app/models/event_exhibit_models.py @@ -16,14 +16,14 @@ class Event_Exhibit_Base(BaseModel): log.debug(locals()) # --- Standardized Vision IDs (Strings for API, Integers for DB) --- - id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random']) - event_exhibit_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random']) - account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random']) - event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) - organization_id: Optional[Union[int, str]] = Field(None, **base_fields['organization_id_random']) - contact_id: Optional[Union[int, str]] = Field(None, **base_fields['contact_id_random']) - person_id: Optional[Union[int, str]] = Field(None, **base_fields['person_id_random']) - status_id: Optional[Union[int, str]] = Field(None, **base_fields['status_id_random']) + id: Optional[Union[int, str]] = Field(**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(**base_fields['account_id_random']) + event_id: Optional[Union[int, str]] = Field(**base_fields['event_id_random']) + organization_id: Optional[Union[int, str]] = Field(**base_fields['organization_id_random']) + contact_id: Optional[Union[int, str]] = Field(**base_fields['contact_id_random']) + person_id: Optional[Union[int, str]] = Field(**base_fields['person_id_random']) + status_id: Optional[Union[int, str]] = Field(**base_fields['status_id_random']) # --- Standardized Legacy / Internal IDs (Excluded) --- id_random: Optional[str] = Field(None, alias='event_exhibit_id_random', exclude=True) diff --git a/app/models/event_exhibit_tracking_models.py b/app/models/event_exhibit_tracking_models.py index 3712dda..4376737 100644 --- a/app/models/event_exhibit_tracking_models.py +++ b/app/models/event_exhibit_tracking_models.py @@ -18,13 +18,13 @@ class Event_Exhibit_Tracking_Base(BaseModel): log.debug(locals()) # --- Standardized Vision IDs (Strings for API, Integers for DB) --- - id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_tracking_id_random']) - event_exhibit_tracking_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_tracking_id_random']) - account_id: Optional[Union[int, str]] = Field(None, **base_fields['account_id_random']) - event_id: Optional[Union[int, str]] = Field(None, **base_fields['event_id_random']) - event_exhibit_id: Optional[Union[int, str]] = Field(None, **base_fields['event_exhibit_id_random']) - event_person_id: Optional[Union[int, str]] = Field(None, **base_fields['event_person_id_random']) - event_badge_id: Optional[Union[int, str]] = Field(None, **base_fields['event_badge_id_random']) + id: Optional[Union[int, str]] = Field(**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(**base_fields['account_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_person_id: Optional[Union[int, str]] = Field(**base_fields['event_person_id_random']) + event_badge_id: Optional[Union[int, str]] = Field(**base_fields['event_badge_id_random']) # --- Standardized Legacy / Internal IDs (Excluded) --- id_random: Optional[str] = Field(None, alias='event_exhibit_tracking_id_random', exclude=True) diff --git a/app/models/event_file_models.py b/app/models/event_file_models.py index 1cfd6dc..247045d 100644 --- a/app/models/event_file_models.py +++ b/app/models/event_file_models.py @@ -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 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.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.debug(locals()) - id_random: Optional[str] = Field( - # **base_fields['event_file_id_random'], - alias = 'event_file_id_random', - ) - id: Optional[int] = Field( - alias = 'event_file_id' - ) + # --- Standardized Vision IDs (Strings for API, Integers for DB) --- + id: Optional[Union[int, str]] = Field(**base_fields['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']) + + # 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] - hosted_file_id: Optional[int] + # --- Standardized Legacy / Internal IDs (Excluded) --- + 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_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_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) - #@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: underscore_attrs_are_private = True allow_population_by_field_name = True diff --git a/app/routers/api_v3_actions_event_file.py b/app/routers/api_v3_actions_event_file.py index a47ad74..8accd81 100644 --- a/app/routers/api_v3_actions_event_file.py +++ b/app/routers/api_v3_actions_event_file.py @@ -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.") +@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') async def download_event_file_action( response: Response, diff --git a/documentation/GUIDE__V3_FRONTEND_API.md b/documentation/GUIDE__V3_FRONTEND_API.md index 2c63228..7ce80fe 100644 --- a/documentation/GUIDE__V3_FRONTEND_API.md +++ b/documentation/GUIDE__V3_FRONTEND_API.md @@ -151,6 +151,23 @@ While `hosted_file` handles generic storage, `event_file` actions are context-aw **Path**: `GET /v3/action/event_file/{id}/download` *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)