Files
OSIT-AE-API-FastAPI/app/routers/api_v3_actions_event_file.py
Scott Idem bcd466edc7 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.
2026-02-03 12:54:17 -05:00

225 lines
9.0 KiB
Python

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
)