130 lines
5.7 KiB
Python
130 lines
5.7 KiB
Python
"""Admin Trash: view, restore, and permanently delete soft-deleted items."""
|
|
|
|
from fastapi import APIRouter, Request, Depends, Form
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import text
|
|
from typing import Optional
|
|
|
|
from core.database import get_db
|
|
from core.base_repository import BaseRepository
|
|
from core.sidebar import get_sidebar_data
|
|
|
|
router = APIRouter(prefix="/admin/trash", tags=["admin"])
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
# Entity configs for trash view: (table, display_name, name_column, detail_url_pattern)
|
|
TRASH_ENTITIES = [
|
|
{"table": "tasks", "label": "Tasks", "name_col": "title", "url": "/tasks/{id}"},
|
|
{"table": "projects", "label": "Projects", "name_col": "name", "url": "/projects/{id}"},
|
|
{"table": "notes", "label": "Notes", "name_col": "title", "url": "/notes/{id}"},
|
|
{"table": "links", "label": "Links", "name_col": "label", "url": "/links"},
|
|
{"table": "contacts", "label": "Contacts", "name_col": "first_name", "url": "/contacts/{id}"},
|
|
{"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"},
|
|
{"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"},
|
|
{"table": "lists", "label": "Lists", "name_col": "name", "url": "/lists/{id}"},
|
|
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
|
|
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
|
|
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
|
|
{"table": "link_folders", "label": "Link Folders", "name_col": "name", "url": "/weblinks"},
|
|
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
|
|
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
|
|
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
|
|
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
|
|
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
|
|
{"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"},
|
|
]
|
|
|
|
|
|
@router.get("/")
|
|
async def trash_view(
|
|
request: Request,
|
|
entity_type: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
sidebar = await get_sidebar_data(db)
|
|
deleted_items = []
|
|
entity_counts = {}
|
|
|
|
for entity in TRASH_ENTITIES:
|
|
try:
|
|
# Count deleted items per type
|
|
result = await db.execute(text(
|
|
f"SELECT count(*) FROM {entity['table']} WHERE is_deleted = true"
|
|
))
|
|
count = result.scalar() or 0
|
|
entity_counts[entity["table"]] = count
|
|
|
|
# Load items for selected type (or all if none selected)
|
|
if count > 0 and (entity_type is None or entity_type == entity["table"]):
|
|
result = await db.execute(text(f"""
|
|
SELECT id, {entity['name_col']} as display_name, deleted_at, created_at
|
|
FROM {entity['table']}
|
|
WHERE is_deleted = true
|
|
ORDER BY deleted_at DESC
|
|
LIMIT 50
|
|
"""))
|
|
rows = [dict(r._mapping) for r in result]
|
|
for row in rows:
|
|
deleted_items.append({
|
|
"id": str(row["id"]),
|
|
"name": str(row.get("display_name") or row["id"])[:100],
|
|
"table": entity["table"],
|
|
"type_label": entity["label"],
|
|
"deleted_at": row.get("deleted_at"),
|
|
"created_at": row.get("created_at"),
|
|
})
|
|
except Exception:
|
|
continue
|
|
|
|
total_deleted = sum(entity_counts.values())
|
|
|
|
return templates.TemplateResponse("trash.html", {
|
|
"request": request, "sidebar": sidebar,
|
|
"deleted_items": deleted_items,
|
|
"entity_counts": entity_counts,
|
|
"trash_entities": TRASH_ENTITIES,
|
|
"current_type": entity_type or "",
|
|
"total_deleted": total_deleted,
|
|
"page_title": "Trash", "active_nav": "trash",
|
|
})
|
|
|
|
|
|
@router.post("/{table}/{item_id}/restore")
|
|
async def restore_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
# Validate table name against known entities
|
|
valid_tables = {e["table"] for e in TRASH_ENTITIES}
|
|
if table not in valid_tables:
|
|
return RedirectResponse(url="/admin/trash", status_code=303)
|
|
|
|
repo = BaseRepository(table, db)
|
|
await repo.restore(item_id)
|
|
referer = request.headers.get("referer", "/admin/trash")
|
|
return RedirectResponse(url=referer, status_code=303)
|
|
|
|
|
|
@router.post("/{table}/{item_id}/permanent-delete")
|
|
async def permanent_delete_item(table: str, item_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
|
valid_tables = {e["table"] for e in TRASH_ENTITIES}
|
|
if table not in valid_tables:
|
|
return RedirectResponse(url="/admin/trash", status_code=303)
|
|
|
|
repo = BaseRepository(table, db)
|
|
await repo.permanent_delete(item_id)
|
|
referer = request.headers.get("referer", "/admin/trash")
|
|
return RedirectResponse(url=referer, status_code=303)
|
|
|
|
|
|
@router.post("/empty")
|
|
async def empty_trash(request: Request, db: AsyncSession = Depends(get_db)):
|
|
"""Permanently delete ALL soft-deleted items across all tables."""
|
|
for entity in TRASH_ENTITIES:
|
|
try:
|
|
await db.execute(text(
|
|
f"DELETE FROM {entity['table']} WHERE is_deleted = true"
|
|
))
|
|
except Exception:
|
|
continue
|
|
return RedirectResponse(url="/admin/trash", status_code=303)
|