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.
303 lines
12 KiB
Python
303 lines
12 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.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
|
|
)
|