Add application logging, SMTP email notifications, and supporting changes

Phase 5.4 — Application Logging:
- AppLog model + migration 0010 for unified audit/usage/error logging
- Usage logging middleware in app.py (after_request for /api/* requests)
- Error logging in 500 handler with traceback capture
- Audit logging for auth events (register, login, login_failed, logout,
  password_change) and admin actions (backup, restore, user_delete,
  registration_toggle, smtp_config, smtp_test)
- Admin log viewer (LogsView.vue) with stats, category/search/date
  filters, paginated table with expandable detail rows
- Admin logs API endpoints in admin.py (GET /logs, GET /logs/stats)
- Configurable retention via LOG_RETENTION_DAYS with hourly cleanup

Phase 5.5 — SMTP Email Notifications:
- aiosmtplib dependency for async email sending
- Email service (services/email.py) with STARTTLS/implicit TLS support
- Notification service (services/notifications.py) for security alerts
  and task due date reminders with per-user preferences
- Admin SMTP config endpoints (GET/PUT /api/admin/smtp, POST test)
- SMTP config in Config class with env var + Docker secret support
- Settings UI: notification preferences for all users, SMTP config
  section for admin with test email

Other changes:
- stream_chat() now accepts optional options dict (for num_predict)
- Increase assist MAX_BODY_CHARS from 3000 to 8000
- get_user_by_username() added to auth service
- apiStreamPost buffer processing refactored for robustness
- AppHeader: admin Logs nav link
- Router: /admin/logs route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 08:34:52 -05:00
parent a89d25f5d6
commit d874e0e2ae
19 changed files with 1516 additions and 31 deletions
+86 -2
View File
@@ -1,6 +1,6 @@
import json
from quart import Blueprint, Response, jsonify, request
from quart import Blueprint, Response, g, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.auth import (
@@ -14,6 +14,9 @@ from fabledassistant.services.backup import (
export_user_backup,
restore_full_backup,
)
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_smtp_config, send_test_email
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.settings import set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -26,13 +29,13 @@ async def backup():
if scope == "full":
# Full backup requires admin
from quart import g
if g.user.role != "admin":
return jsonify({"error": "Admin access required for full backup"}), 403
data = await export_full_backup()
else:
data = await export_user_backup(uid)
await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
return Response(
json.dumps(data, indent=2, default=str),
content_type="application/json",
@@ -50,6 +53,8 @@ async def restore():
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
uid = get_current_user_id()
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
return jsonify({"status": "ok", "stats": stats})
@@ -70,6 +75,7 @@ async def remove_user(user_id: int):
deleted = await delete_user(user_id)
if not deleted:
return jsonify({"error": "User not found"}), 404
await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
return jsonify({"status": "ok"})
@@ -90,4 +96,82 @@ async def toggle_registration():
uid = get_current_user_id()
await set_registration_open(uid, bool(open_val))
await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
return jsonify({"status": "ok", "open": bool(open_val)})
@admin_bp.route("/smtp", methods=["GET"])
@admin_required
async def get_smtp():
config = await get_smtp_config()
# Mask password
if config.get("smtp_password"):
config["smtp_password"] = "********"
return jsonify(config)
@admin_bp.route("/smtp", methods=["PUT"])
@admin_required
async def update_smtp():
data = await request.get_json()
uid = get_current_user_id()
settings_to_save = {}
for key in SMTP_SETTING_KEYS:
if key in data:
# Skip password if it's the mask placeholder
if key == "smtp_password" and data[key] == "********":
continue
settings_to_save[key] = str(data[key])
if settings_to_save:
await set_settings_batch(uid, settings_to_save)
await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
return jsonify({"status": "ok"})
@admin_bp.route("/smtp/test", methods=["POST"])
@admin_required
async def test_smtp():
data = await request.get_json()
recipient = (data.get("recipient") or "").strip()
if not recipient:
return jsonify({"error": "Recipient email is required"}), 400
uid = get_current_user_id()
try:
await send_test_email(recipient)
await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@admin_bp.route("/logs", methods=["GET"])
@admin_required
async def list_logs():
category = request.args.get("category")
user_id = request.args.get("user_id", type=int)
search = request.args.get("search")
date_from = request.args.get("date_from")
date_to = request.args.get("date_to")
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
logs, total = await get_logs(
category=category,
user_id=user_id,
search=search,
date_from=date_from,
date_to=date_to,
limit=min(limit, 200),
offset=offset,
)
return jsonify({"logs": logs, "total": total})
@admin_bp.route("/logs/stats", methods=["GET"])
@admin_required
async def log_stats():
stats = await get_log_stats()
return jsonify(stats)