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.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, 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 )