From bcd466edc74e0017bbf7b5527d9146dd68702f58 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 3 Feb 2026 12:54:17 -0500 Subject: [PATCH] feat(event-file): implement atomic V3 upload action for event files - Creates api_v3_actions_event_file.py with a specialized /upload endpoint. - Handles physical storage (hosted_file), generic linking, and event association (event_file) in one request. - Implements intelligent ID resolution to prevent duplicate event associations for the same physical file. - Updates documentation in GUIDE__V3_FRONTEND_API.md. --- app/routers/api_v3_actions_event_file.py | 224 +++++++++++++++++++++++ app/routers/registry.py | 3 +- documentation/GUIDE__V3_FRONTEND_API.md | 30 ++- 3 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 app/routers/api_v3_actions_event_file.py diff --git a/app/routers/api_v3_actions_event_file.py b/app/routers/api_v3_actions_event_file.py new file mode 100644 index 0000000..a47ad74 --- /dev/null +++ b/app/routers/api_v3_actions_event_file.py @@ -0,0 +1,224 @@ +from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Path, Query, Response, status, UploadFile +from fastapi.responses import FileResponse, StreamingResponse +import aiofiles +import mimetypes +import os +import pathlib +from typing import Dict, List, Optional, Set, Union +import asyncio +import logging + +log = logging.getLogger(__name__) + +from app.config import settings +from app.db_sql import redis_lookup_id_random, sql_select, sql_update, sql_delete, get_id_random +from app.methods.hosted_file_methods import ( + create_hosted_file_obj, load_hosted_file_obj, save_file, + create_hosted_file_link, delete_hosted_file_link +) +from app.methods.event_file_methods import create_event_file_obj, load_event_file_obj +from app.lib_general_v3 import ( + AccountContext, get_account_context, get_account_context_optional, + SerializationParams, DelayParams +) +from app.models.hosted_file_models import Hosted_File_Base +from app.models.event_file_models import Event_File_Base +from app.models.response_models import Resp_Body_Base, mk_resp + +""" +Aether API V3 - Event File Action Router +------------------------------------------ +Handles high-level atomic operations for the Event module, specifically +marrying physical hosted_files with the relational event_file table. +""" + +router = APIRouter() + +# --- Helpers --- + +def validate_file_extension(filename: str, allowed_extensions: List[str]): + if not allowed_extensions: + return True + ext = filename.rsplit('.', 1)[-1].lower() + if ext not in [e.lower().strip('.') for e in allowed_extensions]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File extension '.{ext}' is not allowed. Allowed: {', '.join(allowed_extensions)}" + ) + return True + +# --- Routes --- + +@router.post('/upload', response_model=Resp_Body_Base) +async def upload_event_file_action( + file_list: List[UploadFile] = File(...), + account_id: str = Form(..., min_length=11, max_length=22), + for_type: str = Form(...), + for_id: str = Form(..., min_length=11, max_length=22), + + # Event Specific Metadata + event_id: Optional[str] = Form(None), + event_session_id: Optional[str] = Form(None), + event_presentation_id: Optional[str] = Form(None), + event_presenter_id: Optional[str] = Form(None), + event_location_id: Optional[str] = Form(None), + event_track_id: Optional[str] = Form(None), + + # Display/Logic Metadata + title: Optional[str] = Form(None), + description: Optional[str] = Form(None), + internal_use: Optional[bool] = Form(False), + open_in_os: Optional[str] = Form(None), + + allowed_extensions: Optional[List[str]] = Query(None), + account: AccountContext = Depends(get_account_context), + delay: DelayParams = Depends(), + ): + """ + High-level Event File Upload. + - Saves physical file (hosted_file). + - Links to generic storage (hosted_file_link). + - Creates context-aware association (event_file). + - Returns full event_file objects. + """ + if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s) + + # 1. Resolve Core Parent IDs + account_id_int = redis_lookup_id_random(record_id_random=account_id, table_name='account') + if not account_id_int: + raise HTTPException(status_code=400, detail="Invalid account_id.") + + # Generic link target (usually same as for_type/for_id but explicitly passed) + link_to_id_int = redis_lookup_id_random(record_id_random=for_id, table_name=for_type) + if not link_to_id_int: + raise HTTPException(status_code=400, detail=f"Invalid link target ID for type {for_type}.") + + event_file_results = [] + + for file_obj in file_list: + # 2. Extension Validation + validate_file_extension(file_obj.filename, allowed_extensions) + + # 3. Physical Save & Hosted File Sync (Deduplication) + file_info = await save_file( + file = file_obj, + account_id = account_id_int, + account_id_random = account_id, + link_to_type = for_type, + link_to_id = link_to_id_int, + link_to_id_random = for_id, + check_allowed_extension = False, + ) + + if not file_info.get('saved'): + log.error(f"Failed to save physical file: {file_obj.filename}") + continue + + hosted_file_id_int = None + + # Deduplication lookup + if existing_rec := sql_select(table_name='hosted_file', field_name='hash_sha256', field_value=file_info['hash_sha256']): + hosted_file_id_int = existing_rec.get('id') + if not existing_rec.get('subdirectory_path') and file_info.get('subdirectory_path'): + sql_update(table_name='hosted_file', data={'id': hosted_file_id_int, 'subdirectory_path': file_info['subdirectory_path']}) + else: + file_info['account_id'] = account_id_int + file_info['account_id_random'] = account_id + new_hf = Hosted_File_Base(**file_info) + hosted_file_id_int = create_hosted_file_obj(hosted_file_obj_new=new_hf) + + if not hosted_file_id_int: + log.error("Database failure creating hosted_file record.") + continue + + # 4. Standard Generic Linking + create_hosted_file_link( + account_id = account_id_int, + hosted_file_id = hosted_file_id_int, + link_to_type = for_type, + link_to_id = link_to_id_int + ) + + # 5. Specialized Event File Linking + # Prepare the event_file record + ef_data = { + "hosted_file_id": hosted_file_id_int, + "for_type": for_type, + "for_id": link_to_id_int, # Explicitly pass the resolved int ID + "for_id_random": for_id, + "event_id_random": event_id, + "event_session_id_random": event_session_id, + "event_presentation_id_random": event_presentation_id, + "event_presenter_id_random": event_presenter_id, + "event_location_id_random": event_location_id, + "event_track_id_random": event_track_id, + "filename": file_obj.filename, + "extension": file_info['extension'], + "title": title, + "description": description, + "internal_use": internal_use, + "open_in_os": open_in_os, + "enable": True + } + + # Instantiate model to trigger ID resolution validators + new_ef_obj = Event_File_Base(**ef_data) + + res_ef_id = create_event_file_obj(event_file_obj_new=new_ef_obj) + + if res_ef_id is True: + # An update happened instead of an insert. Resolve the ID via unique keys. + # unique index: hosted_file_id_for (hosted_file_id, for_type, for_id) + lookup_res = sql_select( + table_name='event_file', + data={ + 'hosted_file_id': hosted_file_id_int, + 'for_type': for_type, + 'for_id': link_to_id_int + } + ) + if lookup_res: + res_ef_id = lookup_res.get('id') + + if isinstance(res_ef_id, int): + # Load the newly created/updated object (enriched view) + if enriched_ef := load_event_file_obj(event_file_id=res_ef_id, inc_hosted_file=True, model_as_dict=True): + # Vision Transformer: Ensure ID is the random string for the frontend + if 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 + + event_file_results.append(enriched_ef) + else: + log.error(f"Created/Updated event_file {res_ef_id} but failed to reload.") + else: + log.error(f"Failed to create/update event_file record. Result: {res_ef_id}") + + return mk_resp(data=event_file_results, status_message=f"Successfully processed {len(event_file_results)} event files.") + + +@router.get('/{event_file_id}/download') +async def download_event_file_action( + response: Response, + event_file_id: str = Path(min_length=11, max_length=22), + filename: Optional[str] = Query(None, min_length=4, max_length=255), + site_key: Optional[str] = Query(None), + range: Optional[str] = Header(None), + account: AccountContext = Depends(get_account_context_optional), + delay: DelayParams = Depends(), + ): + """ + Semantic alias for hosted_file download with Event-specific context. + """ + # Simply delegate to the universal hosted_file download logic + from app.routers.api_v3_actions_hosted_file import download_file_action + return await download_file_action( + response=response, + hosted_file_id=event_file_id, # The universal downloader now resolves this! + filename=filename, + site_key=site_key, + range=range, + account=account, + delay=delay + ) diff --git a/app/routers/registry.py b/app/routers/registry.py index 483a1d7..e37d8ae 100644 --- a/app/routers/registry.py +++ b/app/routers/registry.py @@ -7,7 +7,7 @@ from app.routers import ( event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing, event_location, event_person, event_presentation, event_presenter, event_session, - flask_cfg, hosted_file, api_v3_actions_hosted_file, lookup, + flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, lookup, organization, page, person, person_user, qr, site, site_domain, user, util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe @@ -48,6 +48,7 @@ def setup_routers(app: FastAPI): app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File']) app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)']) + app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)']) app.include_router(lookup.router, prefix='/lu', tags=['Lookup']) # app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)]) diff --git a/documentation/GUIDE__V3_FRONTEND_API.md b/documentation/GUIDE__V3_FRONTEND_API.md index 24a8871..9ca8548 100644 --- a/documentation/GUIDE__V3_FRONTEND_API.md +++ b/documentation/GUIDE__V3_FRONTEND_API.md @@ -107,7 +107,35 @@ V3 uses specialized **"Action"** routes for binary operations to separate proces --- -## 5. Hosted File Management (Legacy) +## 5. Event File Management (Specialized V3 Actions) + +While `hosted_file` handles generic storage, `event_file` actions are context-aware and atomic for the Event module. + +### A. Atomic Upload Action +**Path**: `POST /v3/action/event_file/upload` +**Format**: `multipart/form-data` + +| Field | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `file_list` | File[] | Yes | Files to upload. | +| `account_id`| String | Yes | Owner account. | +| `for_type` | String | Yes | Parent object type (e.g., `event_session`). | +| `for_id` | String | Yes | Random ID of the parent object. | +| `event_id` | String | No | Optional event context. | +| `title` | String | No | Display title for the file. | + +**Features:** +- **Atomic Creation:** Automatically creates the `hosted_file`, the `hosted_file_link`, AND the `event_file` association in one request. +- **Intelligent Updates:** If the same file is uploaded again for the same object, it updates the metadata instead of creating a duplicate association. +- **Enriched Return:** Returns full `event_file` objects with nested `hosted_file` data. + +### B. Specialized Download +**Path**: `GET /v3/action/event_file/{id}/download` +*Semantic alias for the universal hosted_file downloader.* + +--- + +## 6. Hosted File Management (Legacy) The following endpoints are maintained for backward compatibility but should be migrated to V3 Actions.