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.
This commit is contained in:
224
app/routers/api_v3_actions_event_file.py
Normal file
224
app/routers/api_v3_actions_event_file.py
Normal file
@@ -0,0 +1,224 @@
|
||||
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
|
||||
)
|
||||
@@ -7,7 +7,7 @@ from app.routers import (
|
||||
event_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
||||
event_location, event_person,
|
||||
event_presentation, event_presenter, event_session,
|
||||
flask_cfg, hosted_file, api_v3_actions_hosted_file, lookup,
|
||||
flask_cfg, hosted_file, api_v3_actions_hosted_file, api_v3_actions_event_file, lookup,
|
||||
organization, page, person,
|
||||
person_user, qr, site, site_domain, user,
|
||||
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
||||
@@ -48,6 +48,7 @@ def setup_routers(app: FastAPI):
|
||||
|
||||
app.include_router(hosted_file.router, prefix='/hosted_file', tags=['Hosted File'])
|
||||
app.include_router(api_v3_actions_hosted_file.router, prefix='/v3/action/hosted_file', tags=['Hosted File (V3 Actions)'])
|
||||
app.include_router(api_v3_actions_event_file.router, prefix='/v3/action/event_file', tags=['Event File (V3 Actions)'])
|
||||
app.include_router(lookup.router, prefix='/lu', tags=['Lookup'])
|
||||
|
||||
# app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)])
|
||||
|
||||
@@ -107,7 +107,35 @@ V3 uses specialized **"Action"** routes for binary operations to separate proces
|
||||
|
||||
---
|
||||
|
||||
## 5. Hosted File Management (Legacy)
|
||||
## 5. Event File Management (Specialized V3 Actions)
|
||||
|
||||
While `hosted_file` handles generic storage, `event_file` actions are context-aware and atomic for the Event module.
|
||||
|
||||
### A. Atomic Upload Action
|
||||
**Path**: `POST /v3/action/event_file/upload`
|
||||
**Format**: `multipart/form-data`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `file_list` | File[] | Yes | Files to upload. |
|
||||
| `account_id`| String | Yes | Owner account. |
|
||||
| `for_type` | String | Yes | Parent object type (e.g., `event_session`). |
|
||||
| `for_id` | String | Yes | Random ID of the parent object. |
|
||||
| `event_id` | String | No | Optional event context. |
|
||||
| `title` | String | No | Display title for the file. |
|
||||
|
||||
**Features:**
|
||||
- **Atomic Creation:** Automatically creates the `hosted_file`, the `hosted_file_link`, AND the `event_file` association in one request.
|
||||
- **Intelligent Updates:** If the same file is uploaded again for the same object, it updates the metadata instead of creating a duplicate association.
|
||||
- **Enriched Return:** Returns full `event_file` objects with nested `hosted_file` data.
|
||||
|
||||
### B. Specialized Download
|
||||
**Path**: `GET /v3/action/event_file/{id}/download`
|
||||
*Semantic alias for the universal hosted_file downloader.*
|
||||
|
||||
---
|
||||
|
||||
## 6. Hosted File Management (Legacy)
|
||||
|
||||
The following endpoints are maintained for backward compatibility but should be migrated to V3 Actions.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user