feat(event_file): add atomic DELETE and hosted_file orphan scan

DELETE /v3/action/event_file/{id} — removes hosted_file_link, optionally
cleans up physical file + hosted_file record if orphaned (rm_orphan=True
default), then deletes the event_file row. Closes the gap left by the V3
CRUD migration which silently dropped hosted_file cleanup.

GET /v3/action/hosted_file/orphan_scan — returns hosted_file rows with no
hosted_file_link entries (DB orphans, paginated), plus optional disk scan
for physical files with no DB record. Needed for admin cleanup of the
backlog accumulated during the broken-delete period.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-18 18:09:20 -04:00
parent 2b1044eebb
commit 88f7609b63
2 changed files with 110 additions and 2 deletions

View File

@@ -13,8 +13,8 @@ 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
create_hosted_file_obj, load_hosted_file_obj, save_file,
create_hosted_file_link, delete_hosted_file_link, handle_delete_hosted_file
)
from app.methods.event_file_methods import create_event_file_obj, load_event_file_obj
from app.lib_general_v3 import (
@@ -276,6 +276,49 @@ async def create_event_file_from_hosted_file_action(
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.")
ef_rec = sql_select(sql="SELECT hosted_file_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
link_cleaned = False
if hf_id_int:
link_cleaned = handle_delete_hosted_file(
account_id=account.account_id,
hosted_file_id=hf_id_int,
link_to_type='event_file',
link_to_id=ef_id_int,
rm_orphan=rm_orphan,
)
if not link_cleaned:
log.warning(f"handle_delete_hosted_file returned False for hosted_file {hf_id_int} / event_file {ef_id_int}")
sql_delete(table_name='event_file', record_id=ef_id_int)
return mk_resp(data={
'event_file_deleted': True,
'hosted_file_link_cleaned': bool(link_cleaned),
})
@router.get('/{event_file_id}/download')
async def download_event_file_action(
response: Response,