Finalize diagnostics, logging controls, and email test support

This commit is contained in:
2026-03-01 22:34:07 +13:00
parent 12d3777e76
commit d1c9acbb8d
19 changed files with 2578 additions and 99 deletions

View File

@@ -84,9 +84,11 @@ from ..services.invite_email import (
get_invite_email_templates,
reset_invite_email_template,
save_invite_email_template,
send_test_email,
send_templated_email,
smtp_email_config_ready,
)
from ..services.diagnostics import get_diagnostics_catalog, run_diagnostics
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
@@ -192,6 +194,10 @@ SETTING_KEYS: List[str] = [
"qbittorrent_password",
"log_level",
"log_file",
"log_file_max_bytes",
"log_file_backup_count",
"log_http_client_level",
"log_background_sync_level",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
@@ -609,6 +615,7 @@ async def list_settings() -> Dict[str, Any]:
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
updates = 0
touched_logging = False
changed_keys: List[str] = []
for key, value in payload.items():
if key not in SETTING_KEYS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
@@ -617,6 +624,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(value, str) and value.strip() == "":
delete_setting(key)
updates += 1
changed_keys.append(key)
continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key in URL_SETTING_KEYS and value_to_store:
@@ -627,14 +635,79 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1
if key in {"log_level", "log_file"}:
changed_keys.append(key)
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
configure_logging(
runtime.log_level,
runtime.log_file,
log_file_max_bytes=runtime.log_file_max_bytes,
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
)
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates}
@router.post("/settings/test/email")
async def test_email_settings(request: Request) -> Dict[str, Any]:
recipient_email = None
content_type = (request.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
try:
if content_type == "application/json":
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("recipient_email"), str):
recipient_email = payload["recipient_email"]
elif content_type in {
"application/x-www-form-urlencoded",
"multipart/form-data",
}:
form = await request.form()
candidate = form.get("recipient_email")
if isinstance(candidate, str):
recipient_email = candidate
except Exception:
recipient_email = None
try:
result = await send_test_email(recipient_email=recipient_email)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
return {"status": "ok", **result}
@router.get("/diagnostics")
async def diagnostics_catalog() -> Dict[str, Any]:
return {"status": "ok", **get_diagnostics_catalog()}
@router.post("/diagnostics/run")
async def diagnostics_run(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
keys: Optional[List[str]] = None
recipient_email: Optional[str] = None
if payload is not None:
raw_keys = payload.get("keys")
if raw_keys is not None:
if not isinstance(raw_keys, list):
raise HTTPException(status_code=400, detail="keys must be an array of diagnostic keys")
keys = []
for raw_key in raw_keys:
if not isinstance(raw_key, str):
raise HTTPException(status_code=400, detail="Each diagnostic key must be a string")
normalized = raw_key.strip()
if normalized:
keys.append(normalized)
raw_recipient_email = payload.get("recipient_email")
if raw_recipient_email is not None:
if not isinstance(raw_recipient_email, str):
raise HTTPException(status_code=400, detail="recipient_email must be a string")
recipient_email = raw_recipient_email.strip() or None
return {"status": "ok", **(await run_diagnostics(keys, recipient_email=recipient_email))}
@router.get("/sonarr/options")
async def sonarr_options() -> Dict[str, Any]:
runtime = get_runtime_settings()
@@ -1061,12 +1134,14 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
@router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, True)
logger.warning("Admin blocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": True}
@router.post("/users/{username}/unblock")
async def unblock_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, False)
logger.info("Admin unblocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": False}
@@ -1173,6 +1248,17 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email"))
):
result["status"] = "partial"
logger.info(
"Admin system action completed: username=%s action=%s overall=%s local=%s jellyfin=%s jellyseerr=%s invites=%s email=%s",
username,
action,
result.get("status"),
result.get("local", {}).get("status"),
result.get("jellyfin", {}).get("status"),
result.get("jellyseerr", {}).get("status"),
result.get("invites", {}).get("status"),
result.get("email", {}).get("status"),
)
return result
@@ -1450,6 +1536,15 @@ async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
logger.info(
"Admin created profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
profile.get("id"),
profile.get("name"),
profile.get("role"),
profile.get("is_active"),
profile.get("auto_search_enabled"),
profile.get("account_expires_days"),
)
return {"status": "ok", "profile": profile}
@@ -1487,6 +1582,15 @@ async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, An
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
logger.info(
"Admin updated profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
profile.get("id"),
profile.get("name"),
profile.get("role"),
profile.get("is_active"),
profile.get("auto_search_enabled"),
profile.get("account_expires_days"),
)
return {"status": "ok", "profile": profile}
@@ -1498,6 +1602,7 @@ async def remove_profile(profile_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Profile not found")
logger.warning("Admin deleted profile: profile_id=%s", profile_id)
return {"status": "ok", "deleted": True, "profile_id": profile_id}
@@ -1561,6 +1666,7 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
master_invite_value = payload.get("master_invite_id")
if master_invite_value in (None, "", 0, "0"):
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
logger.info("Admin cleared invite policy master invite")
return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
try:
master_invite_id = int(master_invite_value)
@@ -1572,6 +1678,7 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
if not invite:
raise HTTPException(status_code=404, detail="Master invite not found")
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
logger.info("Admin updated invite policy: master_invite_id=%s", master_invite_id)
return {
"status": "ok",
"policy": {
@@ -1613,6 +1720,7 @@ async def update_invite_email_template_settings(template_key: str, payload: Dict
body_text=body_text or "",
body_html=body_html or "",
)
logger.info("Admin updated invite email template: template=%s", template_key)
return {"status": "ok", "template": template}
@@ -1621,6 +1729,7 @@ async def reset_invite_email_template_settings(template_key: str) -> Dict[str, A
if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
raise HTTPException(status_code=404, detail="Email template not found")
template = reset_invite_email_template(template_key)
logger.info("Admin reset invite email template: template=%s", template_key)
return {"status": "ok", "template": template}
@@ -1666,6 +1775,13 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info(
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
template_key,
result.get("recipient_email"),
invite.get("id") if invite else None,
user.get("username") if user else None,
)
return {
"status": "ok",
@@ -1725,6 +1841,18 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
)
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
send_email,
)
return {
"status": "partial" if email_error else "ok",
"invite": invite,
@@ -1785,6 +1913,18 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
)
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
send_email,
)
return {
"status": "partial" if email_error else "ok",
"invite": invite,
@@ -1803,4 +1943,5 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
if not deleted:
raise HTTPException(status_code=404, detail="Invite not found")
logger.warning("Admin deleted invite: invite_id=%s", invite_id)
return {"status": "ok", "deleted": True, "invite_id": invite_id}