mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-10 15:38:51 -04:00
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:
@@ -663,7 +663,7 @@ app.include_router(setup_session_routes(
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Admin Danger Zone wipes (Settings → System → Danger Zone)
|
# 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))
|
app.include_router(setup_admin_wipe_routes(session_manager))
|
||||||
|
|
||||||
# Memory
|
# Memory
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
@@ -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
@@ -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
|
This module is replaced in ``sys.modules`` by the canonical module object so
|
||||||
user can selectively reset memory / skills / notes / etc. without
|
that ``import routes.admin_wipe_routes``, ``from routes.admin_wipe_routes
|
||||||
nuking everything. The catch-all `chats` endpoint mirrors the
|
import X``, ``importlib.import_module("routes.admin_wipe_routes")``, and the
|
||||||
existing /api/sessions/all so the Danger Zone speaks one URL pattern.
|
``import ... as admin_wipe_routes`` + ``monkeypatch.setattr(admin_wipe_routes,
|
||||||
|
"SessionLocal", ...)`` / ``"require_admin"`` pattern used by
|
||||||
URL shape: DELETE /api/admin/wipe/{kind}
|
test_admin_wipe_gallery.py all operate on the *same* object the application
|
||||||
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
|
actually uses. Keeps existing import paths working after slice 2h
|
||||||
|
(#4082/#4071).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import sys as _sys
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
|
||||||
|
|
||||||
from core.middleware import require_admin
|
from routes.admin_wipe import admin_wipe_routes as _canonical # noqa: F401
|
||||||
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__)
|
_sys.modules[__name__] = _canonical
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user