refactor(routes): move admin_wipe domain into routes/admin_wipe/ subpackage (#5659)

Slice 2h of the route-domain reorganization (#4082/#4071). Moves
admin_wipe_routes.py into routes/admin_wipe/, leaving a backward-compat
sys.modules shim at the old path. Pure file reorganization, no behavior
change.

The shim uses sys.modules replacement so the `import ... as
admin_wipe_routes` + `monkeypatch.setattr(admin_wipe_routes, "SessionLocal",
...)` / `"require_admin"` pattern in test_admin_wipe_gallery.py reaches
the canonical module.

Canonical module imports only from core/, src/, and stdlib (zero internal
routes/ coupling). Zero source-introspection landmines.

Adds tests/test_admin_wipe_routes_shim.py to pin the sys.modules shim
contract. Verified: compileall clean; targeted tests pass.
This commit is contained in:
Tal.Yuan
2026-07-21 18:39:27 +08:00
committed by GitHub
parent b7d3f2a28d
commit 833cdbd140
5 changed files with 219 additions and 172 deletions
+1 -1
View File
@@ -663,7 +663,7 @@ app.include_router(setup_session_routes(
))
# Admin Danger Zone wipes (Settings → System → Danger Zone)
from routes.admin_wipe_routes import setup_admin_wipe_routes
from routes.admin_wipe.admin_wipe_routes import setup_admin_wipe_routes
app.include_router(setup_admin_wipe_routes(session_manager))
# Memory
+5
View File
@@ -0,0 +1,5 @@
"""Admin wipe route domain package (slice 2h, #4082/#4071).
Contains admin_wipe_routes.py, migrated from the flat routes/ directory.
Backward-compat shim at routes/admin_wipe_routes.py re-exports from here.
"""
+176
View File
@@ -0,0 +1,176 @@
"""Admin Danger Zone — per-category wipes.
Each endpoint is admin-only and truncates exactly one domain so the
user can selectively reset memory / skills / notes / etc. without
nuking everything. The catch-all `chats` endpoint mirrors the
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
URL shape: DELETE /api/admin/wipe/{kind}
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
"""
import json
import logging
import os
import shutil
from fastapi import APIRouter, HTTPException, Request
from core.middleware import require_admin
from core.database import (
SessionLocal,
Session as DbSession,
ChatMessage as DbChatMessage,
Memory,
Note,
ScheduledTask,
TaskRun,
Document,
DocumentVersion,
GalleryImage,
GalleryAlbum,
CalendarEvent,
CalendarCal,
)
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
logger = logging.getLogger(__name__)
def _wipe_memory_files():
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
next audit doesn't try to diff against gone memories."""
for name in ("memory.json", "memory_tidy_state.json"):
p = os.path.join(DATA_DIR, name)
if not os.path.exists(p):
continue
try:
if name == "memory.json":
with open(p, "w", encoding="utf-8") as f:
json.dump([], f)
else:
os.remove(p)
except OSError as e:
logger.warning(f"Could not reset {name}: {e}")
def _rmtree_quiet(path: str):
"""rmtree that doesn't crash if the path doesn't exist."""
if os.path.isdir(path):
try:
shutil.rmtree(path)
except OSError as e:
logger.warning(f"Could not remove {path}: {e}")
def setup_admin_wipe_routes(session_manager):
"""The session_manager is passed in so we can also clear its
in-memory cache when wiping chats — without it the DB is empty
but the next /api/sessions returns stale entries."""
router = APIRouter(prefix="/api/admin")
@router.delete("/wipe/{kind}")
def wipe(kind: str, request: Request):
require_admin(request)
kind = (kind or "").strip().lower()
db = SessionLocal()
try:
if kind == "chats":
count = db.query(DbSession).count()
db.query(DbChatMessage).delete()
db.query(DbSession).delete()
db.commit()
try:
session_manager.sessions.clear()
except Exception:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "memory":
count = db.query(Memory).count()
db.query(Memory).delete()
db.commit()
_wipe_memory_files()
# Drop the vector store too so semantic search doesn't
# return ghosts. Lazy import — chromadb may not be
# initialised in every deployment.
try:
from src.memory_vector import get_memory_vector_store
mv = get_memory_vector_store()
if mv and hasattr(mv, "clear"):
mv.clear()
except Exception as e:
logger.info(f"Memory vector clear skipped: {e}")
return {"status": "deleted", "kind": kind, "count": count}
if kind == "skills":
# Skills live as SKILL.md files under data/skills/. Drop
# the entire directory; the SkillsManager re-creates the
# tree on next write.
skills_dir = SKILLS_DIR
count = 0
if os.path.isdir(skills_dir):
# Count SKILL.md files for the response — quick walk.
for _, _, files in os.walk(skills_dir):
count += sum(1 for f in files if f == "SKILL.md")
_rmtree_quiet(skills_dir)
# Legacy fallback file
legacy = SKILLS_FILE
if os.path.exists(legacy):
try:
os.remove(legacy)
except OSError:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "notes":
count = db.query(Note).count()
db.query(Note).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "tasks":
# TaskRun rows reference tasks via FK — clear them first.
db.query(TaskRun).delete()
count = db.query(ScheduledTask).count()
db.query(ScheduledTask).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "documents":
# DocumentVersion FKs Document — clear children first.
db.query(DocumentVersion).delete()
count = db.query(Document).count()
db.query(Document).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "gallery":
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
db.query(GalleryImage).delete()
db.query(GalleryAlbum).delete()
db.commit()
# Also drop the upload dir so disk doesn't keep orphans.
_rmtree_quiet(GALLERY_DIR)
_rmtree_quiet(GALLERY_UPLOADS_DIR)
return {"status": "deleted", "kind": kind, "count": count}
if kind == "calendar":
# Events FK calendars — clear children first, then both.
db.query(CalendarEvent).delete()
count = db.query(CalendarCal).count()
db.query(CalendarCal).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.exception(f"Wipe {kind} failed")
raise HTTPException(500, f"Wipe {kind} failed: {e}")
finally:
db.close()
return router
+12 -171
View File
@@ -1,176 +1,17 @@
"""Admin Danger Zone — per-category wipes.
"""Backward-compat shim — canonical location is routes/admin_wipe/admin_wipe_routes.py.
Each endpoint is admin-only and truncates exactly one domain so the
user can selectively reset memory / skills / notes / etc. without
nuking everything. The catch-all `chats` endpoint mirrors the
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
URL shape: DELETE /api/admin/wipe/{kind}
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
This module is replaced in ``sys.modules`` by the canonical module object so
that ``import routes.admin_wipe_routes``, ``from routes.admin_wipe_routes
import X``, ``importlib.import_module("routes.admin_wipe_routes")``, and the
``import ... as admin_wipe_routes`` + ``monkeypatch.setattr(admin_wipe_routes,
"SessionLocal", ...)`` / ``"require_admin"`` pattern used by
test_admin_wipe_gallery.py all operate on the *same* object the application
actually uses. Keeps existing import paths working after slice 2h
(#4082/#4071).
"""
import json
import logging
import os
import shutil
from fastapi import APIRouter, HTTPException, Request
import sys as _sys
from core.middleware import require_admin
from core.database import (
SessionLocal,
Session as DbSession,
ChatMessage as DbChatMessage,
Memory,
Note,
ScheduledTask,
TaskRun,
Document,
DocumentVersion,
GalleryImage,
GalleryAlbum,
CalendarEvent,
CalendarCal,
)
from src.constants import DATA_DIR, SKILLS_DIR, SKILLS_FILE, GALLERY_DIR, GALLERY_UPLOADS_DIR
from routes.admin_wipe import admin_wipe_routes as _canonical # noqa: F401
logger = logging.getLogger(__name__)
def _wipe_memory_files():
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
next audit doesn't try to diff against gone memories."""
for name in ("memory.json", "memory_tidy_state.json"):
p = os.path.join(DATA_DIR, name)
if not os.path.exists(p):
continue
try:
if name == "memory.json":
with open(p, "w", encoding="utf-8") as f:
json.dump([], f)
else:
os.remove(p)
except OSError as e:
logger.warning(f"Could not reset {name}: {e}")
def _rmtree_quiet(path: str):
"""rmtree that doesn't crash if the path doesn't exist."""
if os.path.isdir(path):
try:
shutil.rmtree(path)
except OSError as e:
logger.warning(f"Could not remove {path}: {e}")
def setup_admin_wipe_routes(session_manager):
"""The session_manager is passed in so we can also clear its
in-memory cache when wiping chats — without it the DB is empty
but the next /api/sessions returns stale entries."""
router = APIRouter(prefix="/api/admin")
@router.delete("/wipe/{kind}")
def wipe(kind: str, request: Request):
require_admin(request)
kind = (kind or "").strip().lower()
db = SessionLocal()
try:
if kind == "chats":
count = db.query(DbSession).count()
db.query(DbChatMessage).delete()
db.query(DbSession).delete()
db.commit()
try:
session_manager.sessions.clear()
except Exception:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "memory":
count = db.query(Memory).count()
db.query(Memory).delete()
db.commit()
_wipe_memory_files()
# Drop the vector store too so semantic search doesn't
# return ghosts. Lazy import — chromadb may not be
# initialised in every deployment.
try:
from src.memory_vector import get_memory_vector_store
mv = get_memory_vector_store()
if mv and hasattr(mv, "clear"):
mv.clear()
except Exception as e:
logger.info(f"Memory vector clear skipped: {e}")
return {"status": "deleted", "kind": kind, "count": count}
if kind == "skills":
# Skills live as SKILL.md files under data/skills/. Drop
# the entire directory; the SkillsManager re-creates the
# tree on next write.
skills_dir = SKILLS_DIR
count = 0
if os.path.isdir(skills_dir):
# Count SKILL.md files for the response — quick walk.
for _, _, files in os.walk(skills_dir):
count += sum(1 for f in files if f == "SKILL.md")
_rmtree_quiet(skills_dir)
# Legacy fallback file
legacy = SKILLS_FILE
if os.path.exists(legacy):
try:
os.remove(legacy)
except OSError:
pass
return {"status": "deleted", "kind": kind, "count": count}
if kind == "notes":
count = db.query(Note).count()
db.query(Note).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "tasks":
# TaskRun rows reference tasks via FK — clear them first.
db.query(TaskRun).delete()
count = db.query(ScheduledTask).count()
db.query(ScheduledTask).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "documents":
# DocumentVersion FKs Document — clear children first.
db.query(DocumentVersion).delete()
count = db.query(Document).count()
db.query(Document).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
if kind == "gallery":
count = db.query(GalleryImage).count() + db.query(GalleryAlbum).count()
db.query(GalleryImage).delete()
db.query(GalleryAlbum).delete()
db.commit()
# Also drop the upload dir so disk doesn't keep orphans.
_rmtree_quiet(GALLERY_DIR)
_rmtree_quiet(GALLERY_UPLOADS_DIR)
return {"status": "deleted", "kind": kind, "count": count}
if kind == "calendar":
# Events FK calendars — clear children first, then both.
db.query(CalendarEvent).delete()
count = db.query(CalendarCal).count()
db.query(CalendarCal).delete()
db.commit()
return {"status": "deleted", "kind": kind, "count": count}
raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
except HTTPException:
raise
except Exception as e:
db.rollback()
logger.exception(f"Wipe {kind} failed")
raise HTTPException(500, f"Wipe {kind} failed: {e}")
finally:
db.close()
return router
_sys.modules[__name__] = _canonical
+25
View File
@@ -0,0 +1,25 @@
"""Regression test for the admin_wipe route shim (slice 2h, #4082/#4071).
The backward-compat shim at ``routes/admin_wipe_routes.py`` uses
``sys.modules`` replacement so the legacy import path and the canonical
``routes.admin_wipe.*`` path resolve to the *same* module object. This is
required because ``test_admin_wipe_gallery.py`` does
``import routes.admin_wipe_routes`` followed by
``monkeypatch.setattr(routes.admin_wipe_routes, "SessionLocal", ...)`` and
``"require_admin"`` — for those patches to take effect at runtime, the legacy
module object and the canonical one must be identical.
"""
import importlib
import routes.admin_wipe_routes as _shim_admin_wipe # noqa: F401
def test_legacy_and_canonical_admin_wipe_module_are_same_object():
"""``import routes.admin_wipe_routes`` must alias the canonical module."""
legacy = importlib.import_module("routes.admin_wipe_routes")
canonical = importlib.import_module("routes.admin_wipe.admin_wipe_routes")
assert legacy is canonical, (
"routes.admin_wipe_routes shim must resolve to the canonical "
"routes.admin_wipe.admin_wipe_routes module object"
)