- 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.
225 lines
9.0 KiB
Python
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
|
|
)
|