Initial commit
This commit is contained in:
129
routers/admin.py
Normal file
129
routers/admin.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user