Files
aza/AzA march 2026 - Kopie (11)/admin_routes.py
2026-04-16 13:32:32 +02:00

749 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# admin_routes.py AZA Admin Control Panel v2 (internal JSON endpoints)
#
# All endpoints require X-Admin-Token header matching AZA_ADMIN_TOKEN env var.
# This router is mounted with prefix="/admin" in backend_main.py.
#
# v1 endpoints: system_status, licenses_overview, backup_status, billing_overview
# v2 endpoints: license_customer_map, revenue_overview, alerts, dashboard_summary
from __future__ import annotations
import calendar
import json
import os
import shutil
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from aza_security import require_admin_token
router = APIRouter(tags=["admin"], dependencies=[Depends(require_admin_token)])
_BASE_DIR = Path(__file__).resolve().parent
_START_TIME = time.time()
_BACKUP_PATHS = [
Path("/host_backups"),
Path("/root/aza-backups"),
Path("/root/aza-backups/daily"),
Path("/var/backups/aza"),
]
_BACKUP_LOG_PATHS = [
Path("/host_backups/backup.log"),
Path("/root/aza-backups/backup.log"),
Path("/var/log/aza-backup.log"),
]
_LOOKUP_KEY_PRICES_CHF: Dict[str, int] = {
"aza_basic_monthly": 59_00,
"aza_basic_yearly": 590_00,
"aza_team_monthly": 89_00,
"aza_team_yearly": 890_00,
}
def _stripe_db_path() -> Path:
return Path(os.environ.get(
"STRIPE_DB_PATH",
str(_BASE_DIR / "data" / "stripe_webhook.sqlite"),
))
def _events_log_path() -> Path:
return Path(os.environ.get(
"STRIPE_EVENTS_LOG",
str(_BASE_DIR / "data" / "stripe_events.log.jsonl"),
))
def _disk_usage() -> Dict[str, Any]:
try:
usage = shutil.disk_usage("/")
total_gb = round(usage.total / (1024 ** 3), 2)
used_gb = round(usage.used / (1024 ** 3), 2)
free_gb = round(usage.free / (1024 ** 3), 2)
used_pct = round((usage.used / usage.total) * 100, 1) if usage.total else 0
return {
"total_gb": total_gb,
"used_gb": used_gb,
"free_gb": free_gb,
"used_percent": used_pct,
}
except Exception as e:
return {"error": str(e)}
def _safe_db_connect(db_path: Path):
if not db_path.exists():
return None
return sqlite3.connect(str(db_path))
def _stripe_env_ok() -> bool:
return (
bool(os.environ.get("STRIPE_SECRET_KEY", "").strip())
and bool(os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip())
)
def _newest_backup_info() -> Dict[str, Any]:
"""Scan all backup paths and return info about the most recent backup."""
best: Dict[str, Any] = {"found": False}
best_mtime = 0.0
for bp in _BACKUP_PATHS:
if not bp.exists() or not bp.is_dir():
continue
try:
for entry in bp.iterdir():
if not entry.is_dir():
continue
mt = entry.stat().st_mtime
if mt > best_mtime:
best_mtime = mt
age_h = round((time.time() - mt) / 3600, 1)
best = {
"found": True,
"path": str(bp),
"name": entry.name,
"time_utc": datetime.fromtimestamp(mt, tz=timezone.utc).isoformat(),
"age_hours": age_h,
"age_days": round(age_h / 24, 1),
}
except Exception:
continue
return best
def _license_counts() -> Dict[str, int]:
"""Return {status: count} from licenses table."""
db_path = _stripe_db_path()
if not db_path.exists():
return {}
try:
con = sqlite3.connect(str(db_path))
rows = con.execute(
"SELECT status, COUNT(*) FROM licenses GROUP BY status"
).fetchall()
con.close()
return {r[0]: r[1] for r in rows}
except Exception:
return {}
# ═══════════════════════════════════════════════════════════════════════════════
# v1 ENDPOINTS (unchanged)
# ═══════════════════════════════════════════════════════════════════════════════
# ---------------------------------------------------------------------------
# 1. GET /admin/system_status
# ---------------------------------------------------------------------------
@router.get("/system_status")
def system_status() -> Dict[str, Any]:
now = datetime.now(timezone.utc)
uptime_s = int(time.time() - _START_TIME)
stripe_health: Dict[str, Any] = {"ok": False, "detail": "not_checked"}
stripe_key_set = bool(os.environ.get("STRIPE_SECRET_KEY", "").strip())
stripe_webhook_set = bool(os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip())
if stripe_key_set and stripe_webhook_set:
stripe_health = {"ok": True, "detail": "env_configured"}
elif not stripe_key_set:
stripe_health = {"ok": False, "detail": "STRIPE_SECRET_KEY missing"}
elif not stripe_webhook_set:
stripe_health = {"ok": False, "detail": "STRIPE_WEBHOOK_SECRET missing"}
db_path = _stripe_db_path()
db_exists = db_path.exists()
db_size_kb = round(db_path.stat().st_size / 1024, 1) if db_exists else None
return {
"status": "ok",
"timestamp_utc": now.isoformat(),
"uptime_seconds": uptime_s,
"disk": _disk_usage(),
"stripe": stripe_health,
"database": {
"path": str(db_path),
"exists": db_exists,
"size_kb": db_size_kb,
},
"python_pid": os.getpid(),
}
# ---------------------------------------------------------------------------
# 2. GET /admin/licenses_overview
# ---------------------------------------------------------------------------
@router.get("/licenses_overview")
def licenses_overview(email: Optional[str] = None) -> Dict[str, Any]:
db_path = _stripe_db_path()
if not db_path.exists():
return {
"status": "no_database",
"db_path": str(db_path),
"counts_by_status": {},
"total": 0,
"recent": [],
}
try:
con = sqlite3.connect(str(db_path))
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT status, COUNT(*) as cnt FROM licenses GROUP BY status"
).fetchall()
counts = {r["status"]: r["cnt"] for r in rows}
total = sum(counts.values())
if email:
email_clean = email.strip().lower()
recent_rows = con.execute(
"""SELECT * FROM licenses
WHERE lower(customer_email) = ?
ORDER BY updated_at DESC LIMIT 20""",
(email_clean,),
).fetchall()
else:
recent_rows = con.execute(
"SELECT * FROM licenses ORDER BY updated_at DESC LIMIT 20"
).fetchall()
recent = [dict(r) for r in recent_rows]
con.close()
return {
"status": "ok",
"counts_by_status": counts,
"total": total,
"recent": recent,
"filter_email": email or None,
}
except Exception as e:
return {"status": "error", "error": str(e)}
# ---------------------------------------------------------------------------
# 3. GET /admin/backup_status
# ---------------------------------------------------------------------------
@router.get("/backup_status")
def backup_status() -> Dict[str, Any]:
result: Dict[str, Any] = {
"disk": _disk_usage(),
"backup_locations": [],
"backup_log": None,
}
for bp in _BACKUP_PATHS:
entry: Dict[str, Any] = {"path": str(bp), "exists": bp.exists()}
if bp.exists() and bp.is_dir():
try:
items = sorted(bp.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)
dirs = [d for d in items if d.is_dir()]
all_files = list(bp.rglob("*"))
total_size = sum(f.stat().st_size for f in all_files if f.is_file())
entry["folder_count"] = len(dirs)
entry["file_count"] = len([f for f in all_files if f.is_file()])
entry["total_size_mb"] = round(total_size / (1024 ** 2), 2)
if dirs:
newest = dirs[0]
mtime = newest.stat().st_mtime
entry["newest_backup"] = newest.name
entry["newest_backup_time_utc"] = datetime.fromtimestamp(
mtime, tz=timezone.utc
).isoformat()
entry["newest_backup_age_hours"] = round(
(time.time() - mtime) / 3600, 1
)
except Exception as e:
entry["error"] = str(e)
result["backup_locations"].append(entry)
for lp in _BACKUP_LOG_PATHS:
if lp.exists() and lp.is_file():
try:
text = lp.read_text(encoding="utf-8", errors="replace")
lines = text.strip().splitlines()
tail = lines[-20:] if len(lines) > 20 else lines
result["backup_log"] = {
"path": str(lp),
"total_lines": len(lines),
"last_lines": tail,
}
except Exception as e:
result["backup_log"] = {"path": str(lp), "error": str(e)}
break
if result["backup_log"] is None:
result["backup_log"] = {"status": "not_found", "searched": [str(p) for p in _BACKUP_LOG_PATHS]}
return result
# ---------------------------------------------------------------------------
# 4. GET /admin/billing_overview
# ---------------------------------------------------------------------------
@router.get("/billing_overview")
def billing_overview() -> Dict[str, Any]:
stripe_key_set = bool(os.environ.get("STRIPE_SECRET_KEY", "").strip())
stripe_webhook_set = bool(os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip())
stripe_ok = stripe_key_set and stripe_webhook_set
db_path = _stripe_db_path()
events_path = _events_log_path()
db_info: Dict[str, Any] = {"exists": db_path.exists()}
licenses_summary: List[Dict[str, Any]] = []
events_info: Dict[str, Any] = {"exists": events_path.exists()}
if db_path.exists():
try:
con = sqlite3.connect(str(db_path))
con.row_factory = sqlite3.Row
db_info["size_kb"] = round(db_path.stat().st_size / 1024, 1)
rows = con.execute(
"""SELECT subscription_id, customer_email, status,
lookup_key, current_period_end, updated_at
FROM licenses ORDER BY updated_at DESC LIMIT 20"""
).fetchall()
licenses_summary = [dict(r) for r in rows]
processed_count = con.execute(
"SELECT COUNT(*) FROM processed_events"
).fetchone()[0]
db_info["processed_events_count"] = processed_count
con.close()
except Exception as e:
db_info["error"] = str(e)
if events_path.exists():
try:
size_kb = round(events_path.stat().st_size / 1024, 1)
events_info["size_kb"] = size_kb
with events_path.open("r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
events_info["total_lines"] = len(lines)
tail_lines = lines[-10:] if len(lines) > 10 else lines
recent_events: List[Dict[str, Any]] = []
for line in tail_lines:
line = line.strip()
if not line:
continue
try:
recent_events.append(json.loads(line))
except Exception:
recent_events.append({"raw": line[:200]})
events_info["recent"] = recent_events
except Exception as e:
events_info["error"] = str(e)
return {
"stripe_health": {
"ok": stripe_ok,
"secret_key_set": stripe_key_set,
"webhook_secret_set": stripe_webhook_set,
},
"database": db_info,
"licenses_recent": licenses_summary,
"events_log": events_info,
}
# ═══════════════════════════════════════════════════════════════════════════════
# v2 ENDPOINTS
# ═══════════════════════════════════════════════════════════════════════════════
# ---------------------------------------------------------------------------
# 5. GET /admin/license_customer_map
# ---------------------------------------------------------------------------
@router.get("/license_customer_map")
def license_customer_map(
email: Optional[str] = None,
status: Optional[str] = None,
) -> Dict[str, Any]:
db_path = _stripe_db_path()
if not db_path.exists():
return {"status": "no_database", "total": 0, "licenses": []}
try:
con = sqlite3.connect(str(db_path))
con.row_factory = sqlite3.Row
query = """
SELECT subscription_id, customer_id, customer_email, status,
lookup_key, allowed_users, devices_per_user,
current_period_end, client_reference_id, updated_at
FROM licenses
"""
conditions: List[str] = []
params: List[Any] = []
if email:
conditions.append("lower(customer_email) = ?")
params.append(email.strip().lower())
if status:
conditions.append("status = ?")
params.append(status.strip())
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY updated_at DESC LIMIT 200"
rows = con.execute(query, params).fetchall()
licenses = []
now_ts = int(time.time())
for r in rows:
row_dict = dict(r)
cpe = row_dict.get("current_period_end")
if cpe and isinstance(cpe, int):
row_dict["period_end_human"] = datetime.fromtimestamp(
cpe, tz=timezone.utc
).strftime("%Y-%m-%d %H:%M UTC")
row_dict["period_expired"] = cpe < now_ts
ua = row_dict.get("updated_at")
if ua and isinstance(ua, int):
row_dict["updated_at_human"] = datetime.fromtimestamp(
ua, tz=timezone.utc
).strftime("%Y-%m-%d %H:%M UTC")
licenses.append(row_dict)
counts = _license_counts()
con.close()
return {
"status": "ok",
"total": len(licenses),
"counts_by_status": counts,
"filter_email": email or None,
"filter_status": status or None,
"licenses": licenses,
}
except Exception as e:
return {"status": "error", "error": str(e)}
# ---------------------------------------------------------------------------
# 6. GET /admin/revenue_overview
# ---------------------------------------------------------------------------
@router.get("/revenue_overview")
def revenue_overview() -> Dict[str, Any]:
now = datetime.now(timezone.utc)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_start_ts = int(month_start.timestamp())
result: Dict[str, Any] = {
"month": now.strftime("%Y-%m"),
"currency": "chf",
"data_source": "local_db",
}
counts = _license_counts()
result["active_subscriptions"] = counts.get("active", 0)
result["canceled_subscriptions"] = counts.get("canceled", 0)
result["total_subscriptions"] = sum(counts.values())
result["counts_by_status"] = counts
db_path = _stripe_db_path()
counts_by_key: Dict[str, int] = {}
estimated_mrr_cents = 0
if db_path.exists():
try:
con = sqlite3.connect(str(db_path))
rows = con.execute(
"SELECT lookup_key, COUNT(*) FROM licenses WHERE status='active' GROUP BY lookup_key"
).fetchall()
for lk, cnt in rows:
lk_str = lk or "unknown"
counts_by_key[lk_str] = cnt
price = _LOOKUP_KEY_PRICES_CHF.get(lk_str, 0)
if "yearly" in lk_str:
estimated_mrr_cents += int(price / 12) * cnt
else:
estimated_mrr_cents += price * cnt
con.close()
except Exception:
pass
result["counts_by_lookup_key"] = counts_by_key
result["estimated_mrr_chf"] = round(estimated_mrr_cents / 100, 2)
stripe_data: Dict[str, Any] = {"available": False}
stripe_key = os.environ.get("STRIPE_SECRET_KEY", "").strip()
if stripe_key:
try:
import stripe as _stripe
_stripe.api_key = stripe_key
charges = _stripe.Charge.list(
created={"gte": month_start_ts},
limit=100,
)
gross_cents = 0
charge_count = 0
recent_charges: List[Dict[str, Any]] = []
for ch in charges.auto_paging_iter():
if ch.status == "succeeded" and ch.paid:
gross_cents += ch.amount
charge_count += 1
recent_charges.append({
"amount_chf": round(ch.amount / 100, 2),
"email": ch.billing_details.email if ch.billing_details else ch.receipt_email,
"date_utc": datetime.fromtimestamp(ch.created, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"description": ch.description or "",
"charge_id": ch.id,
})
refunds = _stripe.Refund.list(
created={"gte": month_start_ts},
limit=100,
)
refund_cents = 0
refund_count = 0
recent_refunds: List[Dict[str, Any]] = []
for rf in refunds.auto_paging_iter():
if rf.status == "succeeded":
refund_cents += rf.amount
refund_count += 1
recent_refunds.append({
"amount_chf": round(rf.amount / 100, 2),
"date_utc": datetime.fromtimestamp(rf.created, tz=timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"refund_id": rf.id,
})
stripe_data = {
"available": True,
"current_month_gross_chf": round(gross_cents / 100, 2),
"current_month_charges": charge_count,
"current_month_refunds_chf": round(refund_cents / 100, 2),
"current_month_refund_count": refund_count,
"current_month_net_chf": round((gross_cents - refund_cents) / 100, 2),
"recent_charges": recent_charges,
"recent_refunds": recent_refunds,
}
result["data_source"] = "stripe_api+local_db"
except Exception as e:
stripe_data = {"available": False, "error": str(e)}
result["stripe_live"] = stripe_data
events_path = _events_log_path()
event_summary: Dict[str, int] = {}
if events_path.exists():
try:
with events_path.open("r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
evt = json.loads(line)
ts = evt.get("ts", 0)
if ts >= month_start_ts:
kind = evt.get("kind", "unknown")
event_summary[kind] = event_summary.get(kind, 0) + 1
except Exception:
continue
except Exception:
pass
result["current_month_events"] = event_summary
return result
# ---------------------------------------------------------------------------
# 7. GET /admin/alerts
# ---------------------------------------------------------------------------
@router.get("/alerts")
def alerts() -> Dict[str, Any]:
alert_list: List[Dict[str, str]] = []
disk = _disk_usage()
used_pct = disk.get("used_percent", 0)
free_gb = disk.get("free_gb", 999)
if isinstance(used_pct, (int, float)):
if used_pct >= 95:
alert_list.append({
"id": "disk_critical",
"severity": "critical",
"message": f"Disk usage {used_pct}% less than {free_gb} GB free",
})
elif used_pct >= 85:
alert_list.append({
"id": "disk_high",
"severity": "warning",
"message": f"Disk usage {used_pct}% {free_gb} GB free",
})
if not _stripe_env_ok():
alert_list.append({
"id": "stripe_not_configured",
"severity": "critical",
"message": "Stripe env vars (SECRET_KEY / WEBHOOK_SECRET) not set",
})
db_path = _stripe_db_path()
if not db_path.exists():
alert_list.append({
"id": "db_missing",
"severity": "warning",
"message": f"License database not found at {db_path}",
})
counts = _license_counts()
if not counts or counts.get("active", 0) == 0:
alert_list.append({
"id": "no_active_licenses",
"severity": "info",
"message": "No active licenses in database",
})
backup = _newest_backup_info()
if not backup["found"]:
alert_list.append({
"id": "backup_missing",
"severity": "warning",
"message": "No backup folders found in any known path",
})
else:
age_h = backup.get("age_hours", 0)
if age_h > 48:
alert_list.append({
"id": "backup_stale",
"severity": "critical",
"message": f"Latest backup is {backup.get('age_days', '?')} days old ({backup.get('name', '?')})",
})
elif age_h > 26:
alert_list.append({
"id": "backup_old",
"severity": "warning",
"message": f"Latest backup is {round(age_h, 0):.0f}h old ({backup.get('name', '?')})",
})
severity_counts: Dict[str, int] = {"info": 0, "warning": 0, "critical": 0}
for a in alert_list:
sev = a.get("severity", "info")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
return {
"total": len(alert_list),
"counts_by_severity": severity_counts,
"alerts": alert_list,
}
# ---------------------------------------------------------------------------
# 8. GET /admin/dashboard_summary
# ---------------------------------------------------------------------------
@router.get("/dashboard_summary")
def dashboard_summary() -> Dict[str, Any]:
now = datetime.now(timezone.utc)
disk = _disk_usage()
counts = _license_counts()
backup = _newest_backup_info()
alert_data = alerts()
stripe_ok = _stripe_env_ok()
total_licenses = sum(counts.values())
active = counts.get("active", 0)
canceled = counts.get("canceled", 0)
rev: Dict[str, Any] = {
"gross_chf": None,
"refunds_chf": None,
"net_chf": None,
"data_source": "none",
}
stripe_key = os.environ.get("STRIPE_SECRET_KEY", "").strip()
if stripe_key:
try:
import stripe as _stripe
_stripe.api_key = stripe_key
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_start_ts = int(month_start.timestamp())
gross = 0
for ch in _stripe.Charge.list(created={"gte": month_start_ts}, limit=100).auto_paging_iter():
if ch.status == "succeeded" and ch.paid:
gross += ch.amount
refund_total = 0
for rf in _stripe.Refund.list(created={"gte": month_start_ts}, limit=100).auto_paging_iter():
if rf.status == "succeeded":
refund_total += rf.amount
rev = {
"gross_chf": round(gross / 100, 2),
"refunds_chf": round(refund_total / 100, 2),
"net_chf": round((gross - refund_total) / 100, 2),
"data_source": "stripe_api",
}
except Exception:
rev["data_source"] = "stripe_api_error"
return {
"timestamp_utc": now.isoformat(),
"system_ok": True,
"stripe_ok": stripe_ok,
"disk_free_gb": disk.get("free_gb"),
"disk_used_percent": disk.get("used_percent"),
"latest_backup": backup if backup["found"] else None,
"licenses": {
"total": total_licenses,
"active": active,
"canceled": canceled,
"other": total_licenses - active - canceled,
"counts_by_status": counts,
},
"current_month_revenue": rev,
"alerts_total": alert_data["total"],
"alerts_by_severity": alert_data["counts_by_severity"],
}
# ---------------------------------------------------------------------------
# 9. GET /admin/devices?email=...
# ---------------------------------------------------------------------------
@router.get("/devices")
def admin_devices(email: Optional[str] = None) -> Dict[str, Any]:
"""Device overview for a customer email. If no email given, list all emails with devices."""
from aza_device_enforcement import list_devices_for_email, DB_PATH as _DEV_DB
db_path = str(_BASE_DIR / "data" / "stripe_webhook.sqlite")
try:
from stripe_routes import DB_PATH as _SR_DB # type: ignore
db_path = _SR_DB
except Exception:
pass
if email and email.strip():
return list_devices_for_email(email.strip(), db_path=db_path)
try:
con = sqlite3.connect(db_path)
rows = con.execute(
"""SELECT customer_email, COUNT(*) AS device_count,
MAX(last_seen_at) AS last_active
FROM device_bindings
GROUP BY lower(customer_email)
ORDER BY last_active DESC"""
).fetchall()
con.close()
return {
"customers": [
{"email": r[0], "device_count": r[1], "last_active": r[2]}
for r in rows
]
}
except Exception as exc:
return {"error": str(exc), "customers": []}