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_device, event_exhibit, event_exhibit_tracking, event_file, event_importing,
|
||||||
event_location, event_person,
|
event_location, event_person,
|
||||||
event_presentation, event_presenter, event_session,
|
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,
|
organization, page, person,
|
||||||
person_user, qr, site, site_domain, user,
|
person_user, qr, site, site_domain, user,
|
||||||
util_email, websockets, websockets_redis, websockets_v3, e_confex, e_cvent, e_impexium, e_stripe
|
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(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_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(lookup.router, prefix='/lu', tags=['Lookup'])
|
||||||
|
|
||||||
# app.include_router(organization.router, prefix='/organization', tags=['Organization'], dependencies=[Depends(DeprecationParams)])
|
# 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.
|
The following endpoints are maintained for backward compatibility but should be migrated to V3 Actions.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user