Files
OSIT-AE-API-FastAPI/app/routers/api_v3_actions_event_file.py
Scott Idem 45b2890e27 fix(event_file): use for_type/for_id for hosted_file_link deletion
hosted_file_link is created against the parent object (e.g. event_presenter),
not against event_file itself. The delete endpoint was passing link_to_type=
'event_file', finding 0 rows, and bailing before the orphan cleanup ran.

Fix: read for_type + for_id from the event_file row and use those for link
deletion. Also inline the orphan cleanup so a missing link (old orphan from
the pre-fix period) is treated as a non-fatal warning rather than a hard
failure — cleanup proceeds regardless.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:27:19 -04:00

397 lines
16 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, get_hosted_file_link_rec_list
)
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.delete('/{event_file_id}', response_model=Resp_Body_Base)
async def delete_event_file_action(
event_file_id: str = Path(min_length=11, max_length=22),
rm_orphan: bool = Query(True),
account: AccountContext = Depends(get_account_context),
delay: DelayParams = Depends(),
):
"""
Atomic delete for an event_file record.
1. Removes the hosted_file_link (event_file → hosted_file).
2. If rm_orphan=True and no other links remain, removes the physical file
and hosted_file DB record.
3. Removes the event_file row itself.
"""
if delay.sleep_time_s > 0: await asyncio.sleep(delay.sleep_time_s)
ef_id_int = redis_lookup_id_random(record_id_random=event_file_id, table_name='event_file')
if not ef_id_int:
raise HTTPException(status_code=404, detail="Event file not found.")
# Fetch hosted_file_id + the parent the link was originally created against.
# hosted_file_link uses for_type/for_id as link target, NOT 'event_file'.
ef_rec = sql_select(
sql="SELECT hosted_file_id, for_type, for_id FROM event_file WHERE id = :id",
data={'id': ef_id_int}
)
hf_id_int = ef_rec.get('hosted_file_id') if ef_rec else None
for_type = ef_rec.get('for_type') if ef_rec else None
for_id_int = ef_rec.get('for_id') if ef_rec else None
link_cleaned = False
orphan_cleaned = False
if hf_id_int:
# Step 1: Remove the hosted_file_link using the correct parent type/id.
# A missing link (old orphan from pre-fix period) is non-fatal — log and continue.
if for_type and for_id_int:
link_result = delete_hosted_file_link(
account_id=account.account_id,
hosted_file_id=hf_id_int,
link_to_type=for_type,
link_to_id=for_id_int,
)
if link_result:
link_cleaned = True
else:
log.warning(f"hosted_file_link not found (already removed or never created): hosted_file={hf_id_int} {for_type}:{for_id_int}")
# Step 2: Orphan check — clean up physical file + hosted_file record if no links remain.
if rm_orphan:
remaining = get_hosted_file_link_rec_list(hosted_file_id=hf_id_int)
if not remaining:
hf_obj = load_hosted_file_obj(hosted_file_id=hf_id_int)
if hf_obj:
file_path = os.path.join(
settings.FILES_PATH['hosted_files_root'],
hf_obj.subdirectory_path or '',
f'{hf_obj.hash_sha256}.file'
)
if os.path.exists(file_path):
try:
pathlib.Path(file_path).unlink()
log.info(f"Deleted physical file: {file_path}")
except OSError as e:
log.error(f"Failed to delete physical file {file_path}: {e}")
else:
log.warning(f"Physical file already absent from disk: {file_path}")
sql_delete(table_name='hosted_file', record_id=hf_id_int)
orphan_cleaned = True
# Step 3: Always remove the event_file row.
sql_delete(table_name='event_file', record_id=ef_id_int)
return mk_resp(data={
'event_file_deleted': True,
'hosted_file_link_cleaned': link_cleaned,
'orphan_cleaned': orphan_cleaned,
})
@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),
key: Optional[str] = Query(None),
site_key: Optional[str] = Query(None),
range: Optional[str] = Header(None),
account: AccountContext = Depends(get_account_context_optional),
delay: DelayParams = Depends(),
):
"""
Download the underlying file for an event_file record.
Resolves event_file_id → hosted_file_id explicitly before delegating.
"""
ef_int_id = redis_lookup_id_random(record_id_random=event_file_id, table_name='event_file')
if not ef_int_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event file record not found.")
ef_rec = sql_select(sql="SELECT hosted_file_id FROM event_file WHERE id = :id", data={'id': ef_int_id})
if not ef_rec or not ef_rec.get('hosted_file_id'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event file has no associated hosted file.")
hosted_file_id_random = get_id_random(record_id=ef_rec['hosted_file_id'], table_name='hosted_file')
if not hosted_file_id_random:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hosted file record not found.")
from app.routers.api_v3_actions_hosted_file import download_file_action
return await download_file_action(
response=response,
hosted_file_id=hosted_file_id_random,
filename=filename,
key=key,
site_key=site_key,
range=range,
account=account,
delay=delay
)