From 833cdbd1409d0b8c3560fd3864716040fd1516d0 Mon Sep 17 00:00:00 2001 From: "Tal.Yuan" Date: Tue, 21 Jul 2026 18:39:27 +0800 Subject: [PATCH] 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. --- app.py | 2 +- routes/admin_wipe/__init__.py | 5 + routes/admin_wipe/admin_wipe_routes.py | 176 ++++++++++++++++++++++++ routes/admin_wipe_routes.py | 183 ++----------------------- tests/test_admin_wipe_routes_shim.py | 25 ++++ 5 files changed, 219 insertions(+), 172 deletions(-) create mode 100644 routes/admin_wipe/__init__.py create mode 100644 routes/admin_wipe/admin_wipe_routes.py create mode 100644 tests/test_admin_wipe_routes_shim.py diff --git a/app.py b/app.py index b614e2135..5c4db4087 100644 --- a/app.py +++ b/app.py @@ -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 diff --git a/routes/admin_wipe/__init__.py b/routes/admin_wipe/__init__.py new file mode 100644 index 000000000..9d5fa1a52 --- /dev/null +++ b/routes/admin_wipe/__init__.py @@ -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. +""" diff --git a/routes/admin_wipe/admin_wipe_routes.py b/routes/admin_wipe/admin_wipe_routes.py new file mode 100644 index 000000000..212e2a768 --- /dev/null +++ b/routes/admin_wipe/admin_wipe_routes.py @@ -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 diff --git a/routes/admin_wipe_routes.py b/routes/admin_wipe_routes.py index 212e2a768..a57c72df6 100644 --- a/routes/admin_wipe_routes.py +++ b/routes/admin_wipe_routes.py @@ -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 diff --git a/tests/test_admin_wipe_routes_shim.py b/tests/test_admin_wipe_routes_shim.py new file mode 100644 index 000000000..bab31cf7a --- /dev/null +++ b/tests/test_admin_wipe_routes_shim.py @@ -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" + )