172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
"""/wp/api/database/* — read-only info + maintenance dispatch + location config."""
|
|
from __future__ import annotations
|
|
|
|
import json as _json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from aiohttp import web
|
|
|
|
from engine.db import config as db_config
|
|
from engine.db.connection import (
|
|
global_location_path,
|
|
resolve_db_path_with_source,
|
|
root_location_path,
|
|
user_location_path,
|
|
)
|
|
from engine.db.info import analyze, gather_info, integrity_check, run_migrations, vacuum
|
|
from wp_api._helpers import db_session, json_error, json_ok
|
|
|
|
_OPS = {
|
|
"vacuum": vacuum,
|
|
"integrity": integrity_check,
|
|
"analyze": analyze,
|
|
"migrate": run_migrations,
|
|
}
|
|
|
|
_VALID_PREFERENCES = {"user", "global", "root"}
|
|
_VALID_MODES = {"copy", "move"}
|
|
|
|
|
|
async def get_info(request: web.Request) -> web.Response:
|
|
db_path, source = resolve_db_path_with_source()
|
|
with db_session(request) as conn:
|
|
info = gather_info(conn, db_path, source=source)
|
|
return json_ok(info)
|
|
|
|
|
|
async def run_maintenance(request: web.Request) -> web.Response:
|
|
try:
|
|
body = await request.json()
|
|
except _json.JSONDecodeError:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be an object", status=400)
|
|
op = body.get("op")
|
|
if op not in _OPS:
|
|
return json_error(
|
|
f"unknown op {op!r}; expected one of: {sorted(_OPS)}",
|
|
status=400,
|
|
)
|
|
handler = _OPS[op]
|
|
with db_session(request) as conn:
|
|
result = handler(conn)
|
|
# result already includes ok / op / duration_ms (+ op-specific keys)
|
|
return json_ok(result)
|
|
|
|
|
|
def _location_entry(path: Path | None) -> dict[str, Any]:
|
|
"""Render one location dict for the locations payload.
|
|
|
|
``path`` is ``None`` when the user-dir detector chain failed; in that
|
|
case the entry still has the same shape so the frontend can render it
|
|
consistently (it'll just hide/disable the radio for that option)."""
|
|
if path is None:
|
|
return {"path": None, "exists": False, "size_bytes": None}
|
|
exists = path.is_file()
|
|
size = path.stat().st_size if exists else None
|
|
return {"path": str(path), "exists": exists, "size_bytes": size}
|
|
|
|
|
|
def _resolve_all_locations() -> dict[str, dict[str, Any]]:
|
|
"""Compute the three potential DB locations for the config payload."""
|
|
return {
|
|
"user": _location_entry(user_location_path()),
|
|
"global": _location_entry(global_location_path()),
|
|
"root": _location_entry(root_location_path()),
|
|
}
|
|
|
|
|
|
def _build_config_response() -> dict[str, Any]:
|
|
"""Compose the GET /wp/api/database/config response from sidecar +
|
|
detected paths + env-lock detection.
|
|
|
|
Shared by GET, PUT, and DELETE handlers so the response shape stays
|
|
in one place."""
|
|
cfg = db_config.load()
|
|
return {
|
|
"preference": cfg.get("preference"),
|
|
"pending_move": cfg.get("pending_move"),
|
|
"locations": _resolve_all_locations(),
|
|
"env_locked": bool(
|
|
os.environ.get("WP_DB_PATH") or os.environ.get("COMFYUI_USER_DIR")
|
|
),
|
|
}
|
|
|
|
|
|
def _validate_pending_move(pm: Any) -> tuple[dict[str, str] | None, str | None]:
|
|
"""Validate a pending_move payload.
|
|
|
|
Returns ``(value, None)`` on success where ``value`` is either a
|
|
normalized dict or ``None`` (explicit clear). Returns ``(None, err)``
|
|
on validation failure."""
|
|
if pm is None:
|
|
return None, None
|
|
if not isinstance(pm, dict):
|
|
return None, "pending_move must be an object or null"
|
|
src = pm.get("from")
|
|
dst = pm.get("to")
|
|
mode = pm.get("mode")
|
|
if not (isinstance(src, str) and isinstance(dst, str) and isinstance(mode, str)):
|
|
return None, "pending_move requires string fields 'from', 'to', and 'mode'"
|
|
if mode not in _VALID_MODES:
|
|
return None, f"pending_move.mode must be one of: {sorted(_VALID_MODES)}"
|
|
if not Path(src).is_absolute() or not Path(dst).is_absolute():
|
|
return None, "pending_move.from and pending_move.to must be absolute paths"
|
|
return {"from": src, "to": dst, "mode": mode}, None
|
|
|
|
|
|
async def get_config(request: web.Request) -> web.Response:
|
|
return json_ok(_build_config_response())
|
|
|
|
|
|
async def put_config(request: web.Request) -> web.Response:
|
|
try:
|
|
body = await request.json()
|
|
except _json.JSONDecodeError:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be an object", status=400)
|
|
|
|
current = db_config.load()
|
|
new_cfg: dict[str, Any] = dict(current)
|
|
|
|
if "preference" in body:
|
|
pref = body["preference"]
|
|
if pref is None:
|
|
new_cfg.pop("preference", None)
|
|
elif isinstance(pref, str) and pref in _VALID_PREFERENCES:
|
|
new_cfg["preference"] = pref
|
|
else:
|
|
return json_error(
|
|
f"preference must be null or one of: {sorted(_VALID_PREFERENCES)}",
|
|
status=400,
|
|
)
|
|
|
|
if "pending_move" in body:
|
|
pm_raw = body["pending_move"]
|
|
if pm_raw is None:
|
|
new_cfg.pop("pending_move", None)
|
|
else:
|
|
value, err = _validate_pending_move(pm_raw)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
new_cfg["pending_move"] = value # type: ignore[assignment]
|
|
|
|
db_config.save(new_cfg) # type: ignore[arg-type]
|
|
return json_ok(_build_config_response())
|
|
|
|
|
|
async def delete_pending_move(request: web.Request) -> web.Response:
|
|
db_config.clear_pending_move()
|
|
return json_ok(_build_config_response())
|
|
|
|
|
|
def register(router: web.UrlDispatcher) -> None:
|
|
router.add_get("/wp/api/database/info", get_info)
|
|
router.add_post("/wp/api/database/maintenance", run_maintenance)
|
|
router.add_get("/wp/api/database/config", get_config)
|
|
router.add_put("/wp/api/database/config", put_config)
|
|
router.add_delete("/wp/api/database/config/pending-move", delete_pending_move)
|